From ccdffdb62619d98dd9e2a832daf57eb2d289d17d Mon Sep 17 00:00:00 2001 From: P0luz Date: Tue, 21 Apr 2026 18:45:52 +0800 Subject: [PATCH] spec: add BEHAVIOR_SPEC and fix B-01~B-10 (resolved/decay/scoring) - Add BEHAVIOR_SPEC.md as full system behaviour reference - B-01: stop auto-archiving resolved buckets in update() - B-03: keep activation_count as float in calculate_score - B-04: initialise activation_count=0 on create - B-05: time score coefficient 0.1 -> 0.02 - B-06: w_time default 2.5 -> 1.5 - B-07: content_weight default 3.0 -> 1.0 - B-08: refresh local meta after auto_resolve - B-09: user-supplied valence/arousal takes priority over analyze() - B-10: allow empty domain for feel buckets - Refresh INTERNALS/README/dashboard accordingly --- BEHAVIOR_SPEC.md | 595 ++++++++++++++++++++++++++++++++++++++++ ENV_VARS.md | 16 ++ INTERNALS.md | 51 ++-- README.md | 21 ++ bucket_manager.py | 19 +- dashboard.html | 186 ++++++++++++- import_memory.py | 23 +- server.py | 305 +++++++++++++++++++- tests/test_feel_flow.py | 3 +- tests/test_scoring.py | 3 +- 10 files changed, 1186 insertions(+), 36 deletions(-) create mode 100644 BEHAVIOR_SPEC.md create mode 100644 ENV_VARS.md diff --git a/BEHAVIOR_SPEC.md b/BEHAVIOR_SPEC.md new file mode 100644 index 0000000..8d90e83 --- /dev/null +++ b/BEHAVIOR_SPEC.md @@ -0,0 +1,595 @@ +# Ombre Brain 用户全流程行为规格书 + +> 版本:基于 server.py / bucket_manager.py / decay_engine.py / dehydrator.py / embedding_engine.py / CLAUDE_PROMPT.md / config.example.yaml + +--- + +## 一、系统角色说明 + +### 1.1 参与方总览 + +| 角色 | 实体 | 职责边界 | +|------|------|---------| +| **用户** | 人类 | 发起对话,提供原始内容;可直接访问 Dashboard Web UI | +| **Claude(模型端)** | LLM(如 Claude 3.x)| 理解语义、决策何时调用工具、用自然语言回应用户;不直接操作文件 | +| **OB 服务端** | `server.py` + 各模块 | 接收 MCP 工具调用,执行持久化、搜索、衰减;对 Claude 不透明 | + +### 1.2 Claude 端职责边界 +- **必须做**:每次新对话第一步无参调用 `breath()`;对话内容有记忆价值时主动调用 `hold` / `grow` +- **不做**:不直接读写 `.md` 文件;不执行衰减计算;不操作 SQLite +- **决策权**:Claude 决定是否存、存哪些、何时 resolve;OB 决定如何存(合并/新建) + +### 1.3 OB 服务端内部模块职责 + +| 模块 | 核心职责 | +|------|---------| +| `server.py` | 注册 MCP 工具(`breath/hold/grow/trace/pulse/dream`);路由 Dashboard HTTP 请求;`_merge_or_create()` 合并逻辑中枢 | +| `bucket_manager.py` | 桶 CRUD;多维搜索(fuzzy + embedding 双通道);`touch()` 激活刷新;`_time_ripple()` 时间波纹 | +| `dehydrator.py` | `analyze()` 自动打标;`merge()` 内容融合;`digest()` 日记拆分;`dehydrate()` 内容压缩 | +| `embedding_engine.py` | `generate_and_store()` 生成向量并存 SQLite;`search_similar()` 余弦相似度检索 | +| `decay_engine.py` | `calculate_score()` 衰减分计算;`run_decay_cycle()` 周期扫描归档;后台定时循环 | +| `utils.py` | 配置加载;路径安全校验;ID 生成;token 估算 | + +--- + +## 二、场景全流程 + +--- + +### 场景 1:新对话开始(冷启动,无历史记忆) + +**用户操作**:打开新对话窗口,说第一句话 + +**Claude 行为**:在任何回复之前,先调用 `breath()`(无参) + +**OB 工具调用**: +``` +breath(query="", max_tokens=10000, domain="", valence=-1, arousal=-1, max_results=20, importance_min=-1) +``` + +**系统内部发生什么**: +1. `decay_engine.ensure_started()` — 懒加载启动后台衰减循环(若未运行) +2. 进入"浮现模式"(`not query or not query.strip()`) +3. `bucket_mgr.list_all(include_archive=False)` — 遍历 `permanent/` + `dynamic/` + `feel/` 目录,加载所有 `.md` 文件的 frontmatter + 正文 +4. 筛选钉选桶(`pinned=True` 或 `protected=True`) +5. 筛选未解决桶(`resolved=False`,排除 `permanent/feel/pinned`) +6. **冷启动检测**:找 `activation_count==0 && importance>=8` 的桶,最多取 2 个插入排序最前 +7. 按 `decay_engine.calculate_score(metadata)` 降序排列剩余未解决桶 +8. 对 top-20 以外随机洗牌(top-1 固定,2~20 随机) +9. 截断到 `max_results` 条 +10. 对每个桶调用 `dehydrator.dehydrate(strip_wikilinks(content), clean_meta)` 压缩摘要 +11. 按 `max_tokens` 预算截断输出 + +**返回结果**: +- 无记忆时:`"权重池平静,没有需要处理的记忆。"` +- 有记忆时:`"=== 核心准则 ===\n📌 ...\n\n=== 浮现记忆 ===\n[权重:X.XX] [bucket_id:xxx] ..."` + +**注意**:浮现模式**不调用** `touch()`,不重置衰减计时器 + +--- + +### 场景 2:新对话开始(有历史记忆,breath 自动浮现) + +(与场景 1 相同流程,区别在于桶文件已存在) + +**Claude 行为(完整对话启动序列,来自 CLAUDE_PROMPT.md)**: +``` +1. breath() — 浮现未解决记忆 +2. dream() — 消化最近记忆,有沉淀写 feel +3. breath(domain="feel") — 读取之前的 feel +4. 开始和用户说话 +``` + +**`breath(domain="feel")` 内部流程**: +1. 检测到 `domain.strip().lower() == "feel"` → 进入 feel 专用通道 +2. `bucket_mgr.list_all()` 过滤 `type=="feel"` 的桶 +3. 按 `created` 降序排列 +4. 按 `max_tokens` 截断,不压缩(直接展示原文) +5. 返回:`"=== 你留下的 feel ===\n[时间] [bucket_id:xxx]\n内容..."` + +--- + +### 场景 3:用户说了一件事,Claude 决定存入记忆(hold) + +**用户操作**:例如"我刚刚拿到了实习 offer,有点激动" + +**Claude 行为**:判断值得记忆,调用: +```python +hold(content="用户拿到实习 offer,情绪激动", importance=7) +``` + +**OB 工具调用**:`hold(content, tags="", importance=7, pinned=False, feel=False, source_bucket="", valence=-1, arousal=-1)` + +**系统内部发生什么**: + +1. `decay_engine.ensure_started()` +2. 输入校验:`content.strip()` 非空 +3. `importance = max(1, min(10, 7))` = 7 +4. `extra_tags = []`(未传 tags) +5. **自动打标**:`dehydrator.analyze(content)` → 调用 `_api_analyze()` → LLM 返回 JSON + - 返回示例:`{"domain": ["成长", "求职"], "valence": 0.8, "arousal": 0.7, "tags": ["实习", "offer", "激动", ...], "suggested_name": "实习offer获得"}` + - 失败时降级:`{"domain": ["未分类"], "valence": 0.5, "arousal": 0.3, "tags": [], "suggested_name": ""}` +6. 合并 `auto_tags + extra_tags` 去重 +7. **合并检测**:`_merge_or_create(content, tags, importance=7, domain, valence, arousal, name)` + - `bucket_mgr.search(content, limit=1, domain_filter=domain)` — 搜索最相似的桶 + - 若最高分 > `config["merge_threshold"]`(默认 75)且该桶非 pinned/protected: + - `dehydrator.merge(old_content, new_content)` → `_api_merge()` → LLM 融合 + - `bucket_mgr.update(bucket_id, content=merged, tags=union, importance=max, domain=union, valence=avg, arousal=avg)` + - `embedding_engine.generate_and_store(bucket_id, merged_content)` 更新向量 + - 返回 `(bucket_name, True)` + - 否则: + - `bucket_mgr.create(content, tags, importance=7, domain, valence, arousal, name)` → 写 `.md` 文件到 `dynamic/<主题域>/` 目录 + - `embedding_engine.generate_and_store(bucket_id, content)` 生成并存储向量 + - 返回 `(bucket_id, False)` + +**返回结果**: +- 新建:`"新建→实习offer获得 成长,求职"` +- 合并:`"合并→求职经历 成长,求职"` + +**bucket_mgr.create() 详情**: +- `generate_bucket_id()` → `uuid4().hex[:12]` +- `sanitize_name(name)` → 正则清洗,最长 80 字符 +- 写 YAML frontmatter + 正文到 `safe_path(domain_dir, f"{name}_{id}.md")` +- frontmatter 字段:`id, name, tags, domain, valence, arousal, importance, type, created, last_active, activation_count=1` + +--- + +### 场景 4:用户说了一段长日记,Claude 整理存入(grow) + +**用户操作**:发送一大段混合内容,如"今天去医院体检,结果还好;晚上和朋友吃饭聊了很多;最近有点焦虑..." + +**Claude 行为**: +```python +grow(content="今天去医院体检,结果还好;晚上和朋友吃饭聊了很多;最近有点焦虑...") +``` + +**系统内部发生什么**: + +1. `decay_engine.ensure_started()` +2. 内容长度检查:`len(content.strip()) < 30` → 若短于 30 字符走**快速路径**(`dehydrator.analyze()` + `_merge_or_create()`,跳过 digest) +3. **日记拆分**(正常路径):`dehydrator.digest(content)` → `_api_digest()` → LLM 调用 `DIGEST_PROMPT` + - LLM 返回 JSON 数组,每项含:`name, content, domain, valence, arousal, tags, importance` + - `_parse_digest()` 安全解析,校验 valence/arousal 范围 +4. 对每个 `item` 调用 `_merge_or_create(item["content"], item["tags"], item["importance"], item["domain"], item["valence"], item["arousal"], item["name"])` + - 每项独立走合并或新建逻辑(同场景 3) + - 单条失败不影响其他条(`try/except` 隔离) + +**返回结果**: +``` +3条|新2合1 +📝体检结果 +📌朋友聚餐 +📎近期焦虑情绪 +``` + +--- + +### 场景 5:用户想找某段记忆(breath 带 query 检索) + +**用户操作**:例如"还记得我之前说过关于实习的事吗" + +**Claude 行为**: +```python +breath(query="实习", domain="成长", valence=0.7, arousal=0.5) +``` + +**系统内部发生什么**: + +1. `decay_engine.ensure_started()` +2. 检测到 `query` 非空,进入**检索模式** +3. 解析 `domain_filter = ["成长"]`,`q_valence=0.7`,`q_arousal=0.5` +4. **关键词检索**:`bucket_mgr.search(query, limit=20, domain_filter, q_valence, q_arousal)` + - **Layer 1**:domain 预筛 → 仅保留 domain 包含"成长"的桶;若为空则回退全量 + - **Layer 1.5**(embedding 已开启时):`embedding_engine.search_similar(query, top_k=50)` → 用 embedding 候选集替换/缩小精排范围 + - **Layer 2**:多维加权精排: + - `_calc_topic_score()`: `fuzz.partial_ratio(query, name)×3 + domain×2.5 + tags×2 + body×1`,归一化 0~1 + - `_calc_emotion_score()`: `1 - √((v差²+a差²)/2)`,0~1 + - `_calc_time_score()`: `e^(-0.02×days_since_last_active)`,0~1 + - `importance_score`: `importance / 10` + - `total = topic×4 + emotion×2 + time×1.5 + importance×1`,归一化到 0~100 + - `resolved` 桶降权 ×0.3 + - 过滤 `score >= fuzzy_threshold`(默认 50),返回最多 `limit` 条 +5. 排除 pinned/protected 桶(它们在浮现模式展示) +6. **向量补充通道**(server.py 额外层):`embedding_engine.search_similar(query, top_k=20)` → 相似度 > 0.5 的桶补充到结果集(标记 `vector_match=True`) +7. 对每个结果: + - 记忆重构:若传了 `q_valence`,展示层 valence 做微调:`shift = (q_valence - 0.5) × 0.2`,最大 ±0.1 + - `dehydrator.dehydrate(strip_wikilinks(content), clean_meta)` 压缩摘要 + - `bucket_mgr.touch(bucket_id)` — 刷新 `last_active` + `activation_count += 1` + 触发 `_time_ripple()`(对 48h 内创建的邻近桶 activation_count + 0.3,最多 5 个桶) +8. **随机漂流**:若检索结果 < 3 且 `random.random() < 0.4`,随机从 `decay_score < 2.0` 的旧桶里取 1~3 条,标注 `[surface_type: random]` + +**返回结果**: +``` +[bucket_id:abc123] [重要度:7] [主题:成长] 实习offer获得:... +[语义关联] [bucket_id:def456] 求职经历... +--- 忽然想起来 --- +[surface_type: random] 某段旧记忆... +``` + +--- + +### 场景 6:用户想查看所有记忆状态(pulse) + +**用户操作**:"帮我看看你现在都记得什么" + +**Claude 行为**: +```python +pulse(include_archive=False) +``` + +**系统内部发生什么**: + +1. `bucket_mgr.get_stats()` — 遍历三个目录,统计文件数量和 KB 大小 +2. `bucket_mgr.list_all(include_archive=False)` — 加载全部桶 +3. 对每个桶:`decay_engine.calculate_score(metadata)` 计算当前权重分 +4. 按类型/状态分配图标:📌钉选 / 📦permanent / 🫧feel / 🗄️archived / ✅resolved / 💭普通 +5. 拼接每桶摘要行:`名称 bucket_id 主题 情感坐标 重要度 权重 标签` + +**返回结果**: +``` +=== Ombre Brain 记忆系统 === +固化记忆桶: 2 个 +动态记忆桶: 15 个 +归档记忆桶: 3 个 +总存储大小: 48.3 KB +衰减引擎: 运行中 + +=== 记忆列表 === +📌 [核心原则] bucket_id:abc123 主题:内心 情感:V0.8/A0.5 ... +💭 [实习offer获得] bucket_id:def456 主题:成长 情感:V0.8/A0.7 ... +``` + +--- + +### 场景 7:用户想修改/标记已解决/删除某条记忆(trace) + +#### 7a 标记已解决 + +**Claude 行为**: +```python +trace(bucket_id="abc123", resolved=1) +``` + +**系统内部**: +1. `resolved in (0, 1)` → `updates["resolved"] = True` +2. `bucket_mgr.update("abc123", resolved=True)` → 读取 `.md` 文件,更新 frontmatter,写回 +3. 后续 `breath()` 浮现时:该桶 `decay_engine.calculate_score()` 乘以 `resolved_factor=0.05`(若同时 `digested=True` 则 ×0.02) +4. `bucket_mgr.search()` 中该桶得分乘以 0.3 降权,但仍可被关键词激活 + +**返回**:`"已修改记忆桶 abc123: resolved=True → 已沉底,只在关键词触发时重新浮现"` + +#### 7b 修改元数据 + +```python +trace(bucket_id="abc123", name="新名字", importance=8, tags="焦虑,成长") +``` + +**系统内部**:收集非默认值字段 → `bucket_mgr.update()` 批量更新 frontmatter + +#### 7c 删除 + +```python +trace(bucket_id="abc123", delete=True) +``` + +**系统内部**: +1. `bucket_mgr.delete("abc123")` → `_find_bucket_file()` 定位文件 → `os.remove(file_path)` +2. `embedding_engine.delete_embedding("abc123")` → SQLite `DELETE WHERE bucket_id=?` +3. 返回:`"已遗忘记忆桶: abc123"` + +--- + +### 场景 8:记忆长期未被激活,自动衰减归档(后台 decay) + +**触发方式**:服务启动后,`decay_engine.start()` 创建后台 asyncio Task,每 `check_interval_hours`(默认 24h)执行一次 `run_decay_cycle()` + +**系统内部发生什么**: + +1. `bucket_mgr.list_all(include_archive=False)` — 获取所有活跃桶 +2. 跳过 `type in ("permanent","feel")` 或 `pinned=True` 或 `protected=True` 的桶 +3. **自动 resolve**:若 `importance <= 4` 且距上次激活 > 30 天且 `resolved=False` → `bucket_mgr.update(bucket_id, resolved=True)` +4. 对每桶调用 `calculate_score(metadata)`: + + **短期(days_since ≤ 3)**: + ``` + time_weight = 1.0 + e^(-hours/36) (t=0→×2.0, t=36h→×1.5) + emotion_weight = base(1.0) + arousal × arousal_boost(0.8) + combined = time_weight×0.7 + emotion_weight×0.3 + base_score = importance × activation_count^0.3 × e^(-λ×days) × combined + ``` + **长期(days_since > 3)**: + ``` + combined = emotion_weight×0.7 + time_weight×0.3 + ``` + **修正因子**: + - `resolved=True` → ×0.05 + - `resolved=True && digested=True` → ×0.02 + - `arousal > 0.7 && resolved=False` → ×1.5(高唤醒紧迫加成) + - `pinned/protected/permanent` → 返回 999.0(永不衰减) + - `type=="feel"` → 返回 50.0(固定) + +5. `score < threshold`(默认 0.3)→ `bucket_mgr.archive(bucket_id)` → `_move_bucket()` 将文件从 `dynamic/` 移动到 `archive/` 目录,更新 frontmatter `type="archived"` + +**返回 stats**:`{"checked": N, "archived": N, "auto_resolved": N, "lowest_score": X}` + +--- + +### 场景 9:用户使用 dream 工具进行记忆沉淀 + +**触发**:Claude 在对话启动时,`breath()` 之后调用 `dream()` + +**OB 工具调用**:`dream()`(无参数) + +**系统内部发生什么**: + +1. `bucket_mgr.list_all()` → 过滤非 `permanent/feel/pinned/protected` 桶 +2. 按 `created` 降序取前 10 条(最近新增的记忆) +3. 对每条拼接:名称、resolved 状态、domain、V/A、创建时间、正文前 500 字符 +4. **连接提示**(embedding 已开启 && 桶数 >= 2): + - 取每个最近桶的 embedding(`embedding_engine.get_embedding(bucket_id)`) + - 两两计算 `_cosine_similarity()`,找相似度最高的对 + - 若 `best_sim > 0.5` → 输出提示:`"[名A] 和 [名B] 似乎有关联 (相似度:X.XX)"` +5. **feel 结晶提示**(embedding 已开启 && feel 数 >= 3): + - 对所有 feel 桶两两计算相似度 + - 若某 feel 与 >= 2 个其他 feel 相似度 > 0.7 → 提示升级为 pinned 桶 +6. 返回标准 header 说明(引导 Claude 自省)+ 记忆列表 + 连接提示 + 结晶提示 + +**Claude 后续行为**(根据 CLAUDE_PROMPT 引导): +- `trace(bucket_id, resolved=1)` 放下可以放下的 +- `hold(content="...", feel=True, source_bucket="xxx", valence=0.6)` 写感受 +- 无沉淀则不操作 + +--- + +### 场景 10:用户使用 feel 工具记录 Claude 的感受 + +**触发**:Claude 在 dream 后决定记录某段记忆带来的感受 + +**OB 工具调用**: +```python +hold(content="她问起了警校的事,我感觉她在用问题保护自己,问是为了不去碰那个真实的恐惧。", feel=True, source_bucket="abc123", valence=0.45, arousal=0.4) +``` + +**系统内部发生什么**: + +1. `feel=True` → 进入 feel 专用路径,跳过自动打标和合并检测 +2. `feel_valence = valence`(Claude 自身视角的情绪,非事件情绪) +3. `bucket_mgr.create(content, tags=[], importance=5, domain=[], valence=feel_valence, arousal=feel_arousal, bucket_type="feel")` → 写入 `feel/` 目录 +4. `embedding_engine.generate_and_store(bucket_id, content)` — feel 桶同样有向量(供 dream 结晶检测使用) +5. 若 `source_bucket` 非空:`bucket_mgr.update(source_bucket, digested=True, model_valence=feel_valence)` → 标记源记忆已消化 + - 此后该源桶 `calculate_score()` 中 `resolved_factor = 0.02`(accelerated fade) + +**衰减特性**:feel 桶 `type=="feel"` → `calculate_score()` 固定返回 50.0,永不归档 +**检索特性**:不参与普通 `breath()` 浮现;只通过 `breath(domain="feel")` 读取 + +**返回**:`"🫧feel→"` + +--- + +### 场景 11:用户带 importance_min 参数批量拉取重要记忆 + +**Claude 行为**: +```python +breath(importance_min=8) +``` + +**系统内部发生什么**: + +1. `importance_min >= 1` → 进入**批量拉取模式**,完全跳过语义搜索 +2. `bucket_mgr.list_all(include_archive=False)` 全量加载 +3. 过滤 `importance >= 8` 且 `type != "feel"` 的桶 +4. 按 `importance` 降序排列,截断到最多 20 条 +5. 对每条调用 `dehydrator.dehydrate()` 压缩,按 `max_tokens`(默认 10000)预算截断 + +**返回**: +``` +[importance:10] [bucket_id:xxx] ...(核心原则) +--- +[importance:9] [bucket_id:yyy] ... +--- +[importance:8] [bucket_id:zzz] ... +``` + +--- + +### 场景 12:embedding 向量化检索场景(开启 embedding 时) + +**前提**:`config.yaml` 中 `embedding.enabled: true` 且 `OMBRE_API_KEY` 已配置 + +**embedding 介入的两个层次**: + +#### 层次 A:BucketManager.search() 内的 Layer 1.5 预筛 +- 调用点:`bucket_mgr.search()` → Layer 1.5 +- 函数:`embedding_engine.search_similar(query, top_k=50)` → 生成查询 embedding → SQLite 全量余弦计算 → 返回 `[(bucket_id, similarity)]` 按相似度降序 +- 作用:将精排候选集从所有桶缩小到向量最近邻的 50 个,加速后续多维精排 + +#### 层次 B:server.py breath 的额外向量通道 +- 调用点:`breath()` 检索模式中,keyword 搜索完成后 +- 函数:`embedding_engine.search_similar(query, top_k=20)` → 相似度 > 0.5 的桶补充到结果集 +- 标注:补充桶带 `[语义关联]` 前缀 + +**向量存储路径**: +- 新建桶后:`embedding_engine.generate_and_store(bucket_id, content)` → `_generate_embedding(text[:2000])` → API 调用 → `_store_embedding()` → SQLite `INSERT OR REPLACE` +- 合并更新后:同上,用 merged content 重新生成 +- 删除桶时:`embedding_engine.delete_embedding(bucket_id)` → `DELETE FROM embeddings` + +**SQLite 结构**: +```sql +CREATE TABLE embeddings ( + bucket_id TEXT PRIMARY KEY, + embedding TEXT NOT NULL, -- JSON 序列化的 float 数组 + updated_at TEXT NOT NULL +) +``` + +**相似度计算**:`_cosine_similarity(a, b)` = dot(a,b) / (|a| × |b|) + +--- + +## 三、边界与降级行为 + +| 场景 | 异常情况 | 降级行为 | +|------|---------|---------| +| `breath()` 浮现 | 桶目录为空 | 返回 `"权重池平静,没有需要处理的记忆。"` | +| `breath()` 浮现 | `list_all()` 异常 | 返回 `"记忆系统暂时无法访问。"` | +| `breath()` 检索 | `bucket_mgr.search()` 异常 | 返回 `"检索过程出错,请稍后重试。"` | +| `breath()` 检索 | embedding 不可用 / API 失败 | `logger.warning()` 记录,跳过向量通道,仅用 keyword 检索 | +| `breath()` 检索 | 结果 < 3 条 | 40% 概率从低权重旧桶随机浮现 1~3 条,标注 `[surface_type: random]` | +| `hold()` 自动打标 | `dehydrator.analyze()` 失败 | 降级到默认值:`domain=["未分类"], valence=0.5, arousal=0.3, tags=[], name=""` | +| `hold()` 合并检测 | `bucket_mgr.search()` 失败 | `logger.warning()`,直接走新建路径 | +| `hold()` 合并 | `dehydrator.merge()` 失败 | `logger.warning()`,跳过合并,直接新建 | +| `hold()` embedding | API 失败 | `try/except` 吞掉,embedding 缺失但不影响存储 | +| `grow()` 日记拆分 | `dehydrator.digest()` 失败 | 返回 `"日记整理失败: {e}"` | +| `grow()` 单条处理失败 | 单个 item 异常 | `logger.warning()` + 标注 `⚠️条目名`,其他条目正常继续 | +| `grow()` 内容 < 30 字 | — | 快速路径:`analyze()` + `_merge_or_create()`,跳过 `digest()`(节省 token) | +| `trace()` | `bucket_mgr.get()` 返回 None | 返回 `"未找到记忆桶: {bucket_id}"` | +| `trace()` | 未传任何可修改字段 | 返回 `"没有任何字段需要修改。"` | +| `pulse()` | `get_stats()` 失败 | 返回 `"获取系统状态失败: {e}"` | +| `dream()` | embedding 未开启 | 跳过连接提示和结晶提示,仅返回记忆列表 | +| `dream()` | 桶列表为空 | 返回 `"没有需要消化的新记忆。"` | +| `decay_cycle` | `list_all()` 失败 | 返回 `{"checked":0, "archived":0, ..., "error": str(e)}`,不终止后台循环 | +| `decay_cycle` | 单桶 `calculate_score()` 失败 | `logger.warning()`,跳过该桶继续 | +| 所有 feel 操作 | `source_bucket` 不存在 | `logger.warning()` 记录,feel 桶本身仍成功创建 | +| `dehydrator.dehydrate()` | API 不可用(`api_available=False`)| ⚠️ **无本地 fallback**,直接返回原始内容或抛出异常 | +| `embedding_engine.search_similar()` | `enabled=False` | 直接返回 `[]`,调用方 fallback 到 keyword 搜索 | + +--- + +## 四、数据流图 + +### 4.1 一条记忆的完整生命周期 + +``` +用户输入内容 + │ + ▼ +Claude 决策: hold / grow / 自动 + │ + ├─[grow 长内容]──→ dehydrator.digest(content) + │ DIGEST_PROMPT → LLM API + │ 返回 [{name,content,domain,...}] + │ ↓ 每条独立处理 ↓ + │ + └─[hold 单条]──→ dehydrator.analyze(content) + ANALYZE_PROMPT → LLM API + 返回 {domain, valence, arousal, tags, suggested_name} + │ + ▼ + _merge_or_create() + │ + bucket_mgr.search(content, limit=1, domain_filter) + │ + ┌─────┴─────────────────────────┐ + │ score > merge_threshold (75)? │ + │ │ + YES NO + │ │ + ▼ ▼ + dehydrator.merge( bucket_mgr.create( + old_content, new) content, tags, + MERGE_PROMPT → LLM importance, domain, + │ valence, arousal, + ▼ bucket_type="dynamic" + bucket_mgr.update(...) ) + │ │ + └──────────┬─────────────┘ + │ + ▼ + embedding_engine.generate_and_store( + bucket_id, content) + → _generate_embedding(text[:2000]) + → API 调用 (gemini-embedding-001) + → _store_embedding() → SQLite + │ + ▼ + 文件写入: {buckets_dir}/dynamic/{domain}/{name}_{id}.md + YAML frontmatter: + id, name, tags, domain, valence, arousal, + importance, type="dynamic", created, last_active, + activation_count=1 + │ + ▼ + ┌─────── 记忆桶存活期 ──────────────────────────────────────┐ + │ │ + │ 每次被 breath(query) 检索命中: │ + │ bucket_mgr.touch(bucket_id) │ + │ → last_active = now_iso() │ + │ → activation_count += 1 │ + │ → _time_ripple(source_id, now, hours=48) │ + │ 对 48h 内邻近桶 activation_count += 0.3 │ + │ │ + │ 被 dream() 消化: │ + │ hold(feel=True, source_bucket=id) → │ + │ bucket_mgr.update(id, digested=True) │ + │ │ + │ 被 trace(resolved=1) 标记: │ + │ resolved=True → decay score ×0.05 (或 ×0.02) │ + │ │ + └───────────────────────────────────────────────────────────┘ + │ + ▼ + decay_engine 后台循环 (每 check_interval_hours=24h) + run_decay_cycle() + → 列出所有动态桶 + → calculate_score(metadata) + importance × activation_count^0.3 + × e^(-λ×days) + × combined_weight + × resolved_factor + × urgency_boost + → score < threshold (0.3)? + │ + ┌─────┴──────┐ + │ │ + YES NO + │ │ + ▼ ▼ + bucket_mgr.archive(id) 继续存活 + → _move_bucket() + → 文件移动到 archive/ + → frontmatter type="archived" + │ + ▼ + 记忆桶归档(不再参与浮现/搜索) + 但文件仍存在,可通过 pulse(include_archive=True) 查看 +``` + +### 4.2 feel 桶的特殊路径 + +``` +hold(feel=True, source_bucket="xxx", valence=0.45) + │ + ▼ + bucket_mgr.create(bucket_type="feel") + 写入 feel/ 目录 + │ + ├─→ embedding_engine.generate_and_store()(供 dream 结晶检测) + │ + └─→ bucket_mgr.update(source_bucket, digested=True, model_valence=0.45) + 源桶 resolved_factor → 0.02 + 加速衰减直到归档 + +feel 桶自身: + - calculate_score() 返回固定 50.0 + - 不参与普通 breath 浮现 + - 不参与 dreaming 候选 + - 只通过 breath(domain="feel") 读取 + - 永不归档 +``` + +--- + +## 五、⚠️ 待实现项汇总 + +以下功能在现有代码中**未找到对应实现**,标注如下: + +| 编号 | 描述 | 涉及位置 | +|------|------|---------| +| ⚠️-1 | `dehydrator.dehydrate()` **无本地降级 fallback**。当 `api_available=False` 时代码路径缺失(与注释"API 不可用时自动降级到本地关键词提取"不符)。`dehydrate()` 方法直接假设 API 可用。 | `dehydrator.py` `dehydrate()` + `_api_dehydrate()` | +| ⚠️-2 | `decay_engine.run_decay_cycle()` 中 `auto_resolved` 逻辑的具体实现被摘要省略(Lines 211-215),无法确认 `days_since` 的计算和 `bucket_mgr.update(resolved=True)` 调用是否完整存在。 | `decay_engine.py` Lines 211-220 | +| ⚠️-3 | `breath()` 浮现模式对**已归档桶**的处理逻辑:`list_all(include_archive=False)` 已正确排除归档桶,但 feel 桶的 `feel/` 子目录是否在 `list_all()` 中被遍历,需核实 `bucket_manager.py` 的 `list_all()` 实现(Lines 623-645 已摘要)。 | `bucket_manager.py` `list_all()` | +| ⚠️-4 | `_time_ripple()` 的 `activation_count` 使用浮点数累加(+0.3),但 `calculate_score()` 中 `activation_count = max(1, int(...))` 会截断小数。浮点增量实际上对 score 无效果(任何 < 1 的浮点增量在 int() 后丢失)。 | `bucket_manager.py` `_time_ripple()` + `decay_engine.py` `calculate_score()` | +| ⚠️-5 | Dashboard 路由(`/api/buckets`, `/api/search`, `/api/network` 等)未有认证保护说明——auth 中间件 `_require_auth()` 的调用是否覆盖全部 `/api/*` 路由,在摘要版代码中无法确认。 | `server.py` Lines 1211+ | + +--- + +*本文档基于代码直接推导,每个步骤均可对照源文件函数名和行为验证。如代码更新,请同步修订此文档。* diff --git a/ENV_VARS.md b/ENV_VARS.md new file mode 100644 index 0000000..88472c9 --- /dev/null +++ b/ENV_VARS.md @@ -0,0 +1,16 @@ +# 环境变量参考 + +| 变量名 | 必填 | 默认值 | 说明 | +|--------|------|--------|------| +| `OMBRE_API_KEY` | 是 | — | Gemini / OpenAI-compatible API Key,用于脱水(dehydration)和向量嵌入 | +| `OMBRE_BASE_URL` | 否 | `https://generativelanguage.googleapis.com/v1beta/openai/` | API Base URL(可替换为代理或兼容接口) | +| `OMBRE_TRANSPORT` | 否 | `stdio` | MCP 传输模式:`stdio` / `sse` / `streamable-http` | +| `OMBRE_BUCKETS_DIR` | 否 | `./buckets` | 记忆桶文件存放目录(绑定 Docker Volume 时务必设置) | +| `OMBRE_HOOK_URL` | 否 | — | Breath/Dream Webhook 回调地址,留空则不推送 | +| `OMBRE_HOOK_SKIP` | 否 | `false` | 设为 `true` 跳过 Webhook 推送 | +| `OMBRE_DASHBOARD_PASSWORD` | 否 | — | 预设 Dashboard 访问密码;设置后覆盖文件存储的密码,首次访问不弹设置向导 | + +## 说明 + +- `OMBRE_API_KEY` 也可在 `config.yaml` 的 `dehydration.api_key` / `embedding.api_key` 中设置,但**强烈建议**通过环境变量传入,避免密钥写入文件。 +- `OMBRE_DASHBOARD_PASSWORD` 设置后,Dashboard 的"修改密码"功能将被禁用(显示提示,建议直接修改环境变量)。未设置则密码存储在 `{buckets_dir}/.dashboard_auth.json`(SHA-256 + salt)。 diff --git a/INTERNALS.md b/INTERNALS.md index 9980ffe..020ff21 100644 --- a/INTERNALS.md +++ b/INTERNALS.md @@ -76,7 +76,7 @@ | 工具 | 关键参数 | 功能 | |---|---|---| -| `breath` | query, max_tokens, domain, valence, arousal, max_results | 检索/浮现记忆 | +| `breath` | query, max_tokens, domain, valence, arousal, max_results, **importance_min** | 检索/浮现记忆 | | `hold` | content, tags, importance, pinned, feel, source_bucket, valence, arousal | 存储记忆 | | `grow` | content | 日记拆分归档 | | `trace` | bucket_id, name, domain, valence, arousal, importance, tags, resolved, pinned, digested, content, delete | 修改元数据/内容/删除 | @@ -85,10 +85,11 @@ **工具详细行为** -**`breath`** — 两种模式: -- **浮现模式**(无 query):无参调用,按衰减引擎活跃度排序返回 top 记忆,permanent/pinned 始终浮现 +**`breath`** — 三种模式: +- **浮现模式**(无 query):无参调用,按衰减引擎活跃度排序返回 top 记忆,钉选桶始终展示;冷启动检测(`activation_count==0 && importance>=8`)的桶最多 2 个插入最前,再 Top-1 固定 + Top-20 随机打乱 - **检索模式**(有 query):关键词 + 向量双通道搜索,四维评分(topic×4 + emotion×2 + time×2.5 + importance×1),阈值过滤 - **Feel 检索**(`domain="feel"`):特殊通道,按创建时间倒序返回所有 feel 类型桶,不走评分逻辑 +- **重要度批量模式**(`importance_min>=1`):跳过语义搜索,直接筛选 importance≥importance_min 的桶,按 importance 降序,最多 20 条 - 若指定 valence,对匹配桶的 valence 微调 ±0.1(情感记忆重构) **`hold`** — 两种模式: @@ -120,26 +121,41 @@ | `/breath-hook` | GET | SessionStart 钩子 | | `/dream-hook` | GET | Dream 钩子 | | `/dashboard` | GET | Dashboard 页面 | -| `/api/buckets` | GET | 桶列表 | -| `/api/bucket/{id}` | GET | 桶详情 | -| `/api/search?q=` | GET | 搜索 | -| `/api/network` | GET | 向量相似网络 | -| `/api/breath-debug` | GET | 评分调试 | -| `/api/config` | GET | 配置查看(key 脱敏) | -| `/api/config` | POST | 热更新配置 | -| `/api/import/upload` | POST | 上传并启动历史对话导入 | -| `/api/import/status` | GET | 导入进度查询 | -| `/api/import/pause` | POST | 暂停/继续导入 | -| `/api/import/patterns` | GET | 导入完成后词频规律检测 | -| `/api/import/results` | GET | 已导入记忆桶列表 | -| `/api/import/review` | POST | 批量审阅/批准导入结果 | +| `/api/buckets` | GET | 桶列表 🔒 | +| `/api/bucket/{id}` | GET | 桶详情 🔒 | +| `/api/search?q=` | GET | 搜索 🔒 | +| `/api/network` | GET | 向量相似网络 🔒 | +| `/api/breath-debug` | GET | 评分调试 🔒 | +| `/api/config` | GET | 配置查看(key 脱敏)🔒 | +| `/api/config` | POST | 热更新配置 🔒 | +| `/api/status` | GET | 系统状态(版本/桶数/引擎)🔒 | +| `/api/import/upload` | POST | 上传并启动历史对话导入 🔒 | +| `/api/import/status` | GET | 导入进度查询 🔒 | +| `/api/import/pause` | POST | 暂停/继续导入 🔒 | +| `/api/import/patterns` | GET | 导入完成后词频规律检测 🔒 | +| `/api/import/results` | GET | 已导入记忆桶列表 🔒 | +| `/api/import/review` | POST | 批量审阅/批准导入结果 🔒 | +| `/auth/status` | GET | 认证状态(是否需要初始化密码)| +| `/auth/setup` | POST | 首次设置密码 | +| `/auth/login` | POST | 密码登录,颁发 session cookie | +| `/auth/logout` | POST | 注销 session | +| `/auth/change-password` | POST | 修改密码 🔒 | -**Dashboard(5 个 Tab)** +> 🔒 = 需要 Dashboard 认证(未认证返回 401 JSON) + +**Dashboard 认证** +- 密码存储:SHA-256 + 随机 salt,保存于 `{buckets_dir}/.dashboard_auth.json` +- 环境变量 `OMBRE_DASHBOARD_PASSWORD` 设置后,覆盖文件密码(只读,不可通过 Dashboard 修改) +- Session:内存字典(服务重启失效),cookie `ombre_session`(HttpOnly, SameSite=Lax, 7天) +- `/health`, `/breath-hook`, `/dream-hook`, `/mcp*` 路径不受保护(公开) + +**Dashboard(6 个 Tab)** 1. 记忆桶列表:6 种过滤器 + 主题域过滤 + 搜索 + 详情面板 2. Breath 模拟:输入参数 → 可视化五步流程 → 四维条形图 3. 记忆网络:Canvas 力导向图(节点=桶,边=相似度) 4. 配置:热更新脱水/embedding/合并参数 5. 导入:历史对话拖拽上传 → 分块处理进度条 → 词频规律分析 → 导入结果审阅 +6. 设置:服务状态监控、修改密码、退出登录 **部署选项** 1. 本地 stdio(`python server.py`) @@ -172,6 +188,7 @@ | `OMBRE_BUCKETS_DIR` | 记忆桶存储目录路径 | 否 | `""` → 回退到 config 或 `./buckets` | | `OMBRE_HOOK_URL` | SessionStart 钩子调用的服务器 URL | 否 | `"http://localhost:8000"` | | `OMBRE_HOOK_SKIP` | 设为 `"1"` 跳过 SessionStart 钩子 | 否 | 未设置(不跳过) | +| `OMBRE_DASHBOARD_PASSWORD` | 预设 Dashboard 访问密码;设置后覆盖文件密码,首次访问不弹设置向导 | 否 | `""` | 环境变量优先级:`环境变量 > config.yaml > 硬编码默认值`。所有环境变量在 `utils.py` 中读取并注入 config dict。 diff --git a/README.md b/README.md index 76cedd3..a71a922 100644 --- a/README.md +++ b/README.md @@ -435,6 +435,27 @@ Sensitive config via env vars: - `OMBRE_API_KEY` — LLM API 密钥 - `OMBRE_TRANSPORT` — 覆盖传输方式 - `OMBRE_BUCKETS_DIR` — 覆盖存储路径 +- `OMBRE_DASHBOARD_PASSWORD` — Dashboard 访问密码(可选,见下) + +## Dashboard 认证 / Dashboard Auth + +自 v1.3.0 起,Dashboard 和所有 `/api/*` 端点均受密码保护。 +Since v1.3.0, the Dashboard and all `/api/*` endpoints are password-protected. + +**首次访问**:若未设置密码,浏览器会弹出设置向导,填写并确认密码后即可使用。 +**First visit**: If no password is set, a setup wizard will appear. Enter and confirm a password to get started. + +**通过环境变量预设密码**:在 `docker-compose.user.yml` 中添加: +**Pre-set via env var** in your `docker-compose.user.yml`: +```yaml +environment: + - OMBRE_DASHBOARD_PASSWORD=your_password_here +``` +设置后,Dashboard 的"修改密码"功能将被禁用,必须通过环境变量修改。 +When set, the in-Dashboard password change is disabled — modify the env var directly. + +完整环境变量说明见 [ENV_VARS.md](ENV_VARS.md)。 +Full env var reference: [ENV_VARS.md](ENV_VARS.md). ## 衰减公式 / Decay Formula diff --git a/bucket_manager.py b/bucket_manager.py index f815eb8..1a430c8 100644 --- a/bucket_manager.py +++ b/bucket_manager.py @@ -54,7 +54,7 @@ class BucketManager: 天然兼容 Obsidian 直接浏览和编辑。 """ - def __init__(self, config: dict): + def __init__(self, config: dict, embedding_engine=None): # --- Read storage paths from config / 从配置中读取存储路径 --- self.base_dir = config["buckets_dir"] self.permanent_dir = os.path.join(self.base_dir, "permanent") @@ -92,6 +92,9 @@ class BucketManager: self.w_importance = scoring.get("importance", 1.0) self.content_weight = scoring.get("content_weight", 3.0) # Added to allow better content-based matching during merge + # --- Optional embedding engine for pre-filtering / 可选 embedding 引擎,用于预筛候选集 --- + self.embedding_engine = embedding_engine + # --------------------------------------------------------- # Create a new bucket # 创建新桶 @@ -473,6 +476,20 @@ class BucketManager: else: candidates = all_buckets + # --- Layer 1.5: embedding pre-filter (optional, reduces multi-dim ranking set) --- + # --- 第1.5层:embedding 预筛(可选,缩小精排候选集)--- + if self.embedding_engine and self.embedding_engine.enabled: + try: + vector_results = await self.embedding_engine.search_similar(query, top_k=50) + if vector_results: + vector_ids = {bid for bid, _ in vector_results} + emb_candidates = [b for b in candidates if b["id"] in vector_ids] + if emb_candidates: # only replace if there's non-empty overlap + candidates = emb_candidates + # else: keep original candidates as fallback + except Exception as e: + logger.warning(f"Embedding pre-filter failed, using fuzzy only / embedding 预筛失败: {e}") + # --- Layer 2: weighted multi-dim ranking --- # --- 第二层:多维加权精排 --- scored = [] diff --git a/dashboard.html b/dashboard.html index eddab40..dd3ad9d 100644 --- a/dashboard.html +++ b/dashboard.html @@ -607,6 +607,7 @@ +
@@ -615,6 +616,7 @@
记忆网络
配置
导入
+
设置
@@ -778,7 +780,186 @@
+ + + + +
+
+

◐ Ombre Brain

+

验证身份

+ + + + + + + + +
+
+