352 lines
15 KiB
Markdown
352 lines
15 KiB
Markdown
# 项目历史召回插件设计方案 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 确认。
|