Compare commits

...

12 Commits

Author SHA1 Message Date
P0luz
b869a111c7 feat: add base_url env vars, iCloud conflict detector, user compose guidance
Some checks are pending
Build & Push Docker Image / build-and-push (push) Waiting to run
Tests / test (push) Waiting to run
- utils.py: support OMBRE_DEHYDRATION_BASE_URL and OMBRE_EMBEDDING_BASE_URL
  so Gemini/non-DeepSeek users can configure without mounting a custom config
- docker-compose.user.yml: pass all 4 model/url env vars from .env;
  add commented Gemini example + optional config.yaml mount hint
- ENV_VARS.md: document OMBRE_DEHYDRATION_BASE_URL and OMBRE_EMBEDDING_BASE_URL
- check_icloud_conflicts.py: scan bucket dir for iCloud conflict artefacts
  and duplicate bucket IDs (report-only, no file modifications)
2026-04-21 19:18:32 +08:00
P0luz
cddc809f02 chore(gitignore): exclude private memory data and dev test suites
- data/ : local user memory buckets (privacy)
- tests/integration/, tests/regression/, tests/unit/ :
  developer-only test suites kept out of upstream
2026-04-21 19:05:22 +08:00
P0luz
2646f8f7d0 docs: refresh INTERNALS / README / dashboard after B-fix series 2026-04-21 19:05:18 +08:00
P0luz
b318e557b0 fix: complete B-03/B-08/B-09 and add OMBRE_*_MODEL env vars
- decay_engine: keep activation_count as float (B-03);
  refresh local meta after auto_resolve so resolved_factor
  applies in the same cycle (B-08)
- server.hold(): user-supplied valence/arousal now takes
  priority over analyze() output (B-09)
- utils.load_config: support OMBRE_DEHYDRATION_MODEL
  (with OMBRE_MODEL alias) and OMBRE_EMBEDDING_MODEL
- ENV_VARS.md: document new model env vars
- tests/conftest.py: align fixture with spec-correct weights
  (time_proximity=1.5, content_weight=1.0) and feel subdir layout
2026-04-21 19:05:08 +08:00
P0luz
d2d4b89715 fix(search): keep resolved buckets reachable by keyword
Apply ×0.3 resolved penalty *after* fuzzy_threshold filter so
resolved buckets that genuinely match the query still surface
in search results (penalty only affects ranking order).
Update BEHAVIOR_SPEC.md scoring section to document new order.
2026-04-21 18:46:04 +08:00
P0luz
ccdffdb626 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
2026-04-21 18:45:52 +08:00
P0luz
c7ddfd46ad Merge pull request #3 from msz136/main 2026-04-21 13:29:18 +08:00
mousongzhe
2d2de45d5a 单独配置embedding模型 2026-04-21 13:21:23 +08:00
P0luz
e9d61b5d9d fix: 移除本地保底脱水的过时描述(README+dehydrator注释)
Some checks failed
Build & Push Docker Image / build-and-push (push) Has been cancelled
Tests / test (push) Has been cancelled
2026-04-19 18:19:04 +08:00
P0luz
d1cd3f4cc7 docs: 添加各部署方式的更新指南(Docker Hub/源码/Render/Zeabur/VPS) 2026-04-19 18:03:04 +08:00
P0luz
5815be6b69 docs: 云部署补充Dashboard地址, 修正脱水API描述(已移除local fallback) 2026-04-19 18:00:31 +08:00
P0luz
3b5f37c7ca docs: README补充前端Dashboard地址和端口说明 2026-04-19 17:51:59 +08:00
19 changed files with 1846 additions and 77 deletions

4
.gitignore vendored
View File

@@ -15,3 +15,7 @@ scarp_paper
backup_*/ backup_*/
*.db *.db
import_state.json import_state.json
data/
tests/integration/
tests/regression/
tests/unit/

632
BEHAVIOR_SPEC.md Normal file
View File

@@ -0,0 +1,632 @@
# 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 决定是否存、存哪些、何时 resolveOB 决定如何存(合并/新建)
### 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 个插入排序最前(**决策:`create()` 初始化应为 0区分"创建"与"被主动召回",见 B-04**
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=0`**决策:初始为 0`touch()` 首次被召回后变为 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
- 过滤 `score >= fuzzy_threshold`(默认 50
- 通过阈值后,`resolved` 桶仅在排序时降权 ×0.3(不影响是否被检出)
- 返回最多 `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 中 `resolved=True`,写回,**桶留在原 `dynamic/` 目录,不移动**
3. 后续 `breath()` 浮现时:该桶 `decay_engine.calculate_score()` 乘以 `resolved_factor=0.05`(若同时 `digested=True`×0.02),自然降权,最终由 decay 引擎在得分 < threshold 时归档
4. `bucket_mgr.search()` 中该桶得分乘以 0.3 降权,但仍可被关键词激活
> ⚠️ **代码 Bug B-01**:当前实现中 `update(resolved=True)` 会将桶**立即移入 `archive/`**,导致桶完全消失于所有搜索路径,与上述规格不符。需移除 `bucket_manager.py` `update()` 中 resolved → `_move_bucket(archive_dir)` 的自动归档逻辑。
**返回**`"已修改记忆桶 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→<bucket_id>"`
---
### 场景 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] ...
```
---
### 场景 12embedding 向量化检索场景(开启 embedding 时)
**前提**`config.yaml` 中 `embedding.enabled: true` 且 `OMBRE_API_KEY` 已配置
**embedding 介入的两个层次**
#### 层次 ABucketManager.search() 内的 Layer 1.5 预筛
- 调用点:`bucket_mgr.search()` → Layer 1.5
- 函数:`embedding_engine.search_similar(query, top_k=50)` → 生成查询 embedding → SQLite 全量余弦计算 → 返回 `[(bucket_id, similarity)]` 按相似度降序
- 作用:将精排候选集从所有桶缩小到向量最近邻的 50 个,加速后续多维精排
#### 层次 Bserver.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()` / `analyze()` / `merge()` / `digest()` | API 不可用(`api_available=False`| **直接向 MCP 调用端明确报错(`RuntimeError`**,无本地降级。本地关键词提取质量不足以替代语义打标与合并,静默降级比报错更危险(可能产生错误分类记忆)。 |
| `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") 读取
- 永不归档
```
---
## 五、代码与规格差异汇总(审查版)
> 本节由完整源码审查生成2026-04-21记录原待实现项最终状态、新发现 Bug 及参数决策。
---
### 5.1 原待实现项最终状态
| 编号 | 原描述 | 状态 | 结论 |
|------|--------|------|------|
| ⚠️-1 | `dehydrate()` 无本地降级 fallback | **已确认为设计决策** | API 不可用时直接向 MCP 调用端报错RuntimeError不降级见三、降级行为表 |
| ⚠️-2 | `run_decay_cycle()` auto_resolved 实现存疑 | ✅ 已确认实现 | `decay_engine.py` 完整实现 imp≤4 + >30天 + 未解决 → `bucket_mgr.update(resolved=True)` |
| ⚠️-3 | `list_all()` 是否遍历 `feel/` 子目录 | ✅ 已确认实现 | `list_all()` dirs 明确包含 `self.feel_dir`,递归遍历 |
| ⚠️-4 | `_time_ripple()` 浮点增量被 `int()` 截断 | ❌ 已确认 Bug | 见 B-03决策见下 |
| ⚠️-5 | Dashboard `/api/*` 路由认证覆盖 | ✅ 已确认覆盖 | 所有 `/api/buckets`、`/api/search`、`/api/network`、`/api/bucket/{id}`、`/api/breath-debug` 均调用 `_require_auth(request)` |
---
### 5.2 新发现 Bug 及修复决策
| 编号 | 场景 | 严重度 | 问题描述 | 决策 & 修复方案 |
|------|------|--------|----------|----------------|
| **B-01** | 场景7a | 高 | `bucket_mgr.update(resolved=True)` 当前会将桶立即移入 `archive/`type="archived"),规格预期"降权留存、关键词可激活"。resolved 桶实质上立即从所有搜索路径消失。 | **修复**:移除 `bucket_manager.py` `update()` 中 `resolved → _move_bucket(archive_dir)` 的自动归档逻辑,仅更新 frontmatter `resolved=True`,由 decay 引擎自然衰减至 archive。 |
| **B-03** | 全局 | 高 | `_time_ripple()` 对 `activation_count` 做浮点增量(+0.3),但 `calculate_score()` 中 `max(1, int(...))` 截断小数,增量丢失,时间涟漪对衰减分无实际效果。 | **修复**`decay_engine.py` `calculate_score()` 中改为 `activation_count = max(1.0, float(metadata.get("activation_count", 1)))` |
| **B-04** | 场景1 | 中 | `bucket_manager.create()` 初始化 `activation_count=1`,冷启动检测条件 `activation_count==0` 对所有正常创建的桶永不满足,高重要度新桶不被优先浮现。 | **决策:初始化改为 `activation_count=0`**。语义上"创建"≠"被召回"`touch()` 首次命中后变为 1冷启动检测自然生效。规格已更新见场景1步骤6 & 场景3 create 详情)。 |
| **B-05** | 场景5 | 中 | `bucket_manager.py` `_calc_time_score()` 实现 `e^(-0.1×days)`,规格为 `e^(-0.02×days)`,衰减速度快 5 倍30天后时间分 ≈ 0.05(规格预期 ≈ 0.55),旧记忆时间维度近乎失效。 | **决策:保留规格值 `0.02`**。记忆系统中旧记忆应通过关键词仍可被唤醒,时间维度是辅助信号不是淘汰信号。修复:`_calc_time_score()` 改为 `return math.exp(-0.02 * days)` |
| **B-06** | 场景5 | 中 | `bucket_manager.py` `w_time` 默认值为 `2.5`,规格为 `1.5`,叠加 B-05 会导致时间维度严重偏重近期记忆。 | **决策:保留规格值 `1.5`**。修复:`w_time = scoring.get("time_proximity", 1.5)` |
| **B-07** | 场景5 | 中 | `bucket_manager.py` `content_weight` 默认值为 `3.0`,规格为 `1.0`body×1。正文权重过高导致合并检测`search(content, limit=1)`)误判——内容相似但主题不同的桶被错误合并。 | **决策:保留规格值 `1.0`**。正文是辅助信号,主要靠 name/tags/domain 识别同话题桶。修复:`content_weight = scoring.get("content_weight", 1.0)` |
| **B-08** | 场景8 | 低 | `run_decay_cycle()` 内 auto_resolve 后继续使用旧 `meta` 变量计算 score`resolved_factor=0.05` 需等下一 cycle 才生效。 | **修复**auto_resolve 成功后执行 `meta["resolved"] = True` 刷新本地 meta 变量。 |
| **B-09** | 场景3 | 低 | `hold()` 非 feel 路径中,用户显式传入的 `valence`/`arousal` 被 `analyze()` 返回值完全覆盖。 | **修复**:若用户显式传入(`0 <= valence <= 1`),优先使用用户值,`analyze()` 结果作为 fallback。 |
| **B-10** | 场景10 | 低 | feel 桶以 `domain=[]` 创建,但 `bucket_manager.create()` 中 `domain or ["未分类"]` 兜底写入 `["未分类"]`,数据不干净。 | **修复**`create()` 中对 `bucket_type=="feel"` 单独处理,允许空 domain 直接写入。 |
---
### 5.3 已确认正常实现
- `breath()` 浮现模式不调用 `touch()`,不重置衰减计时器
- `feel` 桶 `calculate_score()` 返回固定 50.0,永不归档
- `breath(domain="feel")` 独立通道,按 `created` 降序,不压缩展示原文
- `decay_engine.calculate_score()` 短期≤3天/ 长期(>3天权重分离公式
- `urgency_boost``arousal > 0.7 && !resolved → ×1.5`
- `dream()` 连接提示best_sim > 0.5+ 结晶提示feel 相似度 > 0.7 × ≥2 个)
- 所有 `/api/*` Dashboard 路由均受 `_require_auth` 保护
- `trace(delete=True)` 同步调用 `embedding_engine.delete_embedding()`
- `grow()` 单条失败 `try/except` 隔离,标注 `⚠️条目名`,其他条继续
---
*本文档基于代码直接推导,每个步骤均可对照源文件函数名和行为验证。如代码更新,请同步修订此文档。*

21
ENV_VARS.md Normal file
View File

