Files
hermes-agent/docs/2026-09-10_081415-project-history-recall-plugin-v2.md

352 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 项目历史召回插件设计方案 V2
> **状态:** 设计方案,非实施代码。V2 修正了 V1 中"插件叠加 workspace.move RPC"的错误前提,补齐执行所需的全部接口定义。
**Goal:** 一个插件解决两个需求:(1) 会话项目归属,(2) 项目内历史的按需关键词+语义检索。
**定位:** Footprint Ladder 第 4 级插件。装在 `~/.hermes/plugins/project-history-recall/`。核心零改动。
---
## 一、修正:workspace.move 不可用
V1 假设插件能调用 `session.workspace.move`(tui_gateway JSON-RPC 方法)。核实后这是错误的:
- 插件工具通过 `ctx.register_tool` 注册(`hermes_cli/plugins.py:449-491`),走 `handle_function_call` → `tools/registry.py` 路径。
- `session.workspace.move` 是 tui_gateway 的 JSON-RPC 方法(`tui_gateway/methods_session.py:840`),插件工具没有它的句柄。
- 两者是不同的调用通道,插件无法"叠加"到 workspace.move 上。
**修正后的移动路径:** 插件自己实现归属更新,不调用 workspace.move。归属与 cwd 一致移动的含义变为:**插件工具同时更新归属记录和会话的 cwd/git_repo_root**——通过直接调用 `SessionDB.update_session_cwd()`(`hermes_state_sessions.py:486`)写 state.db。这是唯一的例外写入,且复用核心已有的公共写接口,不绕过核心的持久化逻辑。
## 二、插件结构
```
~/.hermes/plugins/project-history-recall/
├── plugin.yaml
├── __init__.py # register(ctx)
├── attribution.py # 归属解析(纯函数,无 IO)
├── search.py # 关键词+语义混合检索
├── store.py # 归属记录读写(state.db 只读 + update_session_cwd 例外)
└── config_schema.py # embedding 配置声明
```
## 三、需求一:项目归属
### 3.1 归属解析(attribution.py)
纯函数,输入为数据不读文件:
```python
def resolve_project(
session_row: dict, # 当前会话行:{id, cwd, git_repo_root, ...}
projects: list[dict], # projects_db.list_projects() 的结果(含 folders)
) -> dict:
"""返回归属身份。三态:
- {"kind": "explicit", "id": "p_abc", "name": "Hermes"} — 显式项目
- {"kind": "auto", "key": "path_key_of_repo_root"} — 自动 repo(Tier 2)
- {"kind": "home"} — 无归属(Home 桶)
"""
```
规则(镜像桌面端 project_tree.py 的语义,插件内重新实现):
1. 用 `session_row["cwd"]` 和 `session_row["git_repo_root"]` 匹配 projects 的 folders(最长祖先匹配,路径规范化处理 Windows casefold)。
2. 显式命中 → `{"kind": "explicit", "id": ..., "name": ...}`。
3. 无显式命中但有 git_repo_root → `{"kind": "auto", "key": path_key(git_repo_root)}`。
4. 两者都无 → `{"kind": "home"}`。
### 3.2 归属记录存储
归属记录的权威存储是 **state.db 的 sessions 表**,但插件不加列。归属关系通过**运行时解析**得出:每次检索时,从当前会话行的 cwd/git_repo_root + projects.db 的 folders 计算归属。
这等价于"归属 = cwd/git_repo_root 的函数",不需要持久化 project_id 列。优点是核心零改动;缺点是归属随 cwd 变化——用户手动改 cwd 时归属跟着变。这是接受的行为(cwd 是用户选择的工作空间,归属跟随工作空间)。
### 3.3 移动会话(project_session_move 工具)
移动 = 改 cwd 到目标项目的目录。归属自然跟随(因为归属是 cwd 的函数)。
实现:调用 `SessionDB.update_session_cwd(session_id, new_cwd, git_branch, git_repo_root, replace_git_meta=True)`(`hermes_state_sessions.py:486-517`),同时做 git 探测(`git_probe.branch()` / `git_probe.common_repo_root()`)获取 branch 和 repo_root。
**只读约束的例外:** `update_session_cwd` 是核心已有的公共写接口,插件调用它写 state.db 是合法复用,不违背"不改核心 schema"的约束。插件绝不直接 INSERT/UPDATE/DELETE messages 表。
### 3.4 工具 schema
```python
MOVE_SCHEMA = {
"name": "project_session_move",
"description": (
"Move this conversation to a different project. The working directory "
"switches to the target project's folder, and future history searches "
"will look in the new project. Use when the user says 'move this to X' "
"or 'this belongs to project Y'."
),
"parameters": {
"type": "object",
"properties": {
"project_id": {
"type": "string",
"description": (
"Target project ID (e.g. 'p_abc123'). Use '' to remove from "
"any project (moves to Home). The conversation's working "
"directory changes to the project's primary folder."
),
},
},
"required": ["project_id"],
},
}
```
Handler 逻辑:
1. `project_id` 为空 → 移到 Home:调用 `update_session_cwd` 设为 `~`(或 `terminal.cwd` 配置)。
2. `project_id` 非空 → 从 projects.db 读目标项目的 primary_path → git 探测该路径 → `update_session_cwd`。
3. 返回 `{"success": true, "project": {...}, "cwd": "...", "message": "Moved to project X, cwd now ..."}`。
4. 失败(项目不存在、路径不存在、git 探测失败)返回 `{"success": false, "error": "..."}`。
## 四、需求二:项目内历史检索
### 4.1 工具 schema
```python
SEARCH_SCHEMA = {
"name": "project_history_search",
"description": (
"Search past conversations in the SAME project as this one. "
"Returns actual messages from other sessions, with links. "
"Use when the user asks about prior work on this codebase: "
"'what did we decide about X', 'where did we leave Y'. "
"Modes: 'hybrid' (default, keyword + semantic), 'keyword' "
"(exact terms), 'semantic' (similar meaning)."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query. Keywords, phrases, or natural language.",
},
"mode": {
"type": "string",
"enum": ["hybrid", "keyword", "semantic"],
"default": "hybrid",
"description": (
"'hybrid' (default): keyword + semantic combined. "
"'keyword': exact terms only. 'semantic': similar meaning only. "
"Use 'keyword' for code symbols/error codes; 'semantic' for "
"'how did we handle X' when exact words differ."
),
},
"limit": {
"type": "integer",
"default": 3,
"description": "Max sessions to return (default 3, max 10).",
},
},
"required": ["query"],
},
}
```
### 4.2 归属过滤的 SQL
归属 = cwd 的函数,所以过滤条件直接作用在 sessions 行的 cwd/git_repo_root 上:
```sql
-- 显式项目:cwd 或 git_repo_root 在项目 folders 的子树内
WHERE (
s.cwd LIKE ? ESCAPE '\' -- folder_path + '/%'
OR s.git_repo_root LIKE ? ESCAPE '\'
OR s.cwd = ? -- folder_path 精确匹配
OR s.git_repo_root = ?
)
-- 自动 repo:git_repo_root 匹配
WHERE s.git_repo_root = ?
-- Home 桶:无 cwd 且无 git_repo_root
WHERE s.cwd IS NULL AND s.git_repo_root IS NULL
```
LIKE 的 `%` 和 `_` 用 `ESCAPE '\'` 转义。路径比较前统一规范化(去尾部 `/`,Windows casefold)。
**当前会话排除:** 归属过滤天然包含当前会话(它的 cwd 在项目里)。关键词搜索时需要额外排除当前 session_id,避免返回自己:
```sql
AND s.id != ? -- current_session_id
```
### 4.3 关键词路径
```python
def _keyword_search(db_path: str, query: str, scope_where: str, scope_params: tuple,
current_session_id: str, limit: int) -> list[dict]:
"""FTS5 search scoped to project sessions."""
# 用 messages_fts FTS5 表 + sessions JOIN
sql = f"""
SELECT m.id, m.session_id, m.role, m.content, m.timestamp,
s.title, s.started_at, s.cwd, s.git_repo_root,
rank
FROM messages_fts f
JOIN messages m ON f.rowid = m.id
JOIN sessions s ON m.session_id = s.id
WHERE messages_fts MATCH ?
AND m.role IN ('user', 'assistant')
AND m.active = 1
AND s.archived = 0 AND s.hidden = 0
AND {scope_where}
AND s.id != ?
ORDER BY rank
LIMIT ?
"""
params = (query, *scope_params, current_session_id, limit)
# ... execute, return rows
```
FTS5 查询语法沿用现有 `_sanitize_fts5_query` 的消毒规则。LIKE 回退(CJK 查询)用 `m.content LIKE ? ESCAPE '\'`。
### 4.4 语义路径
插件自有 `embeddings.db`(`~/.hermes/plugins/project-history-recall/embeddings.db`):
```sql
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY,
session_id TEXT NOT NULL,
message_id INTEGER NOT NULL,
chunk_index INTEGER NOT NULL,
content_hash TEXT NOT NULL,
model_fingerprint TEXT NOT NULL,
vector BLOB NOT NULL,
created_at REAL NOT NULL,
UNIQUE(session_id, message_id, chunk_index, model_fingerprint)
);
```
**索引构建:**
1. 归属解析出项目会话集合。
2. 读这些会话的 user/assistant 消息(`active = 1`)。
3. 按消息切片(chunk_size=512 tokens, overlap=64 tokens,起始值,P4 实测调整)。
4. 计算 content_hash(SHA-256),跳过已有且未变的 chunk。
5. 调用 embedding 模型,存向量。
**检索:**
1. 归属解析 → 项目会话 ID 集合。
2. 从 embeddings.db 读该集合内所有 chunk 的向量。
3. 计算查询向量与每个 chunk 的余弦相似度。
4. 取 Top-K(K=20,起始值),按 message_id 归并到消息级别。
**混合检索(RRF):**
- 关键词结果按 rank 排序,语义结果按相似度排序。
- RRF 分数:`1 / (k + rank_keyword) + 1 / (k + rank_semantic)`,k=60(标准值)。
- 按 message_id 归并,取 Top-N。
### 4.5 结果格式
```json
{
"success": true,
"mode": "hybrid",
"project": {"kind": "explicit", "id": "p_abc", "name": "Hermes"},
"results": [{
"session_id": "20260901_143022_a1b2",
"link": "@session:20260901_143022_a1b2",
"title": "项目归属设计讨论",
"message_id": 47,
"snippet": "……项目归属应该持久化……",
"score_source": "both",
"when": "2026-09-01 14:30"
}],
"sessions_in_scope": 12,
"semantic_status": "ready"
}
```
`semantic_status`:`disabled / consent_required / indexing / ready / partial / unavailable`。
## 五、Embedding 配置与隐私
配置走 `config.yaml` 的 `plugins.project-history-recall` 节:
```yaml
plugins:
project-history-recall:
embedding:
provider: "openai" # 或 "local"、"voyage" 等
model: "text-embedding-3-small"
base_url: "" # 可选
chunk_size: 512 # tokens
chunk_overlap: 64
top_k: 20
enabled: false # 首次需对话确认后开启
```
密钥进 `.env`(`PROJECT_HISTORY_EMBEDDING_API_KEY`)。
**隐私确认链路:**
1. `enabled: false` 时 `mode=hybrid` 或 `mode=semantic` 返回 `consent_required`,Agent 在对话中说明。
2. 用户确认后 Agent 调用配置命令写入 `enabled: true` + 服务信息。
3. 授权记录存 `~/.hermes/plugins/project-history-recall/consent.json`:`{profile, projects, provider, endpoint, model, confirmed_at, scope: "history+incremental"}`。
4. 换 provider/endpoint/扩大项目范围时重新确认。
## 六、check_fn
```python
def check_available() -> bool:
"""state.db 和 projects.db 至少一个存在即可注册(归属解析需要它们)。"""
from hermes_state import _default_db_path
return _default_db_path().parent.exists()
```
工具注册后始终可见(`hermes tools` 里能看到),但调用时归属解析失败返回明确错误。
## 七、失败模式
| 场景 | 返回 |
|---|---|
| state.db 不存在 | `{"success": false, "error": "session database not found", "code": "no_db"}` |
| projects.db 不存在 | 退化为 auto repo 归属(用 git_repo_root),不报错 |
| 当前会话无 cwd 且无 git_repo_root | `{"success": true, "results": [], "message": "No project context (no working directory recorded)", "sessions_in_scope": 0}` |
| 归属匹配到 0 个其他会话 | `{"success": true, "results": [], "message": "No other sessions in this project", "sessions_in_scope": 1}` |
| FTS5 查询语法错误 | 回退到 LIKE 查询,不报错 |
| embedding 未启用 | `semantic_status: "disabled"`,关键词结果正常返回 |
| embedding 未确认 | `semantic_status: "consent_required"`,关键词结果正常返回 |
| embedding 服务不可用 | `semantic_status: "unavailable"`,关键词结果正常返回 |
| 移动目标项目不存在 | `{"success": false, "error": "project not found: p_xxx"}` |
| 移动目标路径不存在 | `{"success": false, "error": "project folder not found: /path"}` |
## 八、与核心的交互边界
| 插件做的 | 插件不做的 |
|---|---|
| 读 state.db(SELECT only,除 update_session_cwd) | 写 messages 表、改 state.db schema |
| 读 projects.db(SELECT only) | 写 projects.db |
| 调用 SessionDB.update_session_cwd(核心公共接口) | 直接 UPDATE sessions 表 |
| 自己的 embeddings.db | 改核心任何文件 |
| 注册两个工具到 project toolset | 修改 session_search 或 inline executor |
## 九、实施顺序
| 步骤 | 内容 | 验证 |
|---|---|---|
| B0 | 验证 state.db 只读访问 + projects.db 读取 + update_session_cwd 可调用 | 临时 HERMES_HOME 下跑通 |
| P1 | attribution.py 归属解析纯函数 + 单测 | 显式/自动/Home 三态 + Windows 路径 |
| P2 | project_history_search 关键词路径 + 注册 | P/Q 隔离、Home 空、排除当前会话、溯源链接 |
| P3 | project_session_move + update_session_cwd 集成 | 移动后新检索用新归属、cwd 已切换 |
| P4 | embeddings.db + 语义路径 + RRF 混合 | 同义词命中、关键词保底、移动不重算向量 |
| P5 | 隐私确认链路 | 未确认不发送、确认后自主检索 |
## 十、不做的事
- 不改核心 session_search、inline executor、SessionDB schema、project_tree.py。
- 不做项目摘要、会话摘要、自动上下文注入。
- 不做旧会话自动归类。
- 不做权限撤销协议、版本表、跨 profile 联合检索。
- 不动全局 MEMORY/USER。
## 十一、评审自审
- **V1 的 workspace.move 错误已修正:** 插件不调 JSON-RPC,改用核心公共接口 update_session_cwd。
- **归属 = cwd 的函数:** 不加列、不改 schema,核心零改动。归属随 cwd 变化是接受的行为。
- **只读约束:** 除 update_session_cwd 外,插件对 state.db 只有 SELECT。
- **执行水准:** 工具 schema、归属输出形状、SQL WHERE 子句、失败模式、check_fn 全部定死。
- **诚实边界:** 语义检索的 chunk_size/overlap/top_k/RRF-k 是起始值,P4 实测后可能调整;embedding 模型选型待 B0 确认。