@@ -0,0 +1,21 @@
# 环境变量参考
| 变量名 | 必填 | 默认值 | 说明 |
|--------|------|--------|------|
| `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_DEHYDRATION_MODEL` | 否 | `deepseek-chat` | 脱水/打标/合并/拆分用的 LLM 模型名(覆盖 `dehydration.model` |
| `OMBRE_DEHYDRATION_BASE_URL` | 否 | `https://api.deepseek.com/v1` | 脱水模型的 API Base URL覆盖 `dehydration.base_url` |
| `OMBRE_MODEL` | 否 | — | `OMBRE_DEHYDRATION_MODEL` 的别名(前者优先) |
| `OMBRE_EMBEDDING_MODEL` | 否 | `gemini-embedding-001` | 向量嵌入模型名(覆盖 `embedding.model` |
| `OMBRE_EMBEDDING_BASE_URL` | 否 | — | 向量嵌入的 API Base URL覆盖 `embedding.base_url`;留空则复用脱水配置) |
## 说明
- `OMBRE_API_KEY` 也可在 `config.yaml``dehydration.api_key` / `embedding.api_key` 中设置,但**强烈建议**通过环境变量传入,避免密钥写入文件。
- `OMBRE_DASHBOARD_PASSWORD` 设置后Dashboard 的"修改密码"功能将被禁用(显示提示,建议直接修改环境变量)。未设置则密码存储在 `{buckets_dir}/.dashboard_auth.json`SHA-256 + salt

View File

@@ -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 | 存储记忆 | | `hold` | content, tags, importance, pinned, feel, source_bucket, valence, arousal | 存储记忆 |
| `grow` | content | 日记拆分归档 | | `grow` | content | 日记拆分归档 |
| `trace` | bucket_id, name, domain, valence, arousal, importance, tags, resolved, pinned, digested, content, delete | 修改元数据/内容/删除 | | `trace` | bucket_id, name, domain, valence, arousal, importance, tags, resolved, pinned, digested, content, delete | 修改元数据/内容/删除 |
@@ -85,10 +85,11 @@
**工具详细行为** **工具详细行为**
**`breath`** — 种模式: **`breath`** — 种模式:
- **浮现模式**(无 query无参调用按衰减引擎活跃度排序返回 top 记忆,permanent/pinned 始终浮现 - **浮现模式**(无 query无参调用按衰减引擎活跃度排序返回 top 记忆,钉选桶始终展示;冷启动检测(`activation_count==0 && importance>=8`)的桶最多 2 个插入最前,再 Top-1 固定 + Top-20 随机打乱
- **检索模式**(有 query关键词 + 向量双通道搜索四维评分topic×4 + emotion×2 + time×2.5 + importance×1阈值过滤 - **检索模式**(有 query关键词 + 向量双通道搜索四维评分topic×4 + emotion×2 + time×2.5 + importance×1阈值过滤
- **Feel 检索**`domain="feel"`):特殊通道,按创建时间倒序返回所有 feel 类型桶,不走评分逻辑 - **Feel 检索**`domain="feel"`):特殊通道,按创建时间倒序返回所有 feel 类型桶,不走评分逻辑
- **重要度批量模式**`importance_min>=1`):跳过语义搜索,直接筛选 importance≥importance_min 的桶,按 importance 降序,最多 20 条
- 若指定 valence对匹配桶的 valence 微调 ±0.1(情感记忆重构) - 若指定 valence对匹配桶的 valence 微调 ±0.1(情感记忆重构)
**`hold`** — 两种模式: **`hold`** — 两种模式:
@@ -120,26 +121,41 @@
| `/breath-hook` | GET | SessionStart 钩子 | | `/breath-hook` | GET | SessionStart 钩子 |
| `/dream-hook` | GET | Dream 钩子 | | `/dream-hook` | GET | Dream 钩子 |
| `/dashboard` | GET | Dashboard 页面 | | `/dashboard` | GET | Dashboard 页面 |
| `/api/buckets` | GET | 桶列表 | | `/api/buckets` | GET | 桶列表 🔒 |
| `/api/bucket/{id}` | GET | 桶详情 | | `/api/bucket/{id}` | GET | 桶详情 🔒 |
| `/api/search?q=` | GET | 搜索 | | `/api/search?q=` | GET | 搜索 🔒 |
| `/api/network` | GET | 向量相似网络 | | `/api/network` | GET | 向量相似网络 🔒 |
| `/api/breath-debug` | GET | 评分调试 | | `/api/breath-debug` | GET | 评分调试 🔒 |
| `/api/config` | GET | 配置查看key 脱敏) | | `/api/config` | GET | 配置查看key 脱敏)🔒 |
| `/api/config` | POST | 热更新配置 | | `/api/config` | POST | 热更新配置 🔒 |
| `/api/import/upload` | POST | 上传并启动历史对话导入 | | `/api/status` | GET | 系统状态(版本/桶数/引擎)🔒 |
| `/api/import/status` | GET | 导入进度查询 | | `/api/import/upload` | POST | 上传并启动历史对话导入 🔒 |
| `/api/import/pause` | POST | 暂停/继续导入 | | `/api/import/status` | GET | 导入进度查询 🔒 |
| `/api/import/patterns` | GET | 导入完成后词频规律检测 | | `/api/import/pause` | POST | 暂停/继续导入 🔒 |
| `/api/import/results` | GET | 导入记忆桶列表 | | `/api/import/patterns` | GET | 导入完成后词频规律检测 🔒 |
| `/api/import/review` | POST | 批量审阅/批准导入结果 | | `/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 | 修改密码 🔒 |
**Dashboard5 个 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*` 路径不受保护(公开)
**Dashboard6 个 Tab**
1. 记忆桶列表6 种过滤器 + 主题域过滤 + 搜索 + 详情面板 1. 记忆桶列表6 种过滤器 + 主题域过滤 + 搜索 + 详情面板
2. Breath 模拟:输入参数 → 可视化五步流程 → 四维条形图 2. Breath 模拟:输入参数 → 可视化五步流程 → 四维条形图
3. 记忆网络Canvas 力导向图(节点=桶,边=相似度) 3. 记忆网络Canvas 力导向图(节点=桶,边=相似度)
4. 配置:热更新脱水/embedding/合并参数 4. 配置:热更新脱水/embedding/合并参数
5. 导入:历史对话拖拽上传 → 分块处理进度条 → 词频规律分析 → 导入结果审阅 5. 导入:历史对话拖拽上传 → 分块处理进度条 → 词频规律分析 → 导入结果审阅
6. 设置:服务状态监控、修改密码、退出登录
**部署选项** **部署选项**
1. 本地 stdio`python server.py` 1. 本地 stdio`python server.py`
@@ -172,6 +188,7 @@
| `OMBRE_BUCKETS_DIR` | 记忆桶存储目录路径 | 否 | `""` → 回退到 config 或 `./buckets` | | `OMBRE_BUCKETS_DIR` | 记忆桶存储目录路径 | 否 | `""` → 回退到 config 或 `./buckets` |
| `OMBRE_HOOK_URL` | SessionStart 钩子调用的服务器 URL | 否 | `"http://localhost:8000"` | | `OMBRE_HOOK_URL` | SessionStart 钩子调用的服务器 URL | 否 | `"http://localhost:8000"` |
| `OMBRE_HOOK_SKIP` | 设为 `"1"` 跳过 SessionStart 钩子 | 否 | 未设置(不跳过) | | `OMBRE_HOOK_SKIP` | 设为 `"1"` 跳过 SessionStart 钩子 | 否 | 未设置(不跳过) |
| `OMBRE_DASHBOARD_PASSWORD` | 预设 Dashboard 访问密码;设置后覆盖文件密码,首次访问不弹设置向导 | 否 | `""` |
环境变量优先级:`环境变量 > config.yaml > 硬编码默认值`。所有环境变量在 `utils.py` 中读取并注入 config dict。 环境变量优先级:`环境变量 > config.yaml > 硬编码默认值`。所有环境变量在 `utils.py` 中读取并注入 config dict。
@@ -479,3 +496,92 @@ type: dynamic
桶正文内容... 桶正文内容...
``` ```
---
## 7. Bug 修复记录 (B-01 至 B-10)
### B-01 — `update(resolved=True)` 自动归档 🔴 高
- **文件**: `bucket_manager.py``update()`
- **问题**: `resolved=True` 时立即调用 `_move_bucket(archive_dir)` 将桶移入 `archive/`
- **修复**: 移除 `_move_bucket` 逻辑resolved 桶留在 `dynamic/`,由 decay 引擎自然淘汰
- **影响**: 已解决的桶仍可被关键词检索命中(降权但不消失)
- **测试**: `tests/regression/test_issue_B01.py``tests/integration/test_scenario_07_trace.py`
### B-03 — `int()` 截断浮点 activation_count 🔴 高
- **文件**: `decay_engine.py``calculate_score()`
- **问题**: `max(1, int(activation_count))``_time_ripple` 写入的 1.3 截断为 1涟漪加成失效
- **修复**: 改为 `max(1.0, float(activation_count))`
- **影响**: 时间涟漪效果现在正确反映在 score 上;高频访问的桶衰减更慢
- **测试**: `tests/regression/test_issue_B03.py``tests/unit/test_calculate_score.py`
### B-04 — `create()` 初始化 activation_count=1 🟠 中
- **文件**: `bucket_manager.py``create()`
- **问题**: `activation_count=1` 导致冷启动检测条件 `== 0` 永不满足,新建重要桶无法浮现
- **修复**: 改为 `activation_count=0``touch()` 首次命中后变 1
- **测试**: `tests/regression/test_issue_B04.py``tests/integration/test_scenario_01_cold_start.py`
### B-05 — 时间衰减系数 0.1 过快 🟠 中
- **文件**: `bucket_manager.py``_calc_time_score()`
- **问题**: `math.exp(-0.1 * days)` 导致 30 天后得分仅剩 ≈0.05,远快于人类记忆曲线
- **修复**: 改为 `math.exp(-0.02 * days)`30 天后 ≈0.549
- **影响**: 记忆保留时间更符合人类认知模型
- **测试**: `tests/regression/test_issue_B05.py``tests/unit/test_score_components.py`
### B-06 — `w_time` 默认值 2.5 过高 🟠 中
- **文件**: `bucket_manager.py``_calc_final_score()`(或评分调用处)
- **问题**: `scoring.get("time_proximity", 2.5)` — 时间权重过高,近期低质量记忆得分高于高质量旧记忆
- **修复**: 改为 `scoring.get("time_proximity", 1.5)`
- **测试**: `tests/regression/test_issue_B06.py``tests/unit/test_score_components.py`
### B-07 — `content_weight` 默认值 3.0 过高 🟠 中
- **文件**: `bucket_manager.py``_calc_topic_score()`
- **问题**: `scoring.get("content_weight", 3.0)` — 内容权重远大于名字权重(×3),导致内容重复堆砌的桶得分高于名字精确匹配的桶
- **修复**: 改为 `scoring.get("content_weight", 1.0)`
- **影响**: 名字完全匹配 > 标签匹配 > 内容匹配的得分层级现在正确
- **测试**: `tests/regression/test_issue_B07.py``tests/unit/test_topic_score.py`
### B-08 — `run_decay_cycle()` 同轮 auto_resolve 后 score 未降权 🟡 低
- **文件**: `decay_engine.py``run_decay_cycle()`
- **问题**: `auto_resolve` 标记后立即用旧 `meta`stale计算 score`resolved_factor=0.05` 未生效
- **修复**: 在 `bucket_mgr.update(resolved=True)` 后立即执行 `meta["resolved"] = True`,确保同轮降权
- **测试**: `tests/regression/test_issue_B08.py``tests/integration/test_scenario_08_decay.py`
### B-09 — `hold()` 用 analyze() 覆盖用户传入的 valence/arousal 🟡 低
- **文件**: `server.py``hold()`
- **问题**: 先调 `analyze()`,再直接用结果覆盖用户传入的情感值,情感准确性丢失
- **修复**: 使用 `final_valence = user_valence if user_valence is not None else analyze_result.get("valence")`
- **影响**: 用户明确传入的情感坐标(包括 0.0)不再被 LLM 结果覆盖
- **测试**: `tests/regression/test_issue_B09.py``tests/integration/test_scenario_03_hold.py`
### B-10 — feel 桶 `domain=[]` 被填充为 `["未分类"]` 🟡 低
- **文件**: `bucket_manager.py``create()`
- **问题**: `if not domain: domain = ["未分类"]` 对所有桶类型生效feel 桶的空 domain 被错误填充
- **修复**: 改为 `if not domain and bucket_type != "feel": domain = ["未分类"]`
- **影响**: `breath(domain="feel")` 通道过滤逻辑现在正确feel 桶 domain 始终为空列表)
- **测试**: `tests/regression/test_issue_B10.py``tests/integration/test_scenario_10_feel.py`
---
### Bug 修复汇总表
| ID | 严重度 | 文件 | 方法 | 一句话描述 |
|---|---|---|---|---|
| B-01 | 🔴 高 | `bucket_manager.py` | `update()` | resolved 桶不再自动归档 |
| B-03 | 🔴 高 | `decay_engine.py` | `calculate_score()` | float activation_count 不被 int() 截断 |
| B-04 | 🟠 中 | `bucket_manager.py` | `create()` | 初始 activation_count=0 |
| B-05 | 🟠 中 | `bucket_manager.py` | `_calc_time_score()` | 时间衰减系数 0.02(原 0.1 |
| B-06 | 🟠 中 | `bucket_manager.py` | 评分权重配置 | w_time 默认 1.5(原 2.5 |
| B-07 | 🟠 中 | `bucket_manager.py` | `_calc_topic_score()` | content_weight 默认 1.0(原 3.0 |
| B-08 | 🟡 低 | `decay_engine.py` | `run_decay_cycle()` | auto_resolve 同轮应用 ×0.05 |
| B-09 | 🟡 低 | `server.py` | `hold()` | 用户 valence/arousal 优先 |
| B-10 | 🟡 低 | `bucket_manager.py` | `create()` | feel 桶 domain=[] 不被填充 |

187
README.md
View File

@@ -79,6 +79,11 @@ curl http://localhost:8000/health
{"status":"ok","buckets":0,"decay_engine":"stopped"} {"status":"ok","buckets":0,"decay_engine":"stopped"}
``` ```
浏览器打开前端 Dashboard**http://localhost:8000/dashboard**
> 如果你用的是 `docker-compose.user.yml` 默认端口,地址就是 `http://localhost:8000/dashboard`。
> 如果你改了端口映射(比如 `18001:8000`),则是 `http://localhost:18001/dashboard`。
> **看到错误?** 检查 Docker Desktop 是否正在运行(状态栏有图标)。 > **看到错误?** 检查 Docker Desktop 是否正在运行(状态栏有图标)。
### 第六步:接入 Claude ### 第六步:接入 Claude
@@ -154,7 +159,7 @@ OMBRE_API_KEY=你的API密钥
> 3. Set `dehydration.base_url` to `https://generativelanguage.googleapis.com/v1beta/openai` in `config.yaml` > 3. Set `dehydration.base_url` to `https://generativelanguage.googleapis.com/v1beta/openai` in `config.yaml`
> Also supports DeepSeek, Ollama, LM Studio, vLLM, or any OpenAI-compatible API. > Also supports DeepSeek, Ollama, LM Studio, vLLM, or any OpenAI-compatible API.
没有 API key 也能用,脱水压缩会降级到本地模式,只是效果差一点。那就写 没有 API key 则脱水压缩和自动打标功能不可用(会报错),但记忆的读写和检索仍正常工作。如果暂时不用脱水功能,可以留空
``` ```
OMBRE_API_KEY= OMBRE_API_KEY=
@@ -193,6 +198,8 @@ docker logs ombre-brain
看到 `Uvicorn running on http://0.0.0.0:8000` 说明成功了。 看到 `Uvicorn running on http://0.0.0.0:8000` 说明成功了。
浏览器打开前端 Dashboard**http://localhost:18001/dashboard**`docker-compose.yml` 默认端口映射 `18001:8000`
--- ---
**接入 Claude.ai远程访问** **接入 Claude.ai远程访问**
@@ -241,8 +248,8 @@ Ombre Brain gives it persistent memory — not cold key-value storage, but a sys
- **Obsidian 原生 / Obsidian-native**: 每个记忆桶就是一个 Markdown 文件YAML frontmatter 存元数据。可以直接在 Obsidian 里浏览、编辑、搜索。自动注入 `[[双链]]` - **Obsidian 原生 / Obsidian-native**: 每个记忆桶就是一个 Markdown 文件YAML frontmatter 存元数据。可以直接在 Obsidian 里浏览、编辑、搜索。自动注入 `[[双链]]`
Each memory bucket is a Markdown file with YAML frontmatter. Browse, edit, and search directly in Obsidian. Wikilinks are auto-injected. Each memory bucket is a Markdown file with YAML frontmatter. Browse, edit, and search directly in Obsidian. Wikilinks are auto-injected.
- **API 降级 / API degradation**: 脱水压缩和自动打标优先用廉价 LLM APIDeepSeek / Gemini 等)API 不可用时自动降级到本地关键词分析——始终可用。向量检索不可用时降级到 fuzzy matching。 - **API 脱水 + 缓存 / API dehydration + cache**: 脱水压缩和自动打标通过 LLM APIDeepSeek / Gemini 等)完成,结果缓存到本地 SQLite`dehydration_cache.db`),相同内容不重复调用 API。向量检索不可用时降级到 fuzzy matching。
Dehydration and auto-tagging prefer a cheap LLM API (DeepSeek / Gemini etc.). When the API is unavailable, it degrades to local keyword analysis — always functional. Embedding search degrades to fuzzy matching when unavailable. Dehydration and auto-tagging are done via LLM API (DeepSeek / Gemini etc.), with results cached locally in SQLite (`dehydration_cache.db`) to avoid redundant API calls. Embedding search degrades to fuzzy matching when unavailable.
- **历史对话导入 / Conversation history import**: 将过去与 Claude / ChatGPT / DeepSeek 等的对话批量导入为记忆桶。支持 Claude JSON 导出、ChatGPT 导出、Markdown、纯文本等格式分块处理带断点续传通过 Dashboard「导入」Tab 操作。 - **历史对话导入 / Conversation history import**: 将过去与 Claude / ChatGPT / DeepSeek 等的对话批量导入为记忆桶。支持 Claude JSON 导出、ChatGPT 导出、Markdown、纯文本等格式分块处理带断点续传通过 Dashboard「导入」Tab 操作。
Batch-import past conversations (Claude / ChatGPT / DeepSeek etc.) as memory buckets. Supports Claude JSON export, ChatGPT export, Markdown, and plain text. Chunked processing with resume support, via the Dashboard "Import" tab. Batch-import past conversations (Claude / ChatGPT / DeepSeek etc.) as memory buckets. Supports Claude JSON export, ChatGPT export, Markdown, and plain text. Chunked processing with resume support, via the Dashboard "Import" tab.
@@ -428,6 +435,27 @@ Sensitive config via env vars:
- `OMBRE_API_KEY` — LLM API 密钥 - `OMBRE_API_KEY` — LLM API 密钥
- `OMBRE_TRANSPORT` — 覆盖传输方式 - `OMBRE_TRANSPORT` — 覆盖传输方式
- `OMBRE_BUCKETS_DIR` — 覆盖存储路径 - `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 ## 衰减公式 / Decay Formula
@@ -553,6 +581,7 @@ docker compose -f docker-compose.user.yml up -d
``` ```
验证:`curl http://localhost:8000/health` 验证:`curl http://localhost:8000/health`
Dashboard浏览器打开 `http://localhost:8000/dashboard`
### Render ### Render
@@ -565,13 +594,15 @@ docker compose -f docker-compose.user.yml up -d
1. (可选)设置 `OMBRE_API_KEY`:任何 OpenAI 兼容 API 的 key不填则自动降级为本地关键词提取 1. (可选)设置 `OMBRE_API_KEY`:任何 OpenAI 兼容 API 的 key不填则自动降级为本地关键词提取
2. (可选)设置 `OMBRE_BASE_URL`API 地址,支持任意 OpenAI 化地址,如 `https://api.deepseek.com/v1` / `http://123.1.1.1:7689/v1` / `http://your-ollama:11434/v1` 2. (可选)设置 `OMBRE_BASE_URL`API 地址,支持任意 OpenAI 化地址,如 `https://api.deepseek.com/v1` / `http://123.1.1.1:7689/v1` / `http://your-ollama:11434/v1`
3. Render 自动挂载持久化磁盘到 `/opt/render/project/src/buckets` 3. Render 自动挂载持久化磁盘到 `/opt/render/project/src/buckets`
4. 部署后 MCP URL`https://<你的服务名>.onrender.com/mcp` 4. Dashboard`https://<你的服务名>.onrender.com/dashboard`
5. 部署后 MCP URL`https://<你的服务名>.onrender.com/mcp`
`render.yaml` is included. After clicking the button: `render.yaml` is included. After clicking the button:
1. (Optional) `OMBRE_API_KEY`: any OpenAI-compatible key; omit to fall back to local keyword extraction 1. (Optional) `OMBRE_API_KEY`: any OpenAI-compatible key; omit to fall back to local keyword extraction
2. (Optional) `OMBRE_BASE_URL`: any OpenAI-compatible endpoint, e.g. `https://api.deepseek.com/v1`, `http://123.1.1.1:7689/v1`, `http://your-ollama:11434/v1` 2. (Optional) `OMBRE_BASE_URL`: any OpenAI-compatible endpoint, e.g. `https://api.deepseek.com/v1`, `http://123.1.1.1:7689/v1`, `http://your-ollama:11434/v1`
3. Persistent disk auto-mounts at `/opt/render/project/src/buckets` 3. Persistent disk auto-mounts at `/opt/render/project/src/buckets`
4. MCP URL after deploy: `https://<your-service>.onrender.com/mcp` 4. Dashboard: `https://<your-service>.onrender.com/dashboard`
5. MCP URL after deploy: `https://<your-service>.onrender.com/mcp`
### Zeabur ### Zeabur
@@ -611,6 +642,7 @@ docker compose -f docker-compose.user.yml up -d
5. **验证 / Verify** 5. **验证 / Verify**
- 访问 `https://<你的域名>.zeabur.app/health`,应返回 JSON - 访问 `https://<你的域名>.zeabur.app/health`,应返回 JSON
- Visit `https://<your-domain>.zeabur.app/health` — should return JSON - Visit `https://<your-domain>.zeabur.app/health` — should return JSON
- Dashboard`https://<你的域名>.zeabur.app/dashboard`
- 最终 MCP 地址 / MCP URL`https://<你的域名>.zeabur.app/mcp` - 最终 MCP 地址 / MCP URL`https://<你的域名>.zeabur.app/mcp`
**常见问题 / Troubleshooting** **常见问题 / Troubleshooting**
@@ -672,6 +704,151 @@ When connecting via tunnel, ensure:
If using Claude Code, `.claude/settings.json` configures a `SessionStart` hook that auto-calls `breath` on each new or resumed session, surfacing your highest-weight unresolved memories as context. Only active in remote HTTP mode. Set `OMBRE_HOOK_SKIP=1` to disable temporarily. If using Claude Code, `.claude/settings.json` configures a `SessionStart` hook that auto-calls `breath` on each new or resumed session, surfacing your highest-weight unresolved memories as context. Only active in remote HTTP mode. Set `OMBRE_HOOK_SKIP=1` to disable temporarily.
## 更新 / How to Update
不同部署方式的更新方法。
Different update procedures depending on your deployment method.
### Docker Hub 预构建镜像用户 / Docker Hub Pre-built Image
```bash
# 拉取最新镜像
docker pull p0luz/ombre-brain:latest
# 重启容器(记忆数据在 volume 里,不会丢失)
docker compose -f docker-compose.user.yml down
docker compose -f docker-compose.user.yml up -d
```
> 你的记忆数据挂载在 `./buckets:/data`pull + restart 不会影响已有数据。
> Your memory data is mounted at `./buckets:/data` — pull + restart won't affect existing data.
### 从源码部署用户 / Source Code Deploy (Docker)
```bash
cd Ombre-Brain
# 拉取最新代码
git pull origin main
# 重新构建并重启
docker compose down
docker compose build
docker compose up -d
```
> `docker compose build` 会重新构建镜像。volume 挂载的记忆数据不受影响。
> `docker compose build` rebuilds the image. Volume-mounted memory data is unaffected.
### 本地 Python 用户 / Local Python (no Docker)
```bash
cd Ombre-Brain
# 拉取最新代码
git pull origin main
# 更新依赖(如有新增)
pip install -r requirements.txt
# 重启服务
# Ctrl+C 停止旧进程,然后:
python server.py
```
### Render
Render 连接了你的 GitHub 仓库,**自动部署**
1. 如果你 Fork 了仓库 → 在 GitHub 上同步上游更新Sync forkRender 会自动重新部署
2. 或者手动Render Dashboard → 你的服务 → **Manual Deploy** → **Deploy latest commit**
> 持久化磁盘(`/opt/render/project/src/buckets`)上的记忆数据在重新部署时保留。
> Persistent disk data at `/opt/render/project/src/buckets` is preserved across deploys.
### Zeabur
Zeabur 也连接了你的 GitHub 仓库:
1. 在 GitHub 上同步 Fork 的最新代码 → Zeabur 自动触发重新构建部署
2. 或者手动Zeabur Dashboard → 你的服务 → **Redeploy**
> Volume 挂载在 `/app/buckets`,重新部署时数据保留。
> Volume mounted at `/app/buckets` — data persists across redeploys.
### VPS / 自有服务器 / Self-hosted VPS
```bash
cd Ombre-Brain
# 拉取最新代码
git pull origin main
# 方式 ADocker 部署
docker compose down
docker compose build
docker compose up -d
# 方式 B直接 Python 运行
pip install -r requirements.txt
# 重启你的进程管理器systemd / supervisord / pm2 等)
sudo systemctl restart ombre-brain # 示例
```
> **通用注意事项 / General notes:**
> - 更新不会影响你的记忆数据(存在 volume 或 buckets 目录里)
> - 如果 `requirements.txt` 有变化Docker 用户重新 build 即可自动处理;非 Docker 用户需手动 `pip install -r requirements.txt`
> - 更新后访问 `/health` 验证服务正常
> - Updates never affect your memory data (stored in volumes or buckets directory)
> - If `requirements.txt` changed, Docker rebuild handles it automatically; non-Docker users need `pip install -r requirements.txt`
> - After updating, visit `/health` to verify the service is running
## 测试 / Testing
测试套件覆盖规格书所有场景(场景 0111以及 B-01 至 B-10 全部 bug 修复的回归测试。
The test suite covers all spec scenarios (0111) and regression tests for every bug fix (B-01 to B-10).
### 快速运行 / Quick Start
```bash
pip install pytest pytest-asyncio
pytest tests/ # 全部测试
pytest tests/unit/ # 单元测试
pytest tests/integration/ # 集成测试(场景全流程)
pytest tests/regression/ # 回归测试B-01..B-10
pytest tests/ -k "B01" # 单个回归测试
pytest tests/ -v # 详细输出
```
### 测试层级 / Test Layers
| 目录 Directory | 内容 Contents |
|---|---|
| `tests/unit/` | 单独测试 calculate_score、topic_score、时间得分、CRUD 等核心函数 |
| `tests/integration/` | 场景全流程冷启动、hold、search、trace、decay、feel 等 11 个场景 |
| `tests/regression/` | 每个 bugB-01 至 B-10独立回归测试含边界条件 |
### 回归测试覆盖 / Regression Coverage
| 文件 | Bug | 核心断言 |
|---|---|---|
| `test_issue_B01.py` | resolved 桶不再自动归档 | `update(resolved=True)` 后桶留在 `dynamic/`,搜索仍可命中,得分 ×0.05 |
| `test_issue_B03.py` | float activation_count 不被 int() 截断 | 1.3 > 1.0 得分,`_time_ripple` 写入 0.3 增量 |
| `test_issue_B04.py` | create() 初始 activation_count=0 | 新建桶满足冷启动条件touch() 后变 1 |
| `test_issue_B05.py` | 时间衰减系数 0.02(原 0.1| 30天 ≈ 0.549,非旧值 0.049 |
| `test_issue_B06.py` | w_time 默认 1.5(原 2.5| `BucketManager.w_time == 1.5` |
| `test_issue_B07.py` | content_weight 默认 1.0(原 3.0| 名字完全匹配得分 > 内容模糊匹配 |
| `test_issue_B08.py` | auto_resolve 同轮应用降权因子 | stale meta 修复后 score ×0.05 立即生效 |
| `test_issue_B09.py` | hold() 保留用户传入的 valence/arousal | 用户值优先于 analyze() 结果 |
| `test_issue_B10.py` | feel 桶 domain=[] 不被填充 | feel 桶保持 `[]`dynamic 桶正确填 `["未分类"]` |
> **测试隔离**:所有测试运行在 `tmp_path` 临时目录,绝不触碰真实记忆数据。
> **Test isolation**: All tests run in `tmp_path` — your real memory data is never touched.
---
## License ## License
MIT MIT

View File

@@ -54,7 +54,7 @@ class BucketManager:
天然兼容 Obsidian 直接浏览和编辑。 天然兼容 Obsidian 直接浏览和编辑。
""" """
def __init__(self, config: dict): def __init__(self, config: dict, embedding_engine=None):
# --- Read storage paths from config / 从配置中读取存储路径 --- # --- Read storage paths from config / 从配置中读取存储路径 ---
self.base_dir = config["buckets_dir"] self.base_dir = config["buckets_dir"]
self.permanent_dir = os.path.join(self.base_dir, "permanent") self.permanent_dir = os.path.join(self.base_dir, "permanent")
@@ -88,9 +88,12 @@ class BucketManager:
scoring = config.get("scoring_weights", {}) scoring = config.get("scoring_weights", {})
self.w_topic = scoring.get("topic_relevance", 4.0) self.w_topic = scoring.get("topic_relevance", 4.0)
self.w_emotion = scoring.get("emotion_resonance", 2.0) self.w_emotion = scoring.get("emotion_resonance", 2.0)
self.w_time = scoring.get("time_proximity", 2.5) self.w_time = scoring.get("time_proximity", 1.5)
self.w_importance = scoring.get("importance", 1.0) 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 self.content_weight = scoring.get("content_weight", 1.0) # body×1, per spec
# --- Optional embedding engine for pre-filtering / 可选 embedding 引擎,用于预筛候选集 ---
self.embedding_engine = embedding_engine
# --------------------------------------------------------- # ---------------------------------------------------------
# Create a new bucket # Create a new bucket
@@ -121,7 +124,11 @@ class BucketManager:
""" """
bucket_id = generate_bucket_id() bucket_id = generate_bucket_id()
bucket_name = sanitize_name(name) if name else bucket_id bucket_name = sanitize_name(name) if name else bucket_id
domain = domain or ["未分类"] # feel buckets are allowed to have empty domain; others default to ["未分类"]
if bucket_type == "feel":
domain = domain if domain is not None else []
else:
domain = domain or ["未分类"]
tags = tags or [] tags = tags or []
linked_content = content # wikilink injection disabled; LLM adds [[]] via prompt linked_content = content # wikilink injection disabled; LLM adds [[]] via prompt
@@ -142,7 +149,7 @@ class BucketManager:
"type": bucket_type, "type": bucket_type,
"created": now_iso(), "created": now_iso(),
"last_active": now_iso(), "last_active": now_iso(),
"activation_count": 1, "activation_count": 0,
} }
if pinned: if pinned:
metadata["pinned"] = True metadata["pinned"] = True
@@ -289,19 +296,17 @@ class BucketManager:
logger.error(f"Failed to write bucket update / 写入桶更新失败: {file_path}: {e}") logger.error(f"Failed to write bucket update / 写入桶更新失败: {file_path}: {e}")
return False return False
# --- Auto-move: pinned → permanent/, resolved → archive/ --- # --- Auto-move: pinned → permanent/ ---
# --- 自动移动:钉选 → permanent/,已解决 → archive/ --- # --- 自动移动:钉选 → permanent/ ---
# NOTE: resolved buckets are NOT auto-archived here.
# They stay in dynamic/ and decay naturally until score < threshold.
# 注意resolved 桶不在此自动归档,留在 dynamic/ 随衰减引擎自然归档。
domain = post.get("domain", ["未分类"]) domain = post.get("domain", ["未分类"])
if kwargs.get("pinned") and post.get("type") != "permanent": if kwargs.get("pinned") and post.get("type") != "permanent":
post["type"] = "permanent" post["type"] = "permanent"
with open(file_path, "w", encoding="utf-8") as f: with open(file_path, "w", encoding="utf-8") as f:
f.write(frontmatter.dumps(post)) f.write(frontmatter.dumps(post))
self._move_bucket(file_path, self.permanent_dir, domain) self._move_bucket(file_path, self.permanent_dir, domain)
elif kwargs.get("resolved") and post.get("type") not in ("permanent", "feel"):
post["type"] = "archived"
with open(file_path, "w", encoding="utf-8") as f:
f.write(frontmatter.dumps(post))
self._move_bucket(file_path, self.archive_dir, domain)
logger.info(f"Updated bucket / 更新记忆桶: {bucket_id}") logger.info(f"Updated bucket / 更新记忆桶: {bucket_id}")
return True return True
@@ -473,6 +478,20 @@ class BucketManager:
else: else:
candidates = all_buckets 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 --- # --- Layer 2: weighted multi-dim ranking ---
# --- 第二层:多维加权精排 --- # --- 第二层:多维加权精排 ---
scored = [] scored = []
@@ -505,12 +524,14 @@ class BucketManager:
weight_sum = self.w_topic + self.w_emotion + self.w_time + self.w_importance weight_sum = self.w_topic + self.w_emotion + self.w_time + self.w_importance
normalized = (total / weight_sum) * 100 if weight_sum > 0 else 0 normalized = (total / weight_sum) * 100 if weight_sum > 0 else 0
# Resolved buckets get ranking penalty (but still reachable by keyword) # Threshold check uses raw (pre-penalty) score so resolved buckets
# 已解决的桶降权排序(但仍可被关键词激活) # 阈值用原始分数判定,确保 resolved 桶在关键词命中时仍可被搜出
if meta.get("resolved", False): # remain reachable by keyword (penalty applied only to ranking).
normalized *= 0.3
if normalized >= self.fuzzy_threshold: if normalized >= self.fuzzy_threshold:
# Resolved buckets get ranking penalty (but still reachable by keyword)
# 已解决的桶仅在排序时降权
if meta.get("resolved", False):
normalized *= 0.3
bucket["score"] = round(normalized, 2) bucket["score"] = round(normalized, 2)
scored.append(bucket) scored.append(bucket)
except Exception as e: except Exception as e:
@@ -596,7 +617,7 @@ class BucketManager:
days = max(0.0, (datetime.now() - last_active).total_seconds() / 86400) days = max(0.0, (datetime.now() - last_active).total_seconds() / 86400)
except (ValueError, TypeError): except (ValueError, TypeError):
days = 30 days = 30
return math.exp(-0.1 * days) return math.exp(-0.02 * days)
# --------------------------------------------------------- # ---------------------------------------------------------
# List all buckets # List all buckets

147
check_icloud_conflicts.py Normal file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env python3
# ============================================================
# check_icloud_conflicts.py — Ombre Brain iCloud Conflict Detector
# iCloud 冲突文件检测器
#
# Scans the configured bucket directory for iCloud sync conflict
# artefacts and duplicate bucket IDs, then prints a report.
# 扫描配置的桶目录,发现 iCloud 同步冲突文件及重复桶 ID输出报告。
#
# Usage:
# python check_icloud_conflicts.py
# python check_icloud_conflicts.py --buckets-dir /path/to/dir
# python check_icloud_conflicts.py --quiet # exit-code only (0=clean)
# ============================================================
from __future__ import annotations
import argparse
import os
import re
import sys
from collections import defaultdict
from pathlib import Path
# ──────────────────────────────────────────────────────────────
# iCloud conflict file patterns
# Pattern 1 (macOS classic): "filename 2.md", "filename 3.md"
# Pattern 2 (iCloud Drive): "filename (Device's conflicted copy YYYY-MM-DD).md"
# ──────────────────────────────────────────────────────────────
_CONFLICT_SUFFIX = re.compile(r"^(.+?)\s+\d+\.md$")
_CONFLICT_ICLOUD = re.compile(r"^(.+?)\s+\(.+conflicted copy .+\)\.md$", re.IGNORECASE)
# Bucket ID pattern: 12 hex chars at end of stem before extension
_BUCKET_ID_PATTERN = re.compile(r"_([0-9a-f]{12})$")
def resolve_buckets_dir() -> Path:
"""Resolve bucket directory: env var → config.yaml → ./buckets fallback."""
env_dir = os.environ.get("OMBRE_BUCKETS_DIR", "").strip()
if env_dir:
return Path(env_dir)
config_path = Path(__file__).parent / "config.yaml"
if config_path.exists():
try:
import yaml # type: ignore
with open(config_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
if cfg.get("buckets_dir"):
return Path(cfg["buckets_dir"])
except Exception:
pass
return Path(__file__).parent / "buckets"
def scan(buckets_dir: Path) -> tuple[list[Path], dict[str, list[Path]]]:
"""
Returns:
conflict_files — list of files that look like iCloud conflict artefacts
dup_ids — dict of bucket_id -> [list of files sharing that id]
(only entries with 2+ files)
"""
if not buckets_dir.exists():
return [], {}
conflict_files: list[Path] = []
id_to_files: dict[str, list[Path]] = defaultdict(list)
for md_file in buckets_dir.rglob("*.md"):
name = md_file.name
# --- Conflict file detection ---
if _CONFLICT_SUFFIX.match(name) or _CONFLICT_ICLOUD.match(name):
conflict_files.append(md_file)
continue # don't register conflicts in the ID map
# --- Duplicate ID detection ---
stem = md_file.stem
m = _BUCKET_ID_PATTERN.search(stem)
if m:
id_to_files[m.group(1)].append(md_file)
dup_ids = {bid: paths for bid, paths in id_to_files.items() if len(paths) > 1}
return conflict_files, dup_ids
def main() -> int:
parser = argparse.ArgumentParser(
description="Detect iCloud conflict files and duplicate bucket IDs."
)
parser.add_argument(
"--buckets-dir",
metavar="PATH",
help="Override bucket directory (default: from config.yaml / OMBRE_BUCKETS_DIR)",
)
parser.add_argument(
"--quiet",
action="store_true",
help="Suppress output; exit 0 = clean, 1 = problems found",
)
args = parser.parse_args()
buckets_dir = Path(args.buckets_dir) if args.buckets_dir else resolve_buckets_dir()
if not args.quiet:
print(f"Scanning: {buckets_dir}")
if not buckets_dir.exists():
print(" ✗ Directory does not exist.")
return 1
print()
conflict_files, dup_ids = scan(buckets_dir)
problems = bool(conflict_files or dup_ids)
if args.quiet:
return 1 if problems else 0
# ── Report ─────────────────────────────────────────────────
if not problems:
print("✓ No iCloud conflicts or duplicate IDs found.")
return 0
if conflict_files:
print(f"⚠ iCloud conflict files ({len(conflict_files)} found):")
for f in sorted(conflict_files):
rel = f.relative_to(buckets_dir) if f.is_relative_to(buckets_dir) else f
print(f" {rel}")
print()
if dup_ids:
print(f"⚠ Duplicate bucket IDs ({len(dup_ids)} ID(s) shared by multiple files):")
for bid, paths in sorted(dup_ids.items()):
print(f" ID: {bid}")
for p in sorted(paths):
rel = p.relative_to(buckets_dir) if p.is_relative_to(buckets_dir) else p
print(f" {rel}")
print()
print(
"NOTE: This script is report-only. No files are modified or deleted.\n"
"注意:本脚本仅报告,不删除或修改任何文件。"
)
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -61,11 +61,14 @@ decay:
# --- Embedding / 向量化配置 --- # --- Embedding / 向量化配置 ---
# Uses embedding API for semantic similarity search # Uses embedding API for semantic similarity search
# 通过 embedding API 实现语义相似度搜索 # 通过 embedding API 实现语义相似度搜索
# Reuses the same API key (OMBRE_API_KEY) and base_url from dehydration config # You can configure embedding independently from dehydration.
# 复用脱水配置的 API key 和 base_url # If api_key is omitted, reuses the same API key (OMBRE_API_KEY) and base_url from dehydration config
# 你可以把 embedding 独立配置;若 api_key 留空,复用脱水配置的 API key 和 base_url
embedding: embedding:
enabled: true # Enable embedding / 启用向量化 enabled: true # Enable embedding / 启用向量化
model: "gemini-embedding-001" # Embedding model / 向量化模型 model: "gemini-embedding-001" # Embedding model / 向量化模型
# base_url: "https://generativelanguage.googleapis.com/v1beta/openai"
# api_key: ""
# --- Scoring weights / 检索权重参数 --- # --- Scoring weights / 检索权重参数 ---
# total = topic(×4) + emotion(×2) + time(×1.5) + importance(×1) # total = topic(×4) + emotion(×2) + time(×1.5) + importance(×1)

View File

@@ -607,6 +607,7 @@
<div class="search-bar"> <div class="search-bar">
<input type="text" id="search-input" placeholder="搜索记忆…" /> <input type="text" id="search-input" placeholder="搜索记忆…" />
</div> </div>
<button onclick="doLogout()" title="退出登录" style="margin-left:12px;background:none;border:1px solid var(--border);color:var(--text-dim);border-radius:20px;padding:6px 14px;font-size:12px;cursor:pointer;">退出</button>
</div> </div>
<div class="tabs"> <div class="tabs">
@@ -615,6 +616,7 @@
<div class="tab" data-tab="network">记忆网络</div> <div class="tab" data-tab="network">记忆网络</div>
<div class="tab" data-tab="config">配置</div> <div class="tab" data-tab="config">配置</div>
<div class="tab" data-tab="import">导入</div> <div class="tab" data-tab="import">导入</div>
<div class="tab" data-tab="settings">设置</div>
</div> </div>
<div class="content" id="list-view"> <div class="content" id="list-view">
@@ -778,7 +780,186 @@
<div id="detail-content"></div> <div id="detail-content"></div>
</div> </div>
<!-- Settings Tab View -->
<div class="content" id="settings-view" style="display:none">
<div style="max-width:580px;margin:0 auto;">
<div class="config-section">
<h3>服务状态</h3>
<div id="settings-status" style="font-size:13px;color:var(--text-dim);line-height:2;">加载中…</div>
<button onclick="loadSettingsStatus()" style="margin-top:8px;font-size:12px;padding:4px 12px;">刷新状态</button>
</div>
<div class="config-section">
<h3>修改密码</h3>
<div id="settings-env-notice" style="display:none;font-size:12px;color:var(--warning);margin-bottom:10px;">
⚠ 当前使用环境变量 OMBRE_DASHBOARD_PASSWORD请直接修改环境变量。
</div>
<div id="settings-pwd-form">
<div class="config-row">
<label>当前密码</label>
<input type="password" id="settings-current-pwd" placeholder="当前密码" />
</div>
<div class="config-row">
<label>新密码</label>
<input type="password" id="settings-new-pwd" placeholder="新密码至少6位" />
</div>
<div class="config-row">
<label>确认新密码</label>
<input type="password" id="settings-new-pwd2" placeholder="再次输入新密码" />
</div>
<button class="btn-primary" onclick="changePassword()" style="margin-top:4px;">修改密码</button>
<div id="settings-pwd-msg" style="margin-top:10px;font-size:13px;"></div>
</div>
</div>
<div class="config-section">
<h3>账号操作</h3>
<button onclick="doLogout()" style="color:var(--negative);border-color:var(--negative);">退出登录</button>
</div>
</div>
</div>
<!-- Auth Overlay -->
<div id="auth-overlay" style="position:fixed;inset:0;z-index:9999;background:var(--bg-gradient);background-attachment:fixed;display:flex;align-items:center;justify-content:center;">
<div style="background:var(--surface);backdrop-filter:blur(16px);border:1px solid var(--border);border-radius:24px;padding:48px 40px;max-width:400px;width:90%;text-align:center;box-shadow:0 8px 40px rgba(0,0,0,0.12);">
<h2 style="font-family:'Cormorant Garamond',serif;font-size:28px;color:var(--accent);margin-bottom:8px;">◐ Ombre Brain</h2>
<p style="color:var(--text-dim);font-size:13px;margin-bottom:28px;" id="auth-subtitle">验证身份</p>
<!-- Setup form -->
<div id="auth-setup-form" style="display:none;">
<p style="font-size:13px;color:var(--text-dim);margin-bottom:16px;">首次使用,请设置访问密码</p>
<input type="password" id="auth-setup-pwd" placeholder="设置密码至少6位" style="display:block;width:100%;padding:10px 16px;border:1px solid var(--border);border-radius:10px;background:var(--surface-solid);color:var(--text);font-size:14px;margin-bottom:10px;" />
<input type="password" id="auth-setup-pwd2" placeholder="确认密码" style="display:block;width:100%;padding:10px 16px;border:1px solid var(--border);border-radius:10px;background:var(--surface-solid);color:var(--text);font-size:14px;margin-bottom:16px;" onkeydown="if(event.key==='Enter')doSetup()" />
<button onclick="doSetup()" style="width:100%;padding:12px;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:14px;cursor:pointer;">设置密码并进入</button>
</div>
<!-- Login form -->
<div id="auth-login-form" style="display:none;">
<input type="password" id="auth-login-pwd" placeholder="输入访问密码" style="display:block;width:100%;padding:10px 16px;border:1px solid var(--border);border-radius:10px;background:var(--surface-solid);color:var(--text);font-size:14px;margin-bottom:16px;" onkeydown="if(event.key==='Enter')doLogin()" />
<button onclick="doLogin()" style="width:100%;padding:12px;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:14px;cursor:pointer;">登录</button>
</div>
<div id="auth-error" style="color:var(--negative);font-size:13px;margin-top:12px;display:none;"></div>
</div>
</div>
<script> <script>
// ========================================
// Auth system / 认证系统
// ========================================
async function checkAuth() {
try {
const resp = await fetch('/auth/status');
const data = await resp.json();
if (data.setup_needed) {
document.getElementById('auth-subtitle').textContent = '首次设置';
document.getElementById('auth-setup-form').style.display = 'block';
} else if (data.authenticated) {
document.getElementById('auth-overlay').style.display = 'none';
} else {
document.getElementById('auth-subtitle').textContent = '请输入访问密码';
document.getElementById('auth-login-form').style.display = 'block';
}
} catch {
document.getElementById('auth-overlay').style.display = 'none';
}
}
function showAuthError(msg) {
const el = document.getElementById('auth-error');
el.textContent = msg;
el.style.display = 'block';
}
async function doSetup() {
const p1 = document.getElementById('auth-setup-pwd').value;
const p2 = document.getElementById('auth-setup-pwd2').value;
if (p1.length < 6) return showAuthError('密码至少6位');
if (p1 !== p2) return showAuthError('两次密码不一致');
const resp = await fetch('/auth/setup', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({password: p1}) });
if (resp.ok) {
document.getElementById('auth-overlay').style.display = 'none';
} else {
const d = await resp.json();
showAuthError(d.detail || '设置失败');
}
}
async function doLogin() {
const pwd = document.getElementById('auth-login-pwd').value;
const resp = await fetch('/auth/login', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({password: pwd}) });
if (resp.ok) {
document.getElementById('auth-overlay').style.display = 'none';
} else {
const d = await resp.json();
showAuthError(d.detail || '密码错误');
}
}
async function doLogout() {
await fetch('/auth/logout', { method: 'POST' });
document.getElementById('auth-setup-form').style.display = 'none';
document.getElementById('auth-login-form').style.display = 'none';
document.getElementById('auth-login-form').style.display = 'block';
document.getElementById('auth-subtitle').textContent = '请输入访问密码';
document.getElementById('auth-error').style.display = 'none';
document.getElementById('auth-overlay').style.display = 'flex';
}
async function changePassword() {
const currentPwd = document.getElementById('settings-current-pwd').value;
const newPwd = document.getElementById('settings-new-pwd').value;
const newPwd2 = document.getElementById('settings-new-pwd2').value;
const msgEl = document.getElementById('settings-pwd-msg');
if (newPwd.length < 6) { msgEl.style.color = 'var(--negative)'; msgEl.textContent = '新密码至少6位'; return; }
if (newPwd !== newPwd2) { msgEl.style.color = 'var(--negative)'; msgEl.textContent = '两次密码不一致'; return; }
const resp = await authFetch('/auth/change-password', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({current: currentPwd, new: newPwd}) });
if (!resp) return;
if (resp.ok) {
msgEl.style.color = 'var(--accent)'; msgEl.textContent = '密码修改成功';
document.getElementById('settings-current-pwd').value = '';
document.getElementById('settings-new-pwd').value = '';
document.getElementById('settings-new-pwd2').value = '';
} else {
const d = await resp.json();
msgEl.style.color = 'var(--negative)'; msgEl.textContent = d.detail || '修改失败';
}
}
async function loadSettingsStatus() {
const el = document.getElementById('settings-status');
try {
const resp = await authFetch('/api/status');
if (!resp) return;
const d = await resp.json();
const noticeEl = document.getElementById('settings-env-notice');
if (d.using_env_password) noticeEl.style.display = 'block';
else noticeEl.style.display = 'none';
el.innerHTML = `
<b>版本</b>${d.version}<br>
<b>Bucket 总数</b>${(d.buckets?.total ?? 0)} (永久:${d.buckets?.permanent ?? 0} / 动态:${d.buckets?.dynamic ?? 0} / 归档:${d.buckets?.archive ?? 0}<br>
<b>衰减引擎</b>${d.decay_engine}<br>
<b>向量搜索</b>${d.embedding_enabled ? '已启用' : '未启用'}<br>
`;
} catch(e) {
el.textContent = '加载失败: ' + e;
}
}
// authFetch: wraps fetch, shows auth overlay on 401
async function authFetch(url, options) {
const resp = await fetch(url, options);
if (resp.status === 401) {
doLogout();
return null;
}
return resp;
}
// ========================================
const BASE = location.origin; const BASE = location.origin;
let allBuckets = []; let allBuckets = [];
let currentFilter = 'all'; let currentFilter = 'all';
@@ -793,9 +974,11 @@ document.querySelectorAll('.tab').forEach(tab => {
document.getElementById('network-view').style.display = target === 'network' ? '' : 'none'; document.getElementById('network-view').style.display = target === 'network' ? '' : 'none';
document.getElementById('config-view').style.display = target === 'config' ? '' : 'none'; document.getElementById('config-view').style.display = target === 'config' ? '' : 'none';
document.getElementById('import-view').style.display = target === 'import' ? '' : 'none'; document.getElementById('import-view').style.display = target === 'import' ? '' : 'none';
document.getElementById('settings-view').style.display = target === 'settings' ? '' : 'none';
if (target === 'network') loadNetwork(); if (target === 'network') loadNetwork();
if (target === 'config') loadConfig(); if (target === 'config') loadConfig();
if (target === 'import') { pollImportStatus(); loadImportResults(); } if (target === 'import') { pollImportStatus(); loadImportResults(); }
if (target === 'settings') loadSettingsStatus();
}); });
}); });
@@ -812,7 +995,11 @@ document.getElementById('search-input').addEventListener('input', (e) => {
async function loadBuckets() { async function loadBuckets() {
try { try {
const res = await fetch(BASE + '/api/buckets'); const res = await fetch(BASE + '/api/buckets');
allBuckets = await res.json(); const data = await res.json();
if (!res.ok || !Array.isArray(data)) {
throw new Error((data && data.error) ? data.error : `HTTP ${res.status}`);
}
allBuckets = data;
updateStats(); updateStats();
buildFilters(); buildFilters();
renderBuckets(allBuckets); renderBuckets(allBuckets);
@@ -1237,7 +1424,7 @@ async function saveConfig(persist) {
} }
} }
loadBuckets(); checkAuth().then(() => loadBuckets());
// --- Import functions --- // --- Import functions ---
const uploadZone = document.getElementById('import-upload-zone'); const uploadZone = document.getElementById('import-upload-zone');
@@ -1300,6 +1487,7 @@ function updateImportUI(s) {
document.getElementById('import-status-text').textContent = statusMap[s.status] || s.status; document.getElementById('import-status-text').textContent = statusMap[s.status] || s.status;
document.getElementById('import-pause-btn').style.display = s.status === 'running' ? '' : 'none'; document.getElementById('import-pause-btn').style.display = s.status === 'running' ? '' : 'none';
if (s.status !== 'running') clearInterval(importPollTimer); if (s.status !== 'running') clearInterval(importPollTimer);
if (s.status === 'completed') loadImportResults();
const errDiv = document.getElementById('import-errors'); const errDiv = document.getElementById('import-errors');
if (s.errors && s.errors.length) { if (s.errors && s.errors.length) {
errDiv.style.display = ''; errDiv.style.display = '';

View File

@@ -112,7 +112,7 @@ class DecayEngine:
return 50.0 return 50.0
importance = max(1, min(10, int(metadata.get("importance", 5)))) importance = max(1, min(10, int(metadata.get("importance", 5))))
activation_count = max(1, int(metadata.get("activation_count", 1))) activation_count = max(1.0, float(metadata.get("activation_count", 1)))
# --- Days since last activation --- # --- Days since last activation ---
last_active_str = metadata.get("last_active", metadata.get("created", "")) last_active_str = metadata.get("last_active", metadata.get("created", ""))
@@ -215,6 +215,7 @@ class DecayEngine:
if imp <= 4 and days_since > 30: if imp <= 4 and days_since > 30:
try: try:
await self.bucket_mgr.update(bucket["id"], resolved=True) await self.bucket_mgr.update(bucket["id"], resolved=True)
meta["resolved"] = True # refresh local meta so resolved_factor applies this cycle
auto_resolved += 1 auto_resolved += 1
logger.info( logger.info(
f"Auto-resolved / 自动结案: " f"Auto-resolved / 自动结案: "

View File

@@ -235,8 +235,8 @@ class Dehydrator:
# --------------------------------------------------------- # ---------------------------------------------------------
# Dehydrate: compress raw content into concise summary # Dehydrate: compress raw content into concise summary
# 脱水:将原始内容压缩为精简摘要 # 脱水:将原始内容压缩为精简摘要
# Try API first, fallback to local # API only (no local fallback)
# 先尝试 API,失败则回退本地 # 仅通过 API 脱水(无本地回退)
# --------------------------------------------------------- # ---------------------------------------------------------
async def dehydrate(self, content: str, metadata: dict = None) -> str: async def dehydrate(self, content: str, metadata: dict = None) -> str:
""" """

View File

@@ -19,7 +19,20 @@ services:
- OMBRE_API_KEY=${OMBRE_API_KEY} - OMBRE_API_KEY=${OMBRE_API_KEY}
- OMBRE_TRANSPORT=streamable-http - OMBRE_TRANSPORT=streamable-http
- OMBRE_BUCKETS_DIR=/data - OMBRE_BUCKETS_DIR=/data
# --- Model override (optional) ---
# If you use Gemini instead of DeepSeek, set these in your .env:
# 如使用 Gemini 而非 DeepSeek在 .env 里加:
# OMBRE_DEHYDRATION_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/
# OMBRE_DEHYDRATION_MODEL=gemini-2.5-flash-lite
# OMBRE_EMBEDDING_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/
- OMBRE_DEHYDRATION_BASE_URL=${OMBRE_DEHYDRATION_BASE_URL:-}
- OMBRE_DEHYDRATION_MODEL=${OMBRE_DEHYDRATION_MODEL:-}
- OMBRE_EMBEDDING_BASE_URL=${OMBRE_EMBEDDING_BASE_URL:-}
- OMBRE_EMBEDDING_MODEL=${OMBRE_EMBEDDING_MODEL:-}
volumes: volumes:
# 改成你的 Obsidian Vault 路径,或保持 ./buckets 用本地目录 # 改成你的 Obsidian Vault 路径,或保持 ./buckets 用本地目录
# Change to your Obsidian Vault path, or keep ./buckets for local storage # Change to your Obsidian Vault path, or keep ./buckets for local storage
- ./buckets:/data - ./buckets:/data
# (Optional) Mount custom config to override model / API settings:
# (可选)挂载自定义配置,覆盖模型和 API 设置:
# - ./config.yaml:/app/config.yaml

View File

@@ -34,8 +34,12 @@ class EmbeddingEngine:
dehy_cfg = config.get("dehydration", {}) dehy_cfg = config.get("dehydration", {})
embed_cfg = config.get("embedding", {}) embed_cfg = config.get("embedding", {})
self.api_key = dehy_cfg.get("api_key", "") self.api_key = (embed_cfg.get("api_key") or dehy_cfg.get("api_key") or "").strip()
self.base_url = dehy_cfg.get("base_url", "https://generativelanguage.googleapis.com/v1beta/openai/") self.base_url = (
(embed_cfg.get("base_url") or "").strip()
or (dehy_cfg.get("base_url") or "").strip()
or "https://generativelanguage.googleapis.com/v1beta/openai/"
)
self.model = embed_cfg.get("model", "gemini-embedding-001") self.model = embed_cfg.get("model", "gemini-embedding-001")
self.enabled = bool(self.api_key) and embed_cfg.get("enabled", True) self.enabled = bool(self.api_key) and embed_cfg.get("enabled", True)

View File

@@ -39,6 +39,8 @@ def _parse_claude_json(data: dict | list) -> list[dict]:
turns = [] turns = []
conversations = data if isinstance(data, list) else [data] conversations = data if isinstance(data, list) else [data]
for conv in conversations: for conv in conversations:
if not isinstance(conv, dict):
continue
messages = conv.get("chat_messages", conv.get("messages", [])) messages = conv.get("chat_messages", conv.get("messages", []))
for msg in messages: for msg in messages:
if not isinstance(msg, dict): if not isinstance(msg, dict):
@@ -61,18 +63,27 @@ def _parse_chatgpt_json(data: list | dict) -> list[dict]:
turns = [] turns = []
conversations = data if isinstance(data, list) else [data] conversations = data if isinstance(data, list) else [data]
for conv in conversations: for conv in conversations:
if not isinstance(conv, dict):
continue
mapping = conv.get("mapping", {}) mapping = conv.get("mapping", {})
if mapping: if mapping:
# ChatGPT uses a tree structure with mapping # ChatGPT uses a tree structure with mapping
sorted_nodes = sorted( # Filter out None nodes before sorting
mapping.values(), valid_nodes = [n for n in mapping.values() if isinstance(n, dict)]
key=lambda n: n.get("message", {}).get("create_time", 0) or 0,
) def _node_ts(n):
msg = n.get("message")
if not isinstance(msg, dict):
return 0
return msg.get("create_time") or 0
sorted_nodes = sorted(valid_nodes, key=_node_ts)
for node in sorted_nodes: for node in sorted_nodes:
msg = node.get("message") msg = node.get("message")
if not msg or not isinstance(msg, dict): if not msg or not isinstance(msg, dict):
continue continue
content_parts = msg.get("content", {}).get("parts", []) content_obj = msg.get("content", {})
content_parts = content_obj.get("parts", []) if isinstance(content_obj, dict) else []
content = " ".join(str(p) for p in content_parts if p) content = " ".join(str(p) for p in content_parts if p)
if not content.strip(): if not content.strip():
continue continue
@@ -168,7 +179,7 @@ def detect_and_parse(raw_content: str, filename: str = "") -> list[dict]:
# Single conversation object with role/content messages # Single conversation object with role/content messages
if "role" in sample and "content" in sample: if "role" in sample and "content" in sample:
return _parse_claude_json(data) return _parse_claude_json(data)
except (json.JSONDecodeError, KeyError, IndexError): except (json.JSONDecodeError, KeyError, IndexError, AttributeError, TypeError):
pass pass
# Fall back to markdown/text # Fall back to markdown/text

322
server.py
View File

@@ -35,6 +35,11 @@ import sys
import random import random
import logging import logging
import asyncio import asyncio
import hashlib
import hmac
import secrets
import time
import json as _json_lib
import httpx import httpx
@@ -57,10 +62,10 @@ setup_logging(config.get("log_level", "INFO"))
logger = logging.getLogger("ombre_brain") logger = logging.getLogger("ombre_brain")
# --- Initialize core components / 初始化核心组件 --- # --- Initialize core components / 初始化核心组件 ---
bucket_mgr = BucketManager(config) # Bucket manager / 记忆桶管理器 embedding_engine = EmbeddingEngine(config) # Embedding engine first (BucketManager depends on it)
bucket_mgr = BucketManager(config, embedding_engine=embedding_engine) # Bucket manager / 记忆桶管理器
dehydrator = Dehydrator(config) # Dehydrator / 脱水器 dehydrator = Dehydrator(config) # Dehydrator / 脱水器
decay_engine = DecayEngine(config, bucket_mgr) # Decay engine / 衰减引擎 decay_engine = DecayEngine(config, bucket_mgr) # Decay engine / 衰减引擎
embedding_engine = EmbeddingEngine(config) # Embedding engine / 向量化引擎
import_engine = ImportEngine(config, bucket_mgr, dehydrator, embedding_engine) # Import engine / 导入引擎 import_engine = ImportEngine(config, bucket_mgr, dehydrator, embedding_engine) # Import engine / 导入引擎
# --- Create MCP server instance / 创建 MCP 服务器实例 --- # --- Create MCP server instance / 创建 MCP 服务器实例 ---
@@ -73,6 +78,183 @@ mcp = FastMCP(
) )
# =============================================================
# Dashboard Auth — simple cookie-based session auth
# Dashboard 认证 —— 基于 Cookie 的会话认证
#
# Env var OMBRE_DASHBOARD_PASSWORD overrides file-stored password.
# First visit with no password set → forced setup wizard.
# Sessions stored in memory (lost on restart, 7-day expiry).
# =============================================================
_sessions: dict[str, float] = {} # {token: expiry_timestamp}
def _get_auth_file() -> str:
return os.path.join(config["buckets_dir"], ".dashboard_auth.json")
def _load_password_hash() -> str | None:
try:
auth_file = _get_auth_file()
if os.path.exists(auth_file):
with open(auth_file, "r", encoding="utf-8") as f:
return _json_lib.load(f).get("password_hash")
except Exception:
pass
return None
def _save_password_hash(password: str) -> None:
salt = secrets.token_hex(16)
h = hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
auth_file = _get_auth_file()
os.makedirs(os.path.dirname(auth_file), exist_ok=True)
with open(auth_file, "w", encoding="utf-8") as f:
_json_lib.dump({"password_hash": f"{salt}:{h}"}, f)
def _verify_password_hash(password: str, stored: str) -> bool:
if ":" not in stored:
return False
salt, h = stored.split(":", 1)
return hmac.compare_digest(
h, hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
)
def _is_setup_needed() -> bool:
"""True if no password is configured (env var or file)."""
if os.environ.get("OMBRE_DASHBOARD_PASSWORD", ""):
return False
return _load_password_hash() is None
def _verify_any_password(password: str) -> bool:
"""Check password against env var (first) or stored hash."""
env_pwd = os.environ.get("OMBRE_DASHBOARD_PASSWORD", "")
if env_pwd:
return hmac.compare_digest(password, env_pwd)
stored = _load_password_hash()
if not stored:
return False
return _verify_password_hash(password, stored)
def _create_session() -> str:
token = secrets.token_urlsafe(32)
_sessions[token] = time.time() + 86400 * 7 # 7-day expiry
return token
def _is_authenticated(request) -> bool:
token = request.cookies.get("ombre_session")
if not token:
return False
expiry = _sessions.get(token)
if expiry is None or time.time() > expiry:
_sessions.pop(token, None)
return False
return True
def _require_auth(request):
"""Return JSONResponse(401) if not authenticated, else None."""
from starlette.responses import JSONResponse
if not _is_authenticated(request):
return JSONResponse(
{"error": "Unauthorized", "setup_needed": _is_setup_needed()},
status_code=401,
)
return None
# --- Auth endpoints ---
@mcp.custom_route("/auth/status", methods=["GET"])
async def auth_status(request):
"""Return auth state (authenticated, setup_needed)."""
from starlette.responses import JSONResponse
return JSONResponse({
"authenticated": _is_authenticated(request),
"setup_needed": _is_setup_needed(),
})
@mcp.custom_route("/auth/setup", methods=["POST"])
async def auth_setup_endpoint(request):
"""Initial password setup (only when no password is configured)."""
from starlette.responses import JSONResponse
if not _is_setup_needed():
return JSONResponse({"error": "Already configured"}, status_code=400)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
password = body.get("password", "").strip()
if len(password) < 6:
return JSONResponse({"error": "密码不能少于6位"}, status_code=400)
_save_password_hash(password)
token = _create_session()
resp = JSONResponse({"ok": True})
resp.set_cookie("ombre_session", token, httponly=True, samesite="lax", max_age=86400 * 7)
return resp
@mcp.custom_route("/auth/login", methods=["POST"])
async def auth_login(request):
"""Login with password."""
from starlette.responses import JSONResponse
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
password = body.get("password", "")
if _verify_any_password(password):
token = _create_session()
resp = JSONResponse({"ok": True})
resp.set_cookie("ombre_session", token, httponly=True, samesite="lax", max_age=86400 * 7)
return resp
return JSONResponse({"error": "密码错误"}, status_code=401)
@mcp.custom_route("/auth/logout", methods=["POST"])
async def auth_logout(request):
"""Invalidate session."""
from starlette.responses import JSONResponse
token = request.cookies.get("ombre_session")
if token:
_sessions.pop(token, None)
resp = JSONResponse({"ok": True})
resp.delete_cookie("ombre_session")
return resp
@mcp.custom_route("/auth/change-password", methods=["POST"])
async def auth_change_password(request):
"""Change dashboard password (requires current password)."""
from starlette.responses import JSONResponse
err = _require_auth(request)
if err:
return err
if os.environ.get("OMBRE_DASHBOARD_PASSWORD", ""):
return JSONResponse({"error": "当前使用环境变量密码,请直接修改 OMBRE_DASHBOARD_PASSWORD"}, status_code=400)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
current = body.get("current", "")
new_pwd = body.get("new", "").strip()
if not _verify_any_password(current):
return JSONResponse({"error": "当前密码错误"}, status_code=401)
if len(new_pwd) < 6:
return JSONResponse({"error": "新密码不能少于6位"}, status_code=400)
_save_password_hash(new_pwd)
_sessions.clear()
token = _create_session()
resp = JSONResponse({"ok": True})
resp.set_cookie("ombre_session", token, httponly=True, samesite="lax", max_age=86400 * 7)
return resp
# ============================================================= # =============================================================
# /health endpoint: lightweight keepalive # /health endpoint: lightweight keepalive
# 轻量保活接口 # 轻量保活接口
@@ -274,12 +456,47 @@ async def breath(
valence: float = -1, valence: float = -1,
arousal: float = -1, arousal: float = -1,
max_results: int = 20, max_results: int = 20,
importance_min: int = -1,
) -> str: ) -> str:
"""检索/浮现记忆。不传query或传空=自动浮现,有query=关键词检索。max_tokens控制返回总token上限(默认10000)。domain逗号分隔,valence/arousal 0~1(-1忽略)。max_results控制返回数量上限(默认20,最大50)。""" """检索/浮现记忆。不传query或传空=自动浮现,有query=关键词检索。max_tokens控制返回总token上限(默认10000)。domain逗号分隔,valence/arousal 0~1(-1忽略)。max_results控制返回数量上限(默认20,最大50)。importance_min>=1时按重要度批量拉取(不走语义搜索,按importance降序返回最多20条)。"""
await decay_engine.ensure_started() await decay_engine.ensure_started()
max_results = min(max_results, 50) max_results = min(max_results, 50)
max_tokens = min(max_tokens, 20000) max_tokens = min(max_tokens, 20000)
# --- importance_min mode: bulk fetch by importance threshold ---
# --- 重要度批量拉取模式:跳过语义搜索,按 importance 降序返回 ---
if importance_min >= 1:
try:
all_buckets = await bucket_mgr.list_all(include_archive=False)
except Exception as e:
return f"记忆系统暂时无法访问: {e}"
filtered = [
b for b in all_buckets
if int(b["metadata"].get("importance", 0)) >= importance_min
and b["metadata"].get("type") not in ("feel",)
]
filtered.sort(key=lambda b: int(b["metadata"].get("importance", 0)), reverse=True)
filtered = filtered[:20]
if not filtered:
return f"没有重要度 >= {importance_min} 的记忆。"
results = []
token_used = 0
for b in filtered:
if token_used >= max_tokens:
break
try:
clean_meta = {k: v for k, v in b["metadata"].items() if k != "tags"}
summary = await dehydrator.dehydrate(strip_wikilinks(b["content"]), clean_meta)
t = count_tokens_approx(summary)
if token_used + t > max_tokens:
break
imp = b["metadata"].get("importance", 0)
results.append(f"[importance:{imp}] [bucket_id:{b['id']}] {summary}")
token_used += t
except Exception as e:
logger.warning(f"importance_min dehydrate failed: {e}")
return "\n---\n".join(results) if results else "没有可以展示的记忆。"
# --- No args or empty query: surfacing mode (weight pool active push) --- # --- No args or empty query: surfacing mode (weight pool active push) ---
# --- 无参数或空query浮现模式权重池主动推送--- # --- 无参数或空query浮现模式权重池主动推送---
if not query or not query.strip(): if not query or not query.strip():
@@ -330,6 +547,18 @@ async def breath(
top_scores = [(b["metadata"].get("name", b["id"]), decay_engine.calculate_score(b["metadata"])) for b in scored[:5]] top_scores = [(b["metadata"].get("name", b["id"]), decay_engine.calculate_score(b["metadata"])) for b in scored[:5]]
logger.info(f"Top unresolved scores: {top_scores}") logger.info(f"Top unresolved scores: {top_scores}")
# --- Cold-start detection: never-seen important buckets surface first ---
# --- 冷启动检测:从未被访问过且重要度>=8的桶优先插入最前面最多2个---
cold_start = [
b for b in unresolved
if int(b["metadata"].get("activation_count", 0)) == 0
and int(b["metadata"].get("importance", 0)) >= 8
][:2]
cold_start_ids = {b["id"] for b in cold_start}
# Merge: cold_start first, then scored (excluding duplicates)
scored_deduped = [b for b in scored if b["id"] not in cold_start_ids]
scored_with_cold = cold_start + scored_deduped
# --- Token-budgeted surfacing with diversity + hard cap --- # --- Token-budgeted surfacing with diversity + hard cap ---
# --- 按 token 预算浮现,带多样性 + 硬上限 --- # --- 按 token 预算浮现,带多样性 + 硬上限 ---
# Top-1 always surfaces; rest sampled from top-20 for diversity # Top-1 always surfaces; rest sampled from top-20 for diversity
@@ -337,13 +566,17 @@ async def breath(
for r in pinned_results: for r in pinned_results:
token_budget -= count_tokens_approx(r) token_budget -= count_tokens_approx(r)
candidates = list(scored) candidates = list(scored_with_cold)
if len(candidates) > 1: if len(candidates) > 1:
# Ensure highest-score bucket is first, shuffle rest from top-20 # Cold-start buckets stay at front; shuffle rest from top-20
top1 = [candidates[0]] n_cold = len(cold_start)
pool = candidates[1:min(20, len(candidates))] non_cold = candidates[n_cold:]
random.shuffle(pool) if len(non_cold) > 1:
candidates = top1 + pool + candidates[min(20, len(candidates)):] top1 = [non_cold[0]]
pool = non_cold[1:min(20, len(non_cold))]
random.shuffle(pool)
non_cold = top1 + pool + non_cold[min(20, len(non_cold)):]
candidates = cold_start + non_cold
# Hard cap: never surface more than max_results buckets # Hard cap: never surface more than max_results buckets
candidates = candidates[:max_results] candidates = candidates[:max_results]
@@ -557,11 +790,16 @@ async def hold(
} }
domain = analysis["domain"] domain = analysis["domain"]
valence = analysis["valence"] auto_valence = analysis["valence"]
arousal = analysis["arousal"] auto_arousal = analysis["arousal"]
auto_tags = analysis["tags"] auto_tags = analysis["tags"]
suggested_name = analysis.get("suggested_name", "") suggested_name = analysis.get("suggested_name", "")
# --- User-supplied valence/arousal takes priority over analyze() result ---
# --- 用户显式传入的 valence/arousal 优先analyze() 结果作为 fallback ---
final_valence = valence if 0 <= valence <= 1 else auto_valence
final_arousal = arousal if 0 <= arousal <= 1 else auto_arousal
all_tags = list(dict.fromkeys(auto_tags + extra_tags)) all_tags = list(dict.fromkeys(auto_tags + extra_tags))
# --- Pinned buckets bypass merge and are created directly in permanent dir --- # --- Pinned buckets bypass merge and are created directly in permanent dir ---
@@ -572,8 +810,8 @@ async def hold(
tags=all_tags, tags=all_tags,
importance=10, importance=10,
domain=domain, domain=domain,
valence=valence, valence=final_valence,
arousal=arousal, arousal=final_arousal,
name=suggested_name or None, name=suggested_name or None,
bucket_type="permanent", bucket_type="permanent",
pinned=True, pinned=True,
@@ -590,8 +828,8 @@ async def hold(
tags=all_tags, tags=all_tags,
importance=importance, importance=importance,
domain=domain, domain=domain,
valence=valence, valence=final_valence,
arousal=arousal, arousal=final_arousal,
name=suggested_name, name=suggested_name,
) )
@@ -978,6 +1216,8 @@ async def dream() -> str:
async def api_buckets(request): async def api_buckets(request):
"""List all buckets with metadata (no content for efficiency).""" """List all buckets with metadata (no content for efficiency)."""
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
err = _require_auth(request)
if err: return err
try: try:
all_buckets = await bucket_mgr.list_all(include_archive=True) all_buckets = await bucket_mgr.list_all(include_archive=True)
result = [] result = []
@@ -1012,6 +1252,8 @@ async def api_buckets(request):
async def api_bucket_detail(request): async def api_bucket_detail(request):
"""Get full bucket content by ID.""" """Get full bucket content by ID."""
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
err = _require_auth(request)
if err: return err
bucket_id = request.path_params["bucket_id"] bucket_id = request.path_params["bucket_id"]
bucket = await bucket_mgr.get(bucket_id) bucket = await bucket_mgr.get(bucket_id)
if not bucket: if not bucket:
@@ -1029,6 +1271,8 @@ async def api_bucket_detail(request):
async def api_search(request): async def api_search(request):
"""Search buckets by query.""" """Search buckets by query."""
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
err = _require_auth(request)
if err: return err
query = request.query_params.get("q", "") query = request.query_params.get("q", "")
if not query: if not query:
return JSONResponse({"error": "missing q parameter"}, status_code=400) return JSONResponse({"error": "missing q parameter"}, status_code=400)
@@ -1055,6 +1299,8 @@ async def api_search(request):
async def api_network(request): async def api_network(request):
"""Get embedding similarity network for visualization.""" """Get embedding similarity network for visualization."""
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
err = _require_auth(request)
if err: return err
try: try:
all_buckets = await bucket_mgr.list_all(include_archive=False) all_buckets = await bucket_mgr.list_all(include_archive=False)
nodes = [] nodes = []
@@ -1098,6 +1344,8 @@ async def api_network(request):
async def api_breath_debug(request): async def api_breath_debug(request):
"""Debug endpoint: simulate breath scoring and return per-bucket breakdown.""" """Debug endpoint: simulate breath scoring and return per-bucket breakdown."""
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
err = _require_auth(request)
if err: return err
query = request.query_params.get("q", "") query = request.query_params.get("q", "")
q_valence = request.query_params.get("valence") q_valence = request.query_params.get("valence")
q_arousal = request.query_params.get("arousal") q_arousal = request.query_params.get("arousal")
@@ -1189,6 +1437,8 @@ async def dashboard(request):
async def api_config_get(request): async def api_config_get(request):
"""Get current runtime config (safe fields only, API key masked).""" """Get current runtime config (safe fields only, API key masked)."""
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
err = _require_auth(request)
if err: return err
dehy = config.get("dehydration", {}) dehy = config.get("dehydration", {})
emb = config.get("embedding", {}) emb = config.get("embedding", {})
api_key = dehy.get("api_key", "") api_key = dehy.get("api_key", "")
@@ -1216,6 +1466,8 @@ async def api_config_update(request):
"""Hot-update runtime config. Optionally persist to config.yaml.""" """Hot-update runtime config. Optionally persist to config.yaml."""
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
import yaml import yaml
err = _require_auth(request)
if err: return err
try: try:
body = await request.json() body = await request.json()
except Exception: except Exception:
@@ -1306,6 +1558,8 @@ async def api_config_update(request):
async def api_import_upload(request): async def api_import_upload(request):
"""Upload a conversation file and start import.""" """Upload a conversation file and start import."""
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
err = _require_auth(request)
if err: return err
if import_engine.is_running: if import_engine.is_running:
return JSONResponse({"error": "Import already running"}, status_code=409) return JSONResponse({"error": "Import already running"}, status_code=409)
@@ -1357,6 +1611,8 @@ async def api_import_upload(request):
async def api_import_status(request): async def api_import_status(request):
"""Get current import progress.""" """Get current import progress."""
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
err = _require_auth(request)
if err: return err
return JSONResponse(import_engine.get_status()) return JSONResponse(import_engine.get_status())
@@ -1364,6 +1620,8 @@ async def api_import_status(request):
async def api_import_pause(request): async def api_import_pause(request):
"""Pause the running import.""" """Pause the running import."""
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
err = _require_auth(request)
if err: return err
if not import_engine.is_running: if not import_engine.is_running:
return JSONResponse({"error": "No import running"}, status_code=400) return JSONResponse({"error": "No import running"}, status_code=400)
import_engine.pause() import_engine.pause()
@@ -1374,6 +1632,8 @@ async def api_import_pause(request):
async def api_import_patterns(request): async def api_import_patterns(request):
"""Detect high-frequency patterns after import.""" """Detect high-frequency patterns after import."""
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
err = _require_auth(request)
if err: return err
try: try:
patterns = await import_engine.detect_patterns() patterns = await import_engine.detect_patterns()
return JSONResponse({"patterns": patterns}) return JSONResponse({"patterns": patterns})
@@ -1385,6 +1645,8 @@ async def api_import_patterns(request):
async def api_import_results(request): async def api_import_results(request):
"""List recently imported/created buckets for review.""" """List recently imported/created buckets for review."""
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
err = _require_auth(request)
if err: return err
try: try:
limit = int(request.query_params.get("limit", "50")) limit = int(request.query_params.get("limit", "50"))
all_buckets = await bucket_mgr.list_all(include_archive=False) all_buckets = await bucket_mgr.list_all(include_archive=False)
@@ -1411,6 +1673,8 @@ async def api_import_results(request):
async def api_import_review(request): async def api_import_review(request):
"""Apply review decisions: mark buckets as important/noise/pinned.""" """Apply review decisions: mark buckets as important/noise/pinned."""
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
err = _require_auth(request)
if err: return err
try: try:
body = await request.json() body = await request.json()
except Exception: except Exception:
@@ -1446,6 +1710,34 @@ async def api_import_review(request):
return JSONResponse({"applied": applied, "errors": errors}) return JSONResponse({"applied": applied, "errors": errors})
# =============================================================
# /api/status — system status for Dashboard settings tab
# /api/status — Dashboard 设置页用系统状态
# =============================================================
@mcp.custom_route("/api/status", methods=["GET"])
async def api_system_status(request):
"""Return detailed system status for the settings panel."""
from starlette.responses import JSONResponse
err = _require_auth(request)
if err: return err
try:
stats = await bucket_mgr.get_stats()
return JSONResponse({
"decay_engine": "running" if decay_engine.is_running else "stopped",
"embedding_enabled": embedding_engine.enabled,
"buckets": {
"permanent": stats.get("permanent_count", 0),
"dynamic": stats.get("dynamic_count", 0),
"archive": stats.get("archive_count", 0),
"total": stats.get("permanent_count", 0) + stats.get("dynamic_count", 0),
},
"using_env_password": bool(os.environ.get("OMBRE_DASHBOARD_PASSWORD", "")),
"version": "1.3.0",
})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
# --- Entry point / 启动入口 --- # --- Entry point / 启动入口 ---
if __name__ == "__main__": if __name__ == "__main__":
transport = config.get("transport", "stdio") transport = config.get("transport", "stdio")

View File

@@ -14,6 +14,7 @@ import pytest
import asyncio import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
# Ensure project root importable # Ensure project root importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@@ -21,23 +22,28 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@pytest.fixture @pytest.fixture
def test_config(tmp_path): def test_config(tmp_path):
"""Minimal config pointing to a temp directory.""" """
Minimal config pointing to a temp directory.
Uses spec-correct scoring weights (after B-05, B-06, B-07 fixes).
"""
buckets_dir = str(tmp_path / "buckets") buckets_dir = str(tmp_path / "buckets")
os.makedirs(os.path.join(buckets_dir, "permanent"), exist_ok=True) os.makedirs(os.path.join(buckets_dir, "permanent"), exist_ok=True)
os.makedirs(os.path.join(buckets_dir, "dynamic"), exist_ok=True) os.makedirs(os.path.join(buckets_dir, "dynamic"), exist_ok=True)
os.makedirs(os.path.join(buckets_dir, "archive"), exist_ok=True) os.makedirs(os.path.join(buckets_dir, "archive"), exist_ok=True)
os.makedirs(os.path.join(buckets_dir, "dynamic", "feel"), exist_ok=True) os.makedirs(os.path.join(buckets_dir, "feel"), exist_ok=True)
return { return {
"buckets_dir": buckets_dir, "buckets_dir": buckets_dir,
"merge_threshold": 75,
"matching": {"fuzzy_threshold": 50, "max_results": 10}, "matching": {"fuzzy_threshold": 50, "max_results": 10},
"wikilink": {"enabled": False}, "wikilink": {"enabled": False},
# Spec-correct weights (post B-05/B-06/B-07 fix)
"scoring_weights": { "scoring_weights": {
"topic_relevance": 4.0, "topic_relevance": 4.0,
"emotion_resonance": 2.0, "emotion_resonance": 2.0,
"time_proximity": 2.5, "time_proximity": 1.5, # spec: 1.5 (was 2.5 in buggy code)
"importance": 1.0, "importance": 1.0,
"content_weight": 3.0, "content_weight": 1.0, # spec: 1.0 (was 3.0 in buggy code)
}, },
"decay": { "decay": {
"lambda": 0.05, "lambda": 0.05,
@@ -46,7 +52,7 @@ def test_config(tmp_path):
"emotion_weights": {"base": 1.0, "arousal_boost": 0.8}, "emotion_weights": {"base": 1.0, "arousal_boost": 0.8},
}, },
"dehydration": { "dehydration": {
"api_key": os.environ.get("OMBRE_API_KEY", ""), "api_key": os.environ.get("OMBRE_API_KEY", "test-key"),
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
"model": "gemini-2.5-flash-lite", "model": "gemini-2.5-flash-lite",
}, },
@@ -54,10 +60,49 @@ def test_config(tmp_path):
"api_key": os.environ.get("OMBRE_API_KEY", ""), "api_key": os.environ.get("OMBRE_API_KEY", ""),
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
"model": "gemini-embedding-001", "model": "gemini-embedding-001",
"enabled": False,
}, },
} }
@pytest.fixture
def buggy_config(tmp_path):
"""
Config using the PRE-FIX (buggy) scoring weights.
Used in regression tests to document the old broken behaviour.
"""
buckets_dir = str(tmp_path / "buckets")
for d in ["permanent", "dynamic", "archive", "feel"]:
os.makedirs(os.path.join(buckets_dir, d), exist_ok=True)
return {
"buckets_dir": buckets_dir,
"merge_threshold": 75,
"matching": {"fuzzy_threshold": 50, "max_results": 10},
"wikilink": {"enabled": False},
# Buggy weights (before B-05/B-06/B-07 fixes)
"scoring_weights": {
"topic_relevance": 4.0,
"emotion_resonance": 2.0,
"time_proximity": 2.5, # B-06: was too high
"importance": 1.0,
"content_weight": 3.0, # B-07: was too high
},
"decay": {
"lambda": 0.05,
"threshold": 0.3,
"check_interval_hours": 24,
"emotion_weights": {"base": 1.0, "arousal_boost": 0.8},
},
"dehydration": {
"api_key": "",
"base_url": "https://example.com",
"model": "test-model",
},
"embedding": {"enabled": False, "api_key": ""},
}
@pytest.fixture @pytest.fixture
def bucket_mgr(test_config): def bucket_mgr(test_config):
from bucket_manager import BucketManager from bucket_manager import BucketManager
@@ -68,3 +113,85 @@ def bucket_mgr(test_config):
def decay_eng(test_config, bucket_mgr): def decay_eng(test_config, bucket_mgr):
from decay_engine import DecayEngine from decay_engine import DecayEngine
return DecayEngine(test_config, bucket_mgr) return DecayEngine(test_config, bucket_mgr)
@pytest.fixture
def mock_dehydrator():
"""
Mock Dehydrator that returns deterministic results without any API calls.
Suitable for integration tests that do not test LLM behaviour.
"""
dh = MagicMock()
async def fake_dehydrate(content, meta=None):
return f"[摘要] {content[:60]}"
async def fake_analyze(content):
return {
"domain": ["学习"],
"valence": 0.7,
"arousal": 0.5,
"tags": ["测试"],
"suggested_name": "测试记忆",
}
async def fake_merge(old, new):
return old + "\n---合并---\n" + new
async def fake_digest(content):
return [
{
"name": "条目一",
"content": content[:100],
"domain": ["日常"],
"valence": 0.6,
"arousal": 0.4,
"tags": ["测试"],
"importance": 5,
}
]
dh.dehydrate = AsyncMock(side_effect=fake_dehydrate)
dh.analyze = AsyncMock(side_effect=fake_analyze)
dh.merge = AsyncMock(side_effect=fake_merge)
dh.digest = AsyncMock(side_effect=fake_digest)
dh.api_available = True
return dh
@pytest.fixture
def mock_embedding_engine():
"""Mock EmbeddingEngine that returns empty results — no network calls."""
ee = MagicMock()
ee.enabled = False
ee.generate_and_store = AsyncMock(return_value=None)
ee.search_similar = AsyncMock(return_value=[])
ee.delete_embedding = AsyncMock(return_value=True)
ee.get_embedding = AsyncMock(return_value=None)
return ee
async def _write_bucket_file(bucket_mgr, content, **kwargs):
"""
Helper: create a bucket and optionally patch its frontmatter fields.
Accepts extra kwargs like created/last_active/resolved/digested/pinned.
Returns bucket_id.
"""
import frontmatter as fm
direct_fields = {
k: kwargs.pop(k) for k in list(kwargs.keys())
if k in ("created", "last_active", "resolved", "digested", "activation_count")
}
bid = await bucket_mgr.create(content=content, **kwargs)
if direct_fields:
fpath = bucket_mgr._find_bucket_file(bid)
post = fm.load(fpath)
for k, v in direct_fields.items():
post[k] = v
with open(fpath, "w", encoding="utf-8") as f:
f.write(fm.dumps(post))
return bid

View File

@@ -14,11 +14,12 @@
import os import os
import pytest import pytest
import asyncio import asyncio
import pytest_asyncio
# Feel flow tests use direct BucketManager calls, no LLM needed. # Feel flow tests use direct BucketManager calls, no LLM needed.
@pytest.fixture @pytest_asyncio.fixture
async def isolated_tools(test_config, tmp_path, monkeypatch): async def isolated_tools(test_config, tmp_path, monkeypatch):
""" """
Import server tools with config pointing to temp dir. Import server tools with config pointing to temp dir.

View File

@@ -1,3 +1,4 @@
import pytest_asyncio
# ============================================================ # ============================================================
# Test 1: Scoring Regression — pure local, no LLM needed # Test 1: Scoring Regression — pure local, no LLM needed
# 测试 1评分回归 —— 纯本地,不需要 LLM # 测试 1评分回归 —— 纯本地,不需要 LLM
@@ -22,7 +23,7 @@ from tests.dataset import DATASET
# ============================================================ # ============================================================
# Fixtures: populate temp buckets from dataset # Fixtures: populate temp buckets from dataset
# ============================================================ # ============================================================
@pytest.fixture @pytest_asyncio.fixture
async def populated_env(test_config, bucket_mgr, decay_eng): async def populated_env(test_config, bucket_mgr, decay_eng):
"""Create all dataset buckets in temp dir, return (bucket_mgr, decay_eng, bucket_ids).""" """Create all dataset buckets in temp dir, return (bucket_mgr, decay_eng, bucket_ids)."""
import frontmatter as fm import frontmatter as fm

View File

@@ -98,6 +98,26 @@ def load_config(config_path: str = None) -> dict:
if env_buckets_dir: if env_buckets_dir:
config["buckets_dir"] = env_buckets_dir config["buckets_dir"] = env_buckets_dir
# OMBRE_DEHYDRATION_MODEL (with OMBRE_MODEL alias) overrides dehydration.model
env_dehy_model = os.environ.get("OMBRE_DEHYDRATION_MODEL", "") or os.environ.get("OMBRE_MODEL", "")
if env_dehy_model:
config.setdefault("dehydration", {})["model"] = env_dehy_model
# OMBRE_DEHYDRATION_BASE_URL overrides dehydration.base_url
env_dehy_base_url = os.environ.get("OMBRE_DEHYDRATION_BASE_URL", "")
if env_dehy_base_url:
config.setdefault("dehydration", {})["base_url"] = env_dehy_base_url
# OMBRE_EMBEDDING_MODEL overrides embedding.model
env_embed_model = os.environ.get("OMBRE_EMBEDDING_MODEL", "")
if env_embed_model:
config.setdefault("embedding", {})["model"] = env_embed_model
# OMBRE_EMBEDDING_BASE_URL overrides embedding.base_url
env_embed_base_url = os.environ.get("OMBRE_EMBEDDING_BASE_URL", "")
if env_embed_base_url:
config.setdefault("embedding", {})["base_url"] = env_embed_base_url
# --- Ensure bucket storage directories exist --- # --- Ensure bucket storage directories exist ---
# --- 确保记忆桶存储目录存在 --- # --- 确保记忆桶存储目录存在 ---
buckets_dir = config["buckets_dir"] buckets_dir = config["buckets_dir"]