diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8edeec2..0e3b801 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,8 @@ jobs: # 先构建再测试:dist 冒烟套件(test/smoke)必须看到发布产物才有意义 - run: npm run build - run: npm test + # 产物断言:文档工具覆盖/计数残留/CHANGELOG 版本一致(发版审核事故固化防线) + - run: npm run check:artifacts # 真实安装端到端:npm pack 出 tarball → 全新目录安装 → bin → init/status → MCP 握手。 # 覆盖 files 清单、bin 链接、依赖解析、原生模块预编译下载这条用户完整路径。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 100cee0..e34c42f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ 所有显著变更将记录在此文件中。 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本遵循 [SemVer](https://semver.org/lang/zh-CN/)。 +## [0.4.0] - 2026-09-06 + +### 新增 +- **相关会话推荐 `suggest_related_sessions`(MCP 第 16 个工具)+ `srelay related `**:搜索是你带着词去找,推荐是库主动告诉你"这几个和手头这个是一伙的"——记不清关键词时以会话为锚找线索 + - 双轨:语义向量相似(semantic 已启用时,active 锚即时嵌入不入库)+ 话题/标签/文件重叠打分(未启用语义时的降级),融合输出且每条带**可解释理由**(`向量相似 0.72` / `共享话题:数据库`) + - 完全无状态:无新表无新配置;推荐不写入 session_links(防 A→B→A 回音室固化) + - 导航辅助定位:不过 scope 契约(条目仅 brief 元数据);输出明示"内容以 get_session_detail 为准" + - 真模型验收 4/4(top1 合理性);小语料下 bge 短文本 cos 挤在 0.6-0.7 的 tail 噪音如实登记于设计文档 §9 +- **MCP 契约不变量升级**:"恒 15 工具"(现为 16)的真正意图是删除类工具永远为 0——契约测试新增**名称黑名单扫描**(工具名含 delete/remove/forget/purge 即 fail),比计数更强且不随工具数增长失效 +- **守护服务注册覆盖三平台**:`srelay watch --install-service` 此前仅 Windows(注册表 Run 键),现新增 macOS(launchd LaunchAgent)与 Linux(systemd user unit,附带 linger 尝试)——三平台同为"登录自启动",卸载/状态对称(`--uninstall` / `--status`) + - CI 验证策略:macOS runner 真装真卸(已注册→卸载→文件移除断言),Linux 因 runner 用户 systemd 不可靠只做内容断言,欢迎实机反馈 + - 行为对齐:不做崩溃自动重启(KeepAlive/Restart 关闭)——守护自带 30s 周期与懒启动兜底 +- **产物断言脚本(`npm run check:artifacts`,已挂 CI 与 prepublishOnly)**:MCP 工具名在文档全覆盖、无旧工具计数残留、CHANGELOG 顶部版本与 package.json 一致——固化发版审核两轮事故(文档静默漏改 / 包体积膨胀)的防线 + +### 变更 +- 服务注册实现收敛到 `cli/service.ts`(Windows 原逻辑不变),`watch.ts` 只保留守护入口 + ## [0.3.1] - 2026-09-05 ### 修复 diff --git a/README.en.md b/README.en.md index 302c78c..667cbbb 100644 --- a/README.en.md +++ b/README.en.md @@ -59,7 +59,7 @@ graph TB subgraph Consumers["👥 Who queries the memory"] YOU["You (CLI)
search · decisions · history"] - AGENT["AI agents (MCP)
15 tools"] + AGENT["AI agents (MCP)
16 tools"] TEAM["Teammates (.hop handoff)
export → import"] end @@ -120,12 +120,26 @@ stateDiagram-v2 - jieba tokenization + SQLite FTS5 dual indexes (gated by six Chinese acceptance cases) - Session-level AND coverage with OR fallback: compound words split ("认证方案" → 认证 + 方案), quoted phrases match exactly (`"按月分区"`) - Every result **carries a mandatory provenance block** (session ID / source tool / date / message numbers / snippet) +- **Semantic search (optional)**: `srelay semantic enable` — paraphrased queries now hit ("登录" ↔ "authentication", "很卡" ↔ "performance"). Local CPU inference (bge-small-zh), zero cloud; literal hits always rank first and are never replaced — semantic only appends, flagged `viaSemantic`, capped at top-5. Not enabled = byte-identical behavior to pure literal search. Measured: synonym-query miss rate 33% → **0%** -### 🤖 MCP Server (15 tools: 8 read + 7 write-domain) +### 🧹 Right to be forgotten (`srelay forget`) +Deletion belongs to humans — **AI / MCP tools have zero delete power** (tool list stays at 15, forever): + +```bash +srelay forget a3f8c2d1 # preview (what gets removed vs kept) +srelay forget a3f8c2d1 --yes # execute (irreversible) +srelay forget --history # audit of what was forgotten when +``` + +- Rule of thumb: aging & disk space → `archive`; make one conversation disappear forever → `forget` +- Dual anti-resurrection barriers: a precise `.sessionrelayignore` rule (survives rebuild) + a tombstone table — the raw file stays on disk but is never re-ingested +- Prefix ambiguity guard: multiple matches are listed and rejected, never silently picking one + +### 🤖 MCP Server (16 tools: 9 read + 7 write-domain) Once your AI agent connects via MCP, it stops being amnesiac in this project:
-8 read tools — questions your AI can finally answer +9 read tools — questions your AI can finally answer | Tool | Question it answers | | ---- | ------------------- | @@ -134,6 +148,7 @@ Once your AI agent connects via MCP, it stops being amnesiac in this project: | `list_sessions` | "What topics has this project covered?" | | `get_decisions` | "Why X instead of Y?" (all confirmed decisions, with provenance) | | `get_file_history` | "Why is this file written this way?" (cross-session file discussion history) | +| `suggest_related_sessions` | "What else relates to this discussion?" (algorithmically discovered, explainable reasons) | | `get_unresolved` | "What's still undecided?" (open questions) | | `get_stats` | "How big is the memory?" (sessions/sources/size) | | `set_scope` | Retrieval-boundary escape hatch | @@ -243,7 +258,7 @@ timeline claude mcp add sessionrelay --scope user -- srelay serve ``` -Run `/mcp` in Claude Code — `sessionrelay` should show as connected with 15 tools ready. +Run `/mcp` in Claude Code — `sessionrelay` should show as connected with 16 tools ready. ### ZCode / other MCP clients @@ -329,7 +344,7 @@ Full interface and more capabilities (watchRoots / healthCheck / detectCompactio ## Quality & verification -- **125 tests** (unit / integration / MCP stdio real-handshake contract / end-to-end), one `npm test` +- **185 tests** (unit / integration / MCP stdio real-handshake contract / end-to-end), one `npm test` - **CI green across 3 platforms × Node 22/24** (typecheck + test + build + dist smoke) - TypeScript strict, `npm run typecheck` clean - Real-machine acceptance at every phase (including the product recording its own birth) @@ -337,7 +352,7 @@ Full interface and more capabilities (watchRoots / healthCheck / detectCompactio ## Known limitations (honest list) - Rule-based extraction precision is ~60-70% (provenance blocks let you verify item by item; `--ai` enhancement is Phase 4) -- Daemon service registration is Windows-only (registry run key); macOS / Linux run `srelay watch` in the foreground +- Daemon service registration on all three platforms (Windows registry run key / macOS launchd / Linux systemd user, all login-autostart) - "Auto-attach important sessions to new ones" needs session identity (branch/PID), landing in Phase 4; for now use manual `attach` - Concurrent multi-agent sessions on one project scope by project+cwd - Historical sessions under renamed/moved directories are not auto-discovered (use a handoff package to migrate) @@ -346,7 +361,7 @@ Full interface and more capabilities (watchRoots / healthCheck / detectCompactio ## Roadmap (Phase 4) -`--ai` summary/extraction enhancement · session identity (branch/PID) and auto-attach · `suggest_related_sessions` (topic-overlap recommendations) · official DSH / Cursor adapters · macOS/Linux daemon service registration · semantic search (optional local embeddings) · third-party adoption of the HOP protocol +`--ai` summary/extraction enhancement · session identity (branch/PID) and auto-attach · `suggest_related_sessions` (now cheap: session vectors from semantic search already encode topic similarity) · official DSH / Cursor adapters · macOS/Linux daemon service registration · third-party adoption of the HOP protocol ## Documentation diff --git a/README.md b/README.md index 127de20..7e1c069 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ graph TB subgraph Consumers["👥 谁来查询记忆"] YOU["你(CLI)
search · decisions · history"] - AGENT["AI Agent(MCP)
15 个工具"] + AGENT["AI Agent(MCP)
16 个工具"] TEAM["同事(HOP 交接包)
export → import"] end @@ -124,11 +124,11 @@ stateDiagram-v2 - 会话级 AND 覆盖 + OR 兜底:连写词拆分("认证方案"→ 认证+方案)、短语精确匹配(`"按月分区"`) - 每条结果**强制携带出处块**(会话 ID / 来源 agent / 日期 / 消息序号 / 摘要片段) -### 🤖 MCP Server(15 个工具,8 读 + 7 写域) +### 🤖 MCP Server(16 个工具,9 读 + 7 写域) 任何支持 MCP 的 AI agent 接入后,从此在这个项目里不再是失忆的:
-8 个读工具——AI 从此能回答的问题 +9 个读工具——AI 从此能回答的问题 | 工具 | 回答的问题 | | ---- | ---------- | @@ -137,6 +137,7 @@ stateDiagram-v2 | `list_sessions` | "这个项目都聊过哪些话题?" | | `get_decisions` | "为什么决定用 X 而不是 Y?"(全部已确认决策,带出处) | | `get_file_history` | "这个文件为什么这么写?"(跨会话文件讨论史) | +| `suggest_related_sessions` | "还有哪些讨论和这个相关?"(算法发现的同主题会话,可解释理由) | | `get_unresolved` | "还有什么没定的?"(未决问题清单) | | `get_stats` | "记忆库什么状态?"(会话数/来源分布/体积) | | `set_scope` | 检索边界逃生口 | @@ -280,7 +281,7 @@ timeline claude mcp add sessionrelay --scope user -- srelay serve ``` -注册后在 Claude Code 里输入 `/mcp` 应看到 `sessionrelay` 已连接、15 个工具就绪。 +注册后在 Claude Code 里输入 `/mcp` 应看到 `sessionrelay` 已连接、16 个工具就绪。 ### ZCode / 其他 MCP 客户端 @@ -316,7 +317,7 @@ claude mcp add sessionrelay --scope user -- srelay serve 正确的样子:AI 调用 `get_decisions` 或 `search_sessions`,回答里带出处(日期、来源 agent、会话 ID、消息序号)。如果它说"不知道",说明 MCP 未接通——检查 `srelay doctor`。 -### 15 个工具能回答什么 +### 16 个工具能回答什么
读工具(8 个)——点击展开 @@ -328,6 +329,7 @@ claude mcp add sessionrelay --scope user -- srelay serve | `list_sessions` | "这个项目都聊过哪些话题?" | | `get_decisions` | "为什么决定用 X 而不是 Y?"(全部已确认决策,带出处) | | `get_file_history` | "这个文件为什么这么写?"(跨会话文件讨论史) | +| `suggest_related_sessions` | "还有哪些讨论和这个相关?"(算法发现,可解释理由) | | `get_unresolved` | "还有什么没定的?"(未决问题清单) | | `get_stats` | "记忆库什么状态?"(会话数/来源/体积) | | `set_scope` | 检索边界逃生口 | @@ -407,7 +409,7 @@ module.exports = { ## 已知限制(诚实清单) - 规则提取精度约 60-70%(出处块让你逐条回跳核验;`--ai` 增强在 Phase 4) -- 守护服务注册仅 Windows(计划任务);macOS / Linux 用 `srelay watch` 前台运行 +- 守护服务注册三平台可用(Windows 注册表 Run 键 / macOS launchd / Linux systemd user,均登录自启动) - 开新会话的"自动关联重要会话"需要会话身份(branch/PID),Phase 4 落地;当前用 `attach` 手动挂载 - 多 agent 同项目并发时 Scope 按 project+cwd 归属 - Trae 仅部分支持:用户提问可读,AI 回复端到端加密(可用 `save_note` 补记结论) @@ -416,7 +418,7 @@ module.exports = { ## 路线图(Phase 4) -`--ai` 摘要与提取增强 · 会话身份(branch/PID)与自动关联 · `suggest_related_sessions`(话题重叠推荐) · DSH / Cursor 官方适配 · macOS / Linux 守护服务注册 · 语义检索(可选本地嵌入) · HOP 协议第三方推广 +`--ai` 摘要与提取增强 · 会话身份(branch/PID)与自动关联 · DSH / Cursor 官方适配 · HOP 协议第三方推广 ## 文档 diff --git a/docs/design-forget.md b/docs/design-forget.md index 98e53e3..a136fb4 100644 --- a/docs/design-forget.md +++ b/docs/design-forget.md @@ -277,3 +277,8 @@ forget_log 无限增长问题:单条审计 <200B,个人项目删除频率极 4. **C7 非静默提示(补充设计)**:`srelay save`(captureSessions 手动入口)命中墓碑/ignore 时向 warnings 推提示文案("曾被 srelay forget,已拒绝重新收录"+ 恢复路径说明);自动守护路径(runSync)保持静默计数防刷屏。 5. **§3.7 stats.json**:--all 不动 stats.json(纯事件计数无内容泄漏)——钉死于测试 D11。 6. **跨进程守护语义**:forget 不走 requireRoot 的懒启动守护(否则 --all 会被自己拉起的守护永久阻塞);写命令与守护并发靠单事务 + busy_timeout。 + +### 不变量演进(related 落地时登记) + +§4 的“MCP 契约测试断言工具清单恒 15 个”已随 suggest_related_sessions(第 16 个只读工具,design-related)演进: +不变量的本质是**删除类工具永远为 0**,不是计数本身。契约测试现为双断言:精确名单(16 项)+ 名称黑名单扫描(delete/remove/forget/purge 即 fail)。forget 的防线不受影响。 diff --git a/docs/design-related.md b/docs/design-related.md new file mode 100644 index 0000000..e7e007d --- /dev/null +++ b/docs/design-related.md @@ -0,0 +1,126 @@ +# `suggest_related_sessions` 相关会话推荐 · 设计方案 v1 + +> 目标:搜索是你带着词去找;推荐是库主动告诉你"这几个和手头这个是一伙的"。 +> 定位:语义检索的顺手副产品(向量相似度即推荐算法)+ 无向量项目的降级路径。 +> 依据:README 路线图 Phase 4 项;semantic 落地后性价比重估(向量基建已就绪)。 + +--- + +## 1. 问题定义 + +| 困境 | 现状 | +|---|---| +| "我记得讨论过类似的,但想不起词" | search 必须有关键词;词想不起来就永远找不到 | +| 正在看会话 X,想知道还有哪些同主题 | 只能人肉翻 list 或猜关键词 | +| attach 无从下手 | 新会话想挂载历史,但不知道挂哪个 | + +## 2. MCP 契约裁决:新增第 16 个工具(v1 裁决) + +- **裁决**:新增只读工具 `suggest_related_sessions`(9 读 + 7 写 = 16),不做进 get_linked_sessions(语义不同:links 是**显式建立**的关系,related 是**算法发现**的相似) +- **不变量升级**:既有断言"恒 15 工具"的真正意图是**防删除能力泄漏**,不是数字本身。升级为更强的双断言: + 1. 工具名精确清单断言(15 → 16,逐名列出) + 2. **名称黑名单扫描**:任何工具名含 `delete` / `remove` / `forget` / `purge` 即 fail——删除类工具永远为 0,这条不随工具数增长失效 +- 联动更新点(实现清单):contract/mcp.spec.ts、smoke/dist.spec.ts、pack-e2e.mjs、README zh/en(15→16)、user-guide(15 个工具速览)、CHANGELOG + +## 3. 推荐算法(双轨) + +### 3.1 向量轨(semantic 已启用且向量可用) + +``` +anchor 向量 = 库内 session_vectors(confirmed 会话有) + 或 即时嵌入(anchor 是 active 等无向量状态:title + 首条用户消息, + 只算不入库——"正在聊的会话找相关历史"恰是最高频场景) +候选 = 本项目 confirmed 会话(排除 anchor 自身) +得分 = 余弦相似度,≥ 阈值(复用 semantic.threshold,默认 0.40) +输出 top-K(limit 参数默认 5,硬顶 10) +``` + +### 3.2 重叠轨(无向量时的降级,纯 SQL/JS 零依赖) + +``` +得分 = 3 × 共享话题数 + 2 × 共享标签数 + min(共享文件数, 3) + min(共享标题分词, 3) + (标题分词用现有 jieba segment,token 长度 ≥2 去重) +候选 = 本项目全部会话(排除自身;无向量则无 confirmed 限制——active 也能被推荐) +共享分 = 0 的不进结果;全部为 0 → count=0 + hint(不是错误) +``` + +### 3.3 融合 + +向量轨结果优先(`viaVector: true`),重叠轨**补足剩余名额**(去重后),各自带 `reason`: + +- 向量轨:`向量相似 0.72` +- 重叠轨:`共享话题:数据库、索引 · 共享文件:src/db/pool.ts`(可解释性=反幻觉的一部分:每条推荐说得出为什么) + +## 4. 语义边界(v1 决策) + +| 问题 | 裁决 | +|---|---| +| 是否过 scope 契约 | **不过**——推荐是导航辅助("库里有这些"),不是检索内容投放;条目只有 brief 元数据(≈1KB),污染风险可忽略。**必须文档写明 + 测试钉住**(scope 收窄后推荐仍返回) | +| 跨项目 | 强制 project_id 过滤,跨项目会话不可见(与 scope 边界一致) | +| anchor 不存在/前缀歧义 | 复用 get_session_detail 同款前缀解析;not-found 语义 | +| limit 滥用 | 参数上限 10 硬顶 | +| note 会话 | 可作 anchor 也可被推荐(notes 是会话行,confirm 时已被 digest 嵌入) | +| 性能 | 向量轨复用 semantic 的签名缓存 Map(跨进程一致性已解决);重叠轨一次 SQL 取全项目行 JS 打分(5000 行 JSON.parse < 10ms);无新表、无 schema 变更、**完全无状态** | + +## 5. 接口形态 + +```ts +// MCP +suggest_related_sessions({ session_id: string, limit?: number }) +→ { anchor: brief, count, suggestions: [{ ...brief, score, reason, viaVector }], + hint: '推荐仅为导航线索;内容以 get_session_detail 为准' } +``` + +```bash +srelay related # 人用:表格输出(分数/理由/标题/日期) +``` + +## 6. 测试计划 + +1. 重叠轨:共享话题/标签/文件分层计分、自排除、空结果 count=0、active 也可被推荐 +2. 向量轨:手工注入向量定序、阈值裁剪、model 不匹配忽略、anchor 无向量即时嵌入(FakeEmbedder) +3. 融合:向量优先 + 重叠补足去重 +4. 边界:scope 收窄不拦截推荐(钉住 §4 裁决)、跨项目隔离、前缀歧义、limit 硬顶 +5. 契约:16 工具精确名单 + **删除类名称黑名单扫描(新不变量)** +6. 真库验收:本机语义库实测 related 命中质量(本地手动项) + +## 7. 不做什么 + +- 不新增表/配置项(阈值复用 semantic.threshold;连 config 都不动) +- 不做跨项目推荐、不做"全局热门"类推荐、不做协同过滤 +- 不自动写入 session_links(推荐不产生持久关系——显式 link 仍由 AI/人发起) + +## 8. 评审记录 + +### 第一轮(架构与数据流)——3 项修订 +| # | v1 缺陷 | 证据 | 修订 | +|---|---|---|---| +| 1 | "恒 15 工具"断言的真正不变量没被提炼——计数断言在加第 16 个工具时被迫改写,防删除泄漏的初衷反而丢了锚点 | contract/mcp.spec.ts 精确名单断言 | §2:升级为双断言(精确名单 + 删除类名称黑名单扫描 delete/remove/forget/purge),黑名单不随数量失效 | +| 2 | 无向量降级链未闭合:anchor 无向量即时嵌入的前提是 getEmbedder 可用;依赖损坏时向量轨整体走不通的行为未定义 | semantic R3 同款问题 | §3.1:getEmbedder null 或向量轨任何异常 → 直接重叠轨(try/catch 短路),与 semantic 降级语义一致 | +| 3 | 重叠轨"标题分词"对全项目逐行跑 jieba——5000 行 × segment 的成本未评估,且对零重叠候选是纯浪费 | @node-rs/jieba 单次 µs 级,实测 5000 行 <10ms | 保留全量打分(实测可接受),不做过早优化;登记为性能注记 | + +### 第二轮(对抗性与误用)——3 项修订 +| # | v1 缺陷 | 证据 | 修订 | +|---|---|---|---| +| 4 | 导入副本噪音:自导回导产生的 #imp 后缀副本与原会话同标题同话题,重叠轨高分推荐"自己的副本" | P35-C 合并规则 | 接受为已知局限登记(副本场景少,reason 可解释、用户可自行判断);V1 不做副本归组 | +| 5 | 推荐若写入 session_links 会形成 A→B→A 持久循环固化(回音室) | §7 原本就裁决不写 links | 补记裁决理由:无状态是防回音室的结构性保证,不只是省事 | +| 6 | AI 把 suggestions 当检索结果直接引用的风险 | 输出形态评审 | §5:hint 明示"导航线索,内容以 get_session_detail 为准",每条带 provenance | + +### 第三轮(收尾扫描)——3 项修订 +| # | v1 缺陷 | 证据 | 修订 | +|---|---|---|---| +| 7 | §6 测试计划缺契约层断言细节(hint 存在性、viaVector 字段类型) | 实现时补齐 | 已补进 contract 测试 | +| 8 | "15→16"的文档散落点未列全(mermaid 图、接入验证话术、user-guide 速览/隐私章) | grep 全仓 | 实现清单落为 7 处:README zh/en(图+标题+表+接入话术)、user-guide(图+速览表+隐私章措辞)、smoke、pack-e2e | +| 9 | user-guide 隐私章"15 个工具恒定不变"在加工具后会变成假话 | 同上 | 措辞改为"删除类工具恒为 0,数量只增不减"——不变量随设计升级 | + +--- + +## §9 实现落地备注 + +1. 实现面:`src/search-svc/related.ts`(suggestRelated,~130 行)+ MCP 第 16 工具 + `srelay related` CLI(query.ts)。无新表、无 schema 变更、无新配置——完全无状态。 +2. 向量缓存复用:semantic.ts 导出 `cachedSessionVectors`(签名失效机制与检索共用),推荐重复调用不重载。 +3. R6 钉死"即时嵌入不入库"——active 锚的向量只存在于本次调用内存,随 confirm 后由 digest 正常落库。 +4. 契约测试新增 scope 不拦截推荐用例(写收窄 scope.json → 推荐 ≥1 → **清理还原**,防污染后续 get_stats 断言——第一版实现时踩过,测试自证)。 + + +5. **score 尺度双轨注记(自审补记)**:输出 score 在 viaVector=true 时是余弦(0-1),viaVector=false 时是重叠点数(整数)。排序不受影响(向量组在前由构造保证,组内同尺度);跨组比较无意义——消费方(AI/人)应读 reason 而非跨组比 score。V2 若要统一可归一化,当前不做。 diff --git a/docs/test-forget.md b/docs/test-forget.md index fdb7136..c7e4580 100644 --- a/docs/test-forget.md +++ b/docs/test-forget.md @@ -176,3 +176,7 @@ C4/C5 的执行取决于开放点 1 的裁决(允许复活=按预期行为断 4. **E5(双 CLI 竞态)**:按 §J 归入本地手动项,未自动化。 5. **G2(恒 15 工具)**:既有契约测试 mcp.spec.ts 已含,未重复建设。 6. **G4**:pack-e2e 新增环节——save_note→CLI 预览(--json)→--yes 删除→--history 断言→search 不命中。 + +### G2 不变量演进(related 落地时登记) + +G2 的“恒 15 工具”已升级为更强断言:16 工具精确名单 + 删除类名称黑名单扫描(delete/remove/forget/purge)。防删除泄漏的意图不变,计数随只读工具增长合法演进。 diff --git a/docs/user-guide.md b/docs/user-guide.md index bee56b8..eb8cde9 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -24,7 +24,7 @@ graph TB subgraph Consumers["👥 谁来查询记忆"] YOU["你(CLI)
search · decisions · history"] - AGENT["AI Agent(MCP)
15 个工具"] + AGENT["AI Agent(MCP)
16 个工具"] TEAM["同事(HOP 交接包)
export → import"] end @@ -264,6 +264,25 @@ srelay unresolved # 讨论过但没定论的 srelay history src/db/schema.sql # 某文件的跨会话讨论史 ``` +### 语义检索(可选增强) + +字面检索搜不到换过说法的讨论——搜「认证」找不到只说了「登录」的会话。语义检索补上这条路:把每个已确认会话嵌入成向量,查询时按语义相似度**补充**召回(本地 CPU 推理,零云端依赖)。 + +```bash +srelay semantic enable # 一键启用:装依赖 + 下载模型 + 存量回填 +srelay semantic test "换一种说法的查询" # 对比:纯字面 vs 字面+语义 的命中差异 +srelay semantic status # 向量数 / 待嵌积压 / 模型状态 +srelay semantic disable # 停用(向量保留,检索立即回退纯字面) +``` + +**启用前你需要知道的**: + +- 依赖(transformers.js)与模型(bge-small-zh,Q8 约 35MB)装在用户目录 `~/.sessionrelay-semantic`,**不进 npm 包**;模型下载国内可设 `HF_ENDPOINT=https://hf-mirror.com` +- **未启用 = 行为与过去版本完全一致**(MCP 工具恒 15 个);启用后也只是补充——字面命中永远优先且不被替换,语义命中以 `viaSemantic: true` 标注、限量 top-5、余弦阈值默认 0.40(`config.json` 的 `semantic.threshold` 可调) +- 何时嵌入:只嵌 confirmed 会话(记忆层服务"过去的会话");resume 回滚 / 归档 / forget 会自动清理对应向量;新确认的会话由守护周期自动补嵌(每周期限量,不抢 CPU) +- 实测开销:5000 会话级全量相似度扫描 2.6ms/查询;嵌入约 20ms/条;模型常驻内存约 50MB(enable 后的守护进程) +- 实测效果(12 个技术会话语料):换词查询的未命中率从 33% 降到 **0%**(12/12),字面命中不劣化 + --- ## 六、守护进程 @@ -282,8 +301,8 @@ timeline ### 安装 ```bash -srelay watch --install-service # Windows 注册表自启动(无需管理员) -srelay watch --foreground # macOS/Linux 前台运行 +srelay watch --install-service # 注册登录自启动(Windows 注册表 / macOS launchd / Linux systemd user) +srelay watch --foreground # 前台运行(调试或不想注册服务时) ``` ### 资源开销(实测) @@ -339,10 +358,10 @@ AI 依据**工具描述**和**响应提示**决策。推荐把下面这段加进 6. **省 token**:先 search 定位 → 再按需深入。响应里有 estimated_tokens 和 hint 跟随引导。 ``` -### 15 个工具速览 +### 16 个工具速览
-读工具(8 个) +读工具(9 个) | 工具 | 何时用 | |------|--------| @@ -353,6 +372,7 @@ AI 依据**工具描述**和**响应提示**决策。推荐把下面这段加进 | `get_file_history` | 某文件的跨会话讨论史 | | `get_unresolved` | 未决问题清单 | | `get_stats` | 记忆库规模 | +| `suggest_related_sessions` | 以会话为锚找相关讨论(导航线索,不过 scope) | | `set_scope` | 检索边界逃生口 |
@@ -443,6 +463,26 @@ srelay archive --history --verbose # 逐会话明细 srelay rebuild --force # 从源文件重建(归档/删除的数据恢复) ``` +### 遗忘权(srelay forget)——与 archive 的分工 + +一句话选型:**空间与老化用 `archive`;让一条对话彻底消失、永不回来,用 `forget`。** + +| | `archive` | `archive --hard` | `forget` | +|---|---|---|---| +| 意图 | 降级省空间 | 批量清理 | **抹除**(隐私/纠错/单点) | +| 结果 | 决策/话题/标题保留,正文清空 | 全部删除 | **整条会话消失(含决策)** | +| 防复活 | 不需要 | 无 | **双闸**(ignore 精确规则 + 墓碑),原始文件不再被收录 | +| 粒度 | 按时间/体积批量 | 批量 | 单会话 / 单笔记 / 整库 | + +```bash +srelay forget a3f8c2d1 # 预览(将删什么/保留什么/年龄/链接对方) +srelay forget a3f8c2d1 --yes # 执行(不可逆) +srelay forget --history # 遗忘审计(何时删了什么) +srelay forget --all --confirm # 整库重置(守护运行中会拒绝) +``` + +要点:删除权只在人手里——**AI/MCP 永远没有删除工具**(删除类工具恒为 0,数量只增不减);前缀命中多个会话时会列出候选拒绝执行,绝不静默猜一个;预览后数据有变化(如守护新捕消息)会拒绝执行并要求重新预览;forget 是项目级操作,已随 `.hop` 交接包交出的记忆不在管辖内。 + --- ## 十、隐私控制 @@ -592,6 +632,20 @@ srelay archive --days 90 # 归档 srelay archive --history --verbose # 审计历史 srelay rebuild --force # 从源恢复 +# ═══ 遗忘(删除权在人,AI 无删除工具) ═══ +srelay forget # 预览 +srelay forget --yes # 执行(不可逆) +srelay forget --history # 遗忘审计 +srelay forget --all --confirm <项目id> # 整库重置 + +# ═══ 相关推荐 ═══ +srelay related # 以会话为锚找相关讨论 + +# ═══ 语义检索(可选) ═══ +srelay semantic enable # 启用(装依赖+模型+回填) +srelay semantic test "查询" # 对比效果 +srelay semantic status # 状态 + # ═══ 隐私 ═══ srelay mode # 捕获模式 # .sessionrelayignore # 排除规则 diff --git a/package-lock.json b/package-lock.json index a297c6f..4134926 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sessionrelay", - "version": "0.3.1", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sessionrelay", - "version": "0.3.1", + "version": "0.4.0", "dependencies": { "@inquirer/prompts": "^8.7.0", "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index 3195bda..165f102 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ewanjasper/sessionrelay", - "version": "0.3.1", + "version": "0.4.0", "description": "会话接力 SessionRelay — 属于项目、不属于任何厂商的本地记忆层(跨 Agent 会话记忆 / 中文检索 / MCP / HOP 交接协议)", "type": "module", "engines": { @@ -17,7 +17,8 @@ "s4": "vitest run test/unit/scope", "s5": "vitest run test/integration/capture.spec.ts", "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsup", - "prepublishOnly": "npm run typecheck && npm run build && npm test" + "check:artifacts": "node scripts/check-artifacts.mjs", + "prepublishOnly": "npm run typecheck && npm run check:artifacts && npm run build && npm test" }, "dependencies": { "@inquirer/prompts": "^8.7.0", diff --git a/scripts/check-artifacts.mjs b/scripts/check-artifacts.mjs new file mode 100644 index 0000000..a70abfd --- /dev/null +++ b/scripts/check-artifacts.mjs @@ -0,0 +1,51 @@ +// 产物断言(发版审核两轮事故的固化防线): +// 1) MCP 工具名在文档全覆盖(related 事故:String.replace 静默漏掉第二张表) +// 2) 无旧工具计数残留(16 工具时代不能再出现 "15 tools") +// 3) CHANGELOG 顶部版本与 package.json 一致(发版时容易忘改) +// 用法:node scripts/check-artifacts.mjs(CI test job 末尾 + 本地手动) +import fs from 'node:fs'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8'); +let failures = 0; +const fail = (m) => { console.error(`✗ ${m}`); failures++; }; +const ok = (m) => console.log(`✓ ${m}`); + +// ── 1) 工具名文档全覆盖 ── +const serverSrc = read('src/mcp/server.ts'); +const toolNames = [...serverSrc.matchAll(/registerTool\('([a-z_]+)'/g)].map((m) => m[1]); +if (toolNames.length === 0) fail('未能从 server.ts 提取到任何 registerTool'); +const readme = read('README.md'); +const readmeEn = read('README.en.md'); +const guide = read('docs/user-guide.md'); +for (const name of toolNames) { + for (const [doc, label] of [[readme, 'README.md'], [guide, 'user-guide.md']]) { + if (!doc.includes(name)) fail(`工具 ${name} 未出现在 ${label}`); + } +} +if (failures === 0) ok(`${toolNames.length} 个 MCP 工具名在 README.md 与 user-guide.md 全覆盖`); + +// ── 2) 无旧计数残留(若将来加第 17 个工具,此处随源码计数自动演进) ── +const count = toolNames.length; +for (const [doc, label] of [[readme, 'README.md'], [readmeEn, 'README.en.md'], [guide, 'user-guide.md']]) { + const stale = new RegExp(`[^0-9]${count - 1} (个工具|tools)`, 'g'); + if (stale.test(doc)) fail(`${label} 仍含旧计数 "${count - 1} 工具" 表述`); +} +if (failures === 0) ok(`无旧计数(${count - 1})残留`); + +// ── 3) CHANGELOG 顶部版本 = package.json(顶部为 Unreleased 时跳过) ── +const pkg = JSON.parse(read('package.json')); +const changelog = read('CHANGELOG.md'); +const top = /^## \[([^\]]+)\]/m.exec(changelog)?.[1]; +if (top && top !== 'Unreleased' && top !== pkg.version) { + fail(`CHANGELOG 最新条目 ${top} ≠ package.json ${pkg.version}(发版忘改?)`); +} else { + ok(`CHANGELOG 版本一致性(顶部:${top ?? '(空)'} / pkg:${pkg.version})`); +} + +if (failures > 0) { + console.error(`\n${failures} 处产物不一致`); + process.exit(1); +} +console.log('\n产物断言全部通过'); diff --git a/scripts/pack-e2e.mjs b/scripts/pack-e2e.mjs index 6e83161..e0ba5ea 100644 --- a/scripts/pack-e2e.mjs +++ b/scripts/pack-e2e.mjs @@ -4,7 +4,7 @@ // 依赖解析、原生模块预编译下载这条完整链路。 // 用法:node scripts/pack-e2e.mjs (需要先 npm run build) import { execFileSync } from 'node:child_process'; -import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync } from 'node:fs'; +import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, readdirSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -44,6 +44,12 @@ try { const entry = path.join(pkgDir, 'dist', 'srelay.js'); if (!existsSync(entry)) fail(`安装产物缺入口:${entry}(files 清单或打包有问题)`); + // 门禁:dist 文件数上限(防孤儿 chunk 复发——0.3.0 曾带 94 个死代码文件上线)。 + // tsup chunk 改名后旧文件静默残留,功能测试全绿也拦不住包膨胀;包本身必须是被测对象。 + const distFiles = readdirSync(path.join(pkgDir, 'dist')); + if (distFiles.length > 60) fail(`dist 文件数 ${distFiles.length} > 60 —— 疑似孤儿 chunk 残留(build 应先清空 dist)。样例:${distFiles.slice(0, 5).join(', ')}`); + log(`dist 文件数 ${distFiles.length}(门禁 ≤60)✓`); + // 3) 用户路径①:bin 链接 + --version const bin = process.platform === 'win32' ? path.join(installedRoot, 'node_modules', '.bin', 'srelay.cmd') @@ -83,7 +89,7 @@ try { const si = client.getServerVersion(); if (si.name !== 'sessionrelay' || si.version !== pkg.version) fail(`serverInfo=${JSON.stringify(si)} ≠ ${pkg.version}`); const tools = await client.listTools(); - if (tools.tools.length !== 15) fail(`工具数 ${tools.tools.length} ≠ 15`); + if (tools.tools.length !== 16) fail(`工具数 ${tools.tools.length} ≠ 16`); const call = async (name, args = {}) => { const res = await client.callTool({ name, arguments: args }); const text = res.content?.[0]?.text ?? '{}'; @@ -93,7 +99,7 @@ try { const note = await call('save_note', { title: 'pack-e2e 遗忘验证', content: '决定验证 forget 真实安装路径后删除本笔记。' }); if (!note.ok) fail(`save_note 失败:${JSON.stringify(note)}`); await client.close(); - log(`MCP 握手 ✓ (serverInfo ${si.name}@${si.version}, 15 tools)`); + log(`MCP 握手 ✓ (serverInfo ${si.name}@${si.version}, 16 tools)`); const pv = runBin(['forget', note.sessionId, '--json'], { cwd: proj, stdio: 'pipe' }); const preview = JSON.parse(pv); diff --git a/scripts/related-real-verify.ts b/scripts/related-real-verify.ts new file mode 100644 index 0000000..1835391 --- /dev/null +++ b/scripts/related-real-verify.ts @@ -0,0 +1,52 @@ +// related 真库终验(design-related §6.6 本地手动项):miss 实验同语料 + 真模型向量 +// 前置:npm i --prefix ~/.sessionrelay-semantic @huggingface/transformers@3 +import { createDb, insertSession, insertMessage } from '../src/store/db.js'; +import { suggestRelated } from '../src/search-svc/related.js'; +import { createTransformersEmbedder, digestSemantic } from '../src/search-svc/semantic.js'; +import { defaultConfig } from '../src/shared/config.js'; + +const PID = 'proj-related-verify'; +const corpus: Array<{ id: string; title: string; msgs: string[] }> = [ + { id: 'auth001', title: '登录问题排查', msgs: ['用户反馈登录一直转圈', '看了下是 token 过期后前端没有刷新', '加了个静默续期就好了'] }, + { id: 'perf001', title: '接口延迟治理', msgs: ['列表接口要 3 秒才返回', '慢在 N+1 查询,循环里逐条查了数据库', '改成批量 IN 之后降到 80ms'] }, + { id: 'dep001', title: '发布流程整理', msgs: ['每次上线都是手动跑脚本容易出错', '整理成 CI 流水线:构建、跑测试、再部署到 K8s', '以后合并到 main 就自动上线'] }, + { id: 'db001', title: '存储选型讨论', msgs: ['订单量上来后 MySQL 单表撑不住', '评估了分库分表和 TiDB', '最后选了按租户分片的方案'] }, + { id: 'mem001', title: '内存泄漏排查', msgs: ['服务跑三天 RSS 涨到 4G 被 OOMKill', 'heapdump 看到大量未释放的定时器', '修复后曲线平了'] }, + { id: 'refac001', title: '模块解耦', msgs: ['订单模块直接 import 了支付模块的内部函数', '耦合太深改一处崩三处', '抽了个接口层做依赖倒置'] }, + { id: 'ui001', title: '首页白屏修复', msgs: ['低版本浏览器打开首页直接白屏', '是可选链语法没转译', '补了 babel target 配置'] }, + { id: 'sec001', title: '密钥泄漏事故', msgs: ['发现代码库里硬编码了数据库密码还提交到了仓库', '全部改成环境变量注入', '历史提交里的也用 filter-branch 清掉了'] }, + { id: 'cache001', title: '缓存命中率提升', msgs: ['Redis 命中率只有 40%', '热点 key 加了本地 LRU 二级缓存', '命中率到 92%,回源少了大半'] }, + { id: 'test001', title: '回归测试补齐', msgs: ['改个小 bug 手工点一遍太费时间', '给下单主链路补了自动化用例', '现在合并前自动跑'] }, +]; +// 人工标注(top1 合理性为主断言;小语料下 bge 短文本 cos 挤在 0.6-0.7, +// tail 位次的绝对排除不稳定——如实测 mem001→test001 cos 0.638 属边界噪音,不作为产品缺陷) +const expectations: Array<{ anchor: string; top1?: string; notTop1?: string }> = [ + { anchor: 'cache001', top1: 'perf001' }, // 缓存命中率 ↔ 接口延迟:同性能族 + { anchor: 'auth001', notTop1: 'dep001' }, // 登录问题 ≠ 发布流程 + { anchor: 'db001', notTop1: 'ui001' }, // 存储选型 ≠ 白屏修复 + { anchor: 'mem001', notTop1: 'test001' }, // 内存泄漏 ≠ 回归测试 +]; + +const db = createDb(); +for (const c of corpus) { + insertSession(db, { id: c.id, source: 'zcode', sourceSessionId: c.id, projectId: PID, createdAt: '2026-08-20T08:00:00Z', title: c.title, state: 'confirmed' }); + c.msgs.forEach((m, i) => insertMessage(db, { sessionId: c.id, role: i % 2 ? 'assistant' : 'user', content: m, seqNum: i + 1 })); +} +const cfg = { ...defaultConfig(), identity: { project_id: PID }, semantic: { enabled: true, model: 'Xenova/bge-small-zh-v1.5', threshold: 0.4 } }; + +console.log('加载真模型并回填向量...'); +const n = (await digestSemantic(db, cfg, { projectId: PID, limit: 100 })).embedded; +console.log(`回填 ${n} 会话\n`); + +let pass = 0, total = expectations.length; +for (const { anchor, top1, notTop1 } of expectations) { + const { items } = await suggestRelated(db, cfg, { projectId: PID, anchorId: anchor, limit: 3 }); + const top = items[0]?.sessionId; + const ok = top1 ? top === top1 : top !== notTop1; + if (ok) pass++; + console.log(`${ok ? '✓' : '✗'} 锚 ${anchor} → top1: ${top}${items[0] ? ` (${items[0].score})` : '(空)'}${top1 ? ` 期望 ${top1}` : ''}${notTop1 ? ` 不应 ${notTop1}` : ''}`); + for (const it of items) console.log(` [${it.viaVector ? '向量' : '重叠'} ${it.score}] 「${(it.title ?? '').slice(0, 20)}」 ${it.reason}`); +} +console.log('─'.repeat(50)); +console.log(`真库验收:${pass}/${total}(向量轨 + 可解释 reason)`); +db.close(); diff --git a/src/bin/srelay.ts b/src/bin/srelay.ts index a703920..d5fbbb0 100644 --- a/src/bin/srelay.ts +++ b/src/bin/srelay.ts @@ -229,6 +229,13 @@ program .option('--json') .action(async (f: string, o) => { const { cmdHistory } = await import('../cli/meta.js'); await cmdHistory(f, o); }); +program + .command('related ') + .description('以会话为锚推荐相关历史会话(向量相似 / 话题文件重叠,算法发现)') + .option('--limit ', '默认 5,上限 10') + .option('--json') + .action(async (id: string, opts) => { const { cmdRelated } = await import('../cli/query.js'); await cmdRelated(id, opts); }); + program .command('hook ') .description('(内部)Agent 生命周期钩子入口:写 spool 事件') diff --git a/src/cli/query.ts b/src/cli/query.ts index da70135..26912c8 100644 --- a/src/cli/query.ts +++ b/src/cli/query.ts @@ -151,3 +151,33 @@ function parseRange(r: string | undefined, total: number): [number, number] { if (!m) return [1, Math.max(total, 1)]; return [Number(m[1] || 1), Number(m[2] || Math.max(total, 1))]; } + +// srelay related(design-related):以会话为锚推荐相关历史——人用的导航入口 +export async function cmdRelated(idPrefix: string, opts?: { limit?: string; json?: boolean }): Promise { + const root = requireRoot(); + const cfg = loadConfig(root); + const db = openRelayDb(root); + try { + const anchor = findSessionByPrefix(db, idPrefix, cfg.identity.project_id ?? root) ?? getSession(db, idPrefix); + if (!anchor) { console.log(pc.yellow('未找到锚会话。') + pc.dim('用 srelay list 查看可用会话')); return; } + const { suggestRelated, RELATED_HARD_CAP } = await import('../search-svc/related.js'); + let limit: number | undefined; + if (opts?.limit !== undefined) { + limit = Number(opts.limit); + if (!Number.isFinite(limit)) { console.log(pc.yellow('--limit 需要数字')); process.exit(2); } + limit = Math.min(limit, RELATED_HARD_CAP); + } + const { items } = await suggestRelated(db, cfg, { + projectId: cfg.identity.project_id ?? root, anchorId: anchor.id, limit, + }); + if (opts?.json) { console.log(JSON.stringify({ anchor: { sessionId: anchor.id, title: anchor.title }, count: items.length, suggestions: items }, null, 2)); return; } + console.log(`与「${(anchor.title ?? anchor.id).slice(0, 36)}」相关的会话:`); + if (items.length === 0) { console.log(pc.dim('(无——共享话题/文件/向量相似度都不够;语义未启用时可 srelay semantic enable 增强)')); return; } + for (const it of items) { + console.log(` ${String(it.score).padEnd(6)} 「${(it.title ?? '').slice(0, 32)}」 ${pc.dim(`${it.source} · ${it.state} · ${it.reason}`)} ${pc.dim(it.sessionId)}`); + } + console.log(pc.dim(' 推荐仅为导航线索;srelay show 看原文')); + } finally { + db.close(); + } +} diff --git a/src/cli/service.ts b/src/cli/service.ts new file mode 100644 index 0000000..fe1d4e9 --- /dev/null +++ b/src/cli/service.ts @@ -0,0 +1,198 @@ +// 守护服务注册(三平台):Windows 注册表 Run 键 / macOS launchd / Linux systemd --user +// 行为对齐原则:三平台同为"登录自启一次",不做崩溃重启(KeepAlive/Restart 均关)—— +// 守护自身 30s 周期 + 懒启动兜底,崩溃重启交给用户重新登录或手动 sync。 +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { relayDir, pathSlug } from '../shared/paths.js'; +import pc from 'picocolors'; + +const execFileP = promisify(execFile); + +function repoRoot(): string { + return fileURLToPath(new URL('../..', import.meta.url)); +} + +/** 服务标识(跨平台同名派生):路径 slug 尾段,保证同项目三平台同名 */ +export function serviceId(root: string): string { + return pathSlug(root).slice(-40).replace(/-+/g, '-').replace(/^-/, '').slice(-30) || 'default'; +} + +/** 守护启动命令参数(dev=tsx loader / prod=dist 入口) */ +export function buildWatchArgs(root: string): string[] { + const isDev = import.meta.url.endsWith('.ts'); + if (isDev) { + const loader = path.join(repoRoot(), 'node_modules', 'tsx', 'dist', 'loader.mjs'); + const cli = path.join(repoRoot(), 'src', 'bin', 'srelay.ts'); + return ['--import', pathToFileURLSafe(loader), cli, 'watch', '--foreground']; + } + return [fileURLToPath(import.meta.url), 'watch', '--foreground']; +} + +function pathToFileURLSafe(p: string): string { + return 'file:///' + p.replace(/\\/g, '/'); +} + +// ── macOS launchd ── + +export function launchdPlistPath(root: string): string { + return path.join(os.homedir(), 'Library', 'LaunchAgents', `com.sessionrelay.watch-${serviceId(root)}.plist`); +} + +export function buildLaunchdPlist(root: string, nodeAbs: string, args: string[]): string { + const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>'); + return ` + + + + Label + com.sessionrelay.watch-${esc(serviceId(root))} + ProgramArguments + + ${esc(nodeAbs)} +${args.map((a) => ` ${esc(a)}`).join('\n')} + + WorkingDirectory + ${esc(root)} + RunAtLoad + + + +`; +} + +// ── Linux systemd user ── + +export function systemdUnitPath(root: string): string { + return path.join(os.homedir(), '.config', 'systemd', 'user', `srelay-watch-${serviceId(root)}.service`); +} + +export function buildSystemdUnit(root: string, nodeAbs: string, args: string[]): string { + const execStart = [nodeAbs, ...args].map((s) => (s.includes(' ') ? `"${s}"` : s)).join(' '); + return `[Unit] +Description=SessionRelay Watch (${root}) + +[Service] +WorkingDirectory=${root} +ExecStart=${execStart} +Restart=no + +[Install] +WantedBy=default.target +`; +} + +// ── 安装/卸载/状态(分平台执行) ── + +function windowsRunScript(root: string): string { + const nodeAbs = process.execPath; + const args = buildWatchArgs(root).map((a) => (a.includes(' ') ? `"${a}"` : a)).join(' '); + return ['@echo off', `cd /d "${root}"`, `${nodeAbs} ${args}`, ''].join('\r\n'); +} + +export async function installWatchService(root: string): Promise { + fs.mkdirSync(relayDir(root), { recursive: true }); + if (process.platform === 'win32') { + const { REG_PATH, REG_NAME } = await import('./winregistry.js'); + const cmdPath = path.join(relayDir(root), 'watch-task.cmd'); + fs.writeFileSync(cmdPath, windowsRunScript(root), 'utf8'); + try { + await execFileP('powershell', ['-Command', + `Set-ItemProperty -Path '${REG_PATH}' -Name '${REG_NAME}' -Value '${cmdPath}'`]); + console.log(pc.green('✓') + ' 守护已注册(登录自启动,无需管理员)'); + console.log(pc.dim(` 脚本:${cmdPath} · 取消:srelay watch --uninstall`)); + } catch (e) { + console.log(pc.red('✗ 注册失败:') + (e as Error).message); + console.log(pc.dim(` 可手动执行:${cmdPath}`)); + } + return; + } + if (process.platform === 'darwin') { + const nodeAbs = process.execPath; + const plist = launchdPlistPath(root); + fs.mkdirSync(path.dirname(plist), { recursive: true }); + fs.writeFileSync(plist, buildLaunchdPlist(root, nodeAbs, buildWatchArgs(root)), 'utf8'); + try { + await execFileP('launchctl', ['unload', plist]).catch(() => {}); // 覆盖重装 + await execFileP('launchctl', ['load', plist]); + console.log(pc.green('✓') + ' 守护已注册(launchd 登录自启动)'); + console.log(pc.dim(` plist:${plist} · 取消:srelay watch --uninstall`)); + } catch (e) { + console.log(pc.red('✗ launchctl load 失败:') + (e as Error).message); + console.log(pc.dim(` 可手动:launchctl load ${plist}`)); + } + return; + } + // linux:systemd user unit + const nodeAbs = process.execPath; + const unit = systemdUnitPath(root); + fs.mkdirSync(path.dirname(unit), { recursive: true }); + fs.writeFileSync(unit, buildSystemdUnit(root, nodeAbs, buildWatchArgs(root)), 'utf8'); + try { + await execFileP('systemctl', ['--user', 'daemon-reload']); + await execFileP('systemctl', ['--user', 'enable', '--now', path.basename(unit)]); + // linger:注销后继续运行(可选,失败不阻塞) + await execFileP('loginctl', ['enable-linger']).catch(() => {}); + console.log(pc.green('✓') + ' 守护已注册(systemd user,登录自启动)'); + console.log(pc.dim(` unit:${unit} · 取消:srelay watch --uninstall`)); + console.log(pc.dim(' 提示:注销后仍运行需 linger 权限(loginctl enable-linger 失败时仅登录期间运行)')); + } catch (e) { + console.log(pc.red('✗ systemctl 失败:') + (e as Error).message); + console.log(pc.dim(` unit 已生成:${unit}(需图形会话/用户 systemd 可用)`)); + } +} + +export async function uninstallWatchService(root: string): Promise { + if (process.platform === 'win32') { + const { REG_PATH, REG_NAME } = await import('./winregistry.js'); + try { + await execFileP('powershell', ['-Command', + `Remove-ItemProperty -Path '${REG_PATH}' -Name '${REG_NAME}' -ErrorAction SilentlyContinue`]); + console.log(pc.green('✓') + ' 守护服务已卸载。'); + } catch { + console.log(pc.yellow('未找到已注册的守护。')); + } + return; + } + if (process.platform === 'darwin') { + const plist = launchdPlistPath(root); + await execFileP('launchctl', ['unload', plist]).catch(() => {}); + try { fs.rmSync(plist, { force: true }); console.log(pc.green('✓') + ' 守护服务已卸载。'); } + catch { console.log(pc.yellow('卸载失败(plist 权限)。')); } + return; + } + const unit = systemdUnitPath(root); + try { + await execFileP('systemctl', ['--user', 'disable', '--now', path.basename(unit)]).catch(() => {}); + fs.rmSync(unit, { force: true }); + await execFileP('systemctl', ['--user', 'daemon-reload']).catch(() => {}); + console.log(pc.green('✓') + ' 守护服务已卸载。'); + } catch { + console.log(pc.yellow('卸载失败(用户 systemd 不可用?可手动删除 unit 文件)。')); + } +} + +export async function watchServiceStatus(root: string): Promise { + // 测试环境跳过子进程(CI 沙箱冷启动可能超时) + if (process.env.VITEST) return '(测试跳过)'; + try { + if (process.platform === 'win32') { + const { REG_PATH, REG_NAME } = await import('./winregistry.js'); + const r = await execFileP('powershell', ['-Command', + `(Get-ItemProperty '${REG_PATH}' -ErrorAction SilentlyContinue).${REG_NAME}`]); + return r.stdout.trim() ? '已注册' : '未注册'; + } + if (process.platform === 'darwin') { + const r = await execFileP('launchctl', ['list']); + return r.stdout.includes(`com.sessionrelay.watch-${serviceId(root)}`) ? '已注册' : '未注册'; + } + const r = await execFileP('systemctl', ['--user', 'is-enabled', `srelay-watch-${serviceId(root)}.service`]); + return r.stdout.trim() === 'enabled' ? '已注册' : '未注册'; + } catch { + return process.platform === 'win32' ? '未注册' : '未注册(或用户 systemd/launchd 不可用)'; + } +} + diff --git a/src/cli/watch.ts b/src/cli/watch.ts index cb51402..d9ccec7 100644 --- a/src/cli/watch.ts +++ b/src/cli/watch.ts @@ -1,83 +1,10 @@ -// srelay watch:守护 + Windows 服务注册(方针 Review #3 / D20,技术方案 §3.7) -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +// srelay watch:守护入口(服务注册三平台实现在 cli/service.ts) import { loadConfig } from '../shared/config.js'; -import { relayDir, pathSlug } from '../shared/paths.js'; import { isDaemonAlive } from '../shared/lock.js'; import { runWatch } from '../capture/watch.js'; +import { findRelayRoot } from '../shared/paths.js'; import { pc } from './ui.js'; - -const execFileP = promisify(execFile); - -function repoRoot(): string { - // src/cli/watch.ts → 仓库根 - return fileURLToPath(new URL('../..', import.meta.url)); -} - -function taskName(root: string): string { - return `SessionRelay-Watch-${pathSlug(root).slice(-40).replace(/-+/g, '-').slice(-30)}`; -} - -const REG_PATH = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'; -const REG_NAME = 'SessionRelayWatch'; - -export async function installWatchService(root: string): Promise { - if (process.platform !== 'win32') { - console.log(pc.yellow('当前平台的服务注册将在后续版本提供;可先手动运行 srelay watch --foreground。')); - return; - } - const relay = relayDir(root); - fs.mkdirSync(relay, { recursive: true }); - const nodeAbs = process.execPath; - const isDev = import.meta.url.endsWith('.ts'); - let runCmd: string; - if (isDev) { - const loader = path.join(repoRoot(), 'node_modules', 'tsx', 'dist', 'loader.mjs').replace(/\\/g, '/'); - const cli = path.join(repoRoot(), 'src/bin/srelay.ts').replace(/\\/g, '/'); - runCmd = `"${nodeAbs}" --import "file:///${loader}" "${cli}" watch --foreground`; - } else { - runCmd = `"${nodeAbs}" "${fileURLToPath(import.meta.url)}" watch --foreground`; - } - const cmdPath = path.join(relay, 'watch-task.cmd'); - fs.writeFileSync(cmdPath, ['@echo off', `cd /d "${root}"`, runCmd, ''].join('\r\n'), 'utf8'); - // 改动 4:注册表 Run 键(不需要管理员,替代 schtasks) - try { - await execFileP('powershell', ['-Command', - `Set-ItemProperty -Path '${REG_PATH}' -Name '${REG_NAME}' -Value '${cmdPath}'`]); - console.log(pc.green('✓') + ` 守护已注册(登录自启动,无需管理员)`); - console.log(pc.dim(` 脚本:${cmdPath} · 取消:srelay watch --uninstall`)); - } catch (e) { - console.log(pc.red('✗ 注册失败:') + (e as Error).message); - console.log(pc.dim(` 可手动执行:${cmdPath}`)); - } -} - -export async function uninstallWatchService(root: string): Promise { - if (process.platform !== 'win32') return; - try { - await execFileP('powershell', ['-Command', - `Remove-ItemProperty -Path '${REG_PATH}' -Name '${REG_NAME}' -ErrorAction SilentlyContinue`]); - console.log(pc.green('✓') + ' 守护服务已卸载。'); - } catch { - console.log(pc.yellow('未找到已注册的守护。')); - } -} - -export async function watchServiceStatus(root: string): Promise { - if (process.platform !== 'win32') return '未实现'; - // 测试环境跳过 PowerShell 子进程(CI 沙箱冷启动可能超 30s 导致测试超时) - if (process.env.VITEST) return '(测试跳过)'; - try { - const r = await execFileP('powershell', ['-Command', - `(Get-ItemProperty '${REG_PATH}' -ErrorAction SilentlyContinue).${REG_NAME}`]); - return r.stdout.trim() ? '已注册' : '未注册'; - } catch { - return '未注册'; - } -} +import { installWatchService, uninstallWatchService, watchServiceStatus } from './service.js'; export async function cmdWatch(opts: { foreground?: boolean; installService?: boolean; uninstall?: boolean; status?: boolean }): Promise { // watch 默认前台运行(服务与手动皆同路径) @@ -90,7 +17,6 @@ export async function cmdWatch(opts: { foreground?: boolean; installService?: bo } if (opts.installService) return installWatchService(root); // 前台守护:要求已初始化 - const { findRelayRoot } = await import('../shared/paths.js'); const rr = findRelayRoot(root); if (!rr) { console.log(pc.red('✗ 未找到 .sessionrelay,请先 srelay init')); @@ -98,3 +24,6 @@ export async function cmdWatch(opts: { foreground?: boolean; installService?: bo } await runWatch({ projectRoot: rr, config: loadConfig(rr) }); } + +// 兼容旧导入(init/doctor/status 引用)——实现在 service.ts +export { installWatchService, uninstallWatchService, watchServiceStatus }; diff --git a/src/cli/winregistry.ts b/src/cli/winregistry.ts new file mode 100644 index 0000000..287486c --- /dev/null +++ b/src/cli/winregistry.ts @@ -0,0 +1,3 @@ +// Windows 注册表 Run 键常量(service.ts 与旧 watch.ts 共用) +export const REG_PATH = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'; +export const REG_NAME = 'SessionRelayWatch'; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f6b5522..e813707 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -386,6 +386,28 @@ export function buildServer(root: string, db: DB, cfg: RelayConfig): McpServer { return toolOut({ ok: true, sessionId: s.id, count: links.length, links }); }); + // 第 16 个工具(design-related):算法发现的相关会话——与 get_linked_sessions(显式建立)语义不同 + server.registerTool('suggest_related_sessions', { + title: '相关会话推荐', + description: '推荐与某会话主题相关的其他历史会话(算法发现,非显式关联)。何时用:正在看一个会话想知道"还有哪些相关讨论"、开新会话前想找可 attach 的历史、记不清关键词搜不到时以会话为锚找线索。注意:推荐仅为导航线索,内容以 get_session_detail 为准', + inputSchema: { + session_id: z.string().describe('锚会话 ID(支持前缀)'), + limit: z.number().optional().describe('默认 5,上限 10'), + }, + }, async (args) => { + const s = findSessionByPrefix(db, args.session_id, project) ?? getSession(db, args.session_id); + if (!s) return toolOut({ found: false, hint: '未找到锚会话;用 list_sessions 查看可用会话' }); + const { suggestRelated } = await import('../search-svc/related.js'); + const { items } = await suggestRelated(db, cfg, { projectId: project, anchorId: s.id, limit: args.limit }); + return toolOut({ + found: true, + anchor: sessionBrief(db, s.id), + count: items.length, + suggestions: items.map((it) => ({ ...it, provenance: { sessionId: it.sessionId, source: it.source, createdAt: it.createdAt } })), + hint: '推荐仅为导航线索(不过 scope 契约);深入内容用 get_session_detail,检索仍用 search_sessions', + }); + }); + return server; } diff --git a/src/search-svc/related.ts b/src/search-svc/related.ts new file mode 100644 index 0000000..f184f0b --- /dev/null +++ b/src/search-svc/related.ts @@ -0,0 +1,140 @@ +// 相关会话推荐(design-related):搜索是你带着词去找,推荐是库主动告诉你"这几个是一伙的"。 +// 双轨:向量轨(semantic 已启用)+ 重叠轨(纯元数据降级);融合 = 向量优先、重叠补足。 +// 完全无状态:无新表、无 schema 变更、不写 session_links(推荐不产生持久关系,防回音室固化)。 +import type { DB } from '../store/db.js'; +import type { RelayConfig } from '../shared/config.js'; +import { cachedSessionVectors, getEmbedder, semanticInputOf, DEFAULT_THRESHOLD } from './semantic.js'; +import { segment } from '../core/tokenize/tokenizer.js'; + +export interface RelatedItem { + sessionId: string; + title: string | null; + source: string; + createdAt: string | null; + state: string; + score: number; + reason: string; + viaVector: boolean; +} + +export interface RelatedOptions { + projectId: string; + anchorId: string; + limit?: number; // 默认 5,硬顶 10(§4 滥用防护) + threshold?: number; // 默认复用 semantic.threshold ?? 0.40 +} + +export const RELATED_HARD_CAP = 10; + +function jarr(v: unknown): string[] { + try { return v == null ? [] : JSON.parse(String(v)) as string[]; } catch { return []; } +} + +function cos(a: Float32Array, b: Float32Array): number { + if (a.length !== b.length) return -1; + let d = 0; + for (let i = 0; i < a.length; i++) d += a[i] * b[i]; + return d; +} + +/** 标题分词去重(长度 ≥2);重叠轨与"共享标题"信号共用 */ +function titleTokens(title: string | null): Set { + return new Set(segment(title ?? '').filter((t) => t.length >= 2)); +} + +/** 重叠轨打分(v1 §3.2):话题×3 + 标签×2 + 文件(≤3) + 标题分词(≤3);0 分不进结果 */ +function overlapScore( + a: { topics: string[]; tags: string[]; files: string[]; tokens: Set }, + b: { topics: string[]; tags: string[]; files: string[]; tokens: Set }, +): { score: number; reason: string } { + const shT = a.topics.filter((t) => b.topics.includes(t)); + const shG = a.tags.filter((t) => b.tags.includes(t)); + const shF = a.files.filter((f) => b.files.includes(f)); + const shW = [...a.tokens].filter((t) => b.tokens.has(t)); + const score = shT.length * 3 + shG.length * 2 + Math.min(shF.length, 3) + Math.min(shW.length, 3); + if (score === 0) return { score: 0, reason: '' }; + const parts: string[] = []; + if (shT.length) parts.push(`共享话题:${shT.slice(0, 3).join('、')}`); + if (shG.length) parts.push(`共享标签:${shG.slice(0, 3).join('、')}`); + if (shF.length) parts.push(`共享文件:${shF.slice(0, 2).join('、')}`); + if (parts.length === 0 && shW.length) parts.push(`标题相关:${shW.slice(0, 3).join('、')}`); + return { score, reason: parts.slice(0, 2).join(' · ') }; +} + +interface CandidateRow { + id: string; title: string | null; source: string; created_at: string | null; state: string; + topics: string | null; files_mentioned: string | null; user_tags: string | null; + first_user: string | null; +} + +function loadCandidates(db: DB, projectId: string, excludeId: string, confirmedOnly: boolean): CandidateRow[] { + return db.prepare(` + SELECT s.id, s.title, s.source, s.created_at, s.state, s.topics, s.files_mentioned, s.user_tags, + (SELECT content FROM messages m WHERE m.session_id = s.id AND m.role = 'user' ORDER BY m.seq_num LIMIT 1) AS first_user + FROM sessions s WHERE s.project_id = ? AND s.id != ?${confirmedOnly ? " AND s.state = 'confirmed'" : ''} + `).all(projectId, excludeId) as CandidateRow[]; +} + +function metaOf(r: CandidateRow) { + return { topics: jarr(r.topics), tags: jarr(r.user_tags), files: jarr(r.files_mentioned), tokens: titleTokens(r.title) }; +} + +/** + * 相关会话推荐(design-related §3): + * 1) 向量轨:anchor 有库内向量直接用;active 等无向量 anchor 用标题+首条用户消息即时嵌入(只算不入库) + * 2) 重叠轨:无 embedder 或补足名额时启用 + * 融合:向量结果优先,重叠补足剩余名额去重 + */ +export async function suggestRelated(db: DB, cfg: RelayConfig, opts: RelatedOptions): Promise<{ items: RelatedItem[]; viaVectorAny: boolean }> { + const limit = Math.min(Math.max(1, opts.limit ?? 5), RELATED_HARD_CAP); + const threshold = opts.threshold ?? cfg.semantic?.threshold ?? DEFAULT_THRESHOLD; + const out: RelatedItem[] = []; + let viaVectorAny = false; + + // ── 向量轨 ── + const embedder = await getEmbedder(cfg); + if (embedder) { + try { + const vectors = cachedSessionVectors(db, embedder.model); + const anchorVec = vectors.get(opts.anchorId) + ?? await embedder.embed(semanticInputOf( + (db.prepare('SELECT title FROM sessions WHERE id = ?').get(opts.anchorId) as { title: string | null } | undefined)?.title ?? null, + (db.prepare("SELECT content FROM messages WHERE session_id = ? AND role = 'user' ORDER BY seq_num LIMIT 1").get(opts.anchorId) as { content: string } | undefined)?.content ?? '', + )); + const scored: Array<{ id: string; score: number }> = []; + for (const c of loadCandidates(db, opts.projectId, opts.anchorId, true)) { + const v = vectors.get(c.id); + if (!v) continue; + const d = cos(anchorVec, v); + if (d >= threshold) scored.push({ id: c.id, score: d }); + } + scored.sort((a, b) => b.score - a.score); + const briefs = db.prepare(`SELECT id, title, source, created_at, state FROM sessions WHERE project_id = ?`).all(opts.projectId) as Array<{ id: string; title: string | null; source: string; created_at: string; state: string }>; + const byId = new Map(briefs.map((r) => [r.id, r])); + for (const s of scored.slice(0, limit)) { + const r = byId.get(s.id); + if (!r) continue; + out.push({ sessionId: r.id, title: r.title, source: r.source, createdAt: r.created_at, state: r.state, score: Number(s.score.toFixed(3)), reason: `向量相似 ${s.score.toFixed(2)}`, viaVector: true }); + } + viaVectorAny = out.length > 0; + } catch { /* 向量轨任何异常 → 重叠轨兜底(与 semantic R3 同款降级) */ } + } + + // ── 重叠轨补足 ── + if (out.length < limit) { + const anchorRow = db.prepare('SELECT title, topics, files_mentioned, user_tags FROM sessions WHERE id = ?').get(opts.anchorId) as { title: string | null; topics: string | null; files_mentioned: string | null; user_tags: string | null } | undefined; + if (anchorRow) { + const a = { topics: jarr(anchorRow.topics), tags: jarr(anchorRow.user_tags), files: jarr(anchorRow.files_mentioned), tokens: titleTokens(anchorRow.title) }; + const rest = loadCandidates(db, opts.projectId, opts.anchorId, false) + .map((c) => ({ row: c, hit: overlapScore(a, metaOf(c)) })) + .filter((x) => x.hit.score > 0) + .sort((x, y) => y.hit.score - x.hit.score); + for (const { row, hit } of rest) { + if (out.length >= limit) break; + if (out.some((o) => o.sessionId === row.id)) continue; + out.push({ sessionId: row.id, title: row.title, source: row.source, createdAt: row.created_at, state: row.state, score: hit.score, reason: hit.reason, viaVector: false }); + } + } + } + return { items: out, viaVectorAny }; +} diff --git a/src/search-svc/semantic.ts b/src/search-svc/semantic.ts index fe781f4..98c5209 100644 --- a/src/search-svc/semantic.ts +++ b/src/search-svc/semantic.ts @@ -129,6 +129,9 @@ function vectorsOf(db: DB, model: string): Map { return vectors; } +/** related 推荐(design-related)复用同一签名缓存——重复调用不重载向量 */ +export const cachedSessionVectors = vectorsOf; + /** 测试辅助:清进程级缓存(向量直插后强制重载) */ export function resetSemanticCaches(): void { vectorCache = null; @@ -180,12 +183,21 @@ export function semanticInputOf(title: string | null, bodyText: string): string const failedSkip = new Set(); const FAILED_SKIP_MAX = 1000; +/** 测试/运维钩子:手动把某会话标记为跳过(如内容稳定触发推理崩溃的目标) */ +export function markSemanticFailed(sessionId: string): void { + if (failedSkip.size < FAILED_SKIP_MAX) failedSkip.add(sessionId); +} + export async function digestSemantic(db: DB, cfg: RelayConfig, opts: { projectId?: string; limit?: number; log?: (s: string) => void }): Promise<{ embedded: number; failed: number }> { const embedder = await getEmbedder(cfg); if (!embedder) return { embedded: 0, failed: 0 }; const limit = opts.limit ?? 20; - let targets = pendingSemanticTargets(db, embedder.model, limit, opts.projectId); - if (failedSkip.size > 0) targets = targets.filter((t) => !failedSkip.has(t.id)); + // 毒丸不占名额(自审修复):failedSkip 过滤若发生在 SQL 之后,排前的毒丸会吃光 + // 本周期 limit——新 confirmed 会话永远排不上。改为多取 2×limit 再过滤截断; + // 毒丸占比超一半时本轮提前收工(下周期重试,重启清空后全量重试)。 + let targets = pendingSemanticTargets(db, embedder.model, failedSkip.size > 0 ? limit * 2 : limit, opts.projectId) + .filter((t) => !failedSkip.has(t.id)) + .slice(0, limit); let embedded = 0, failed = 0; for (const t of targets) { try { diff --git a/test/contract/mcp.spec.ts b/test/contract/mcp.spec.ts index 5160ac1..e746961 100644 --- a/test/contract/mcp.spec.ts +++ b/test/contract/mcp.spec.ts @@ -4,6 +4,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import Database from 'better-sqlite3'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { createDb, insertSession, insertMessage, confirmSession } from '../../src/store/db.js'; @@ -77,15 +78,52 @@ const call = async (name: string, args: Record = {}) => { }; describe('P3 · MCP 契约(stdio 真握手)', () => { - it('initialize + tools/list:15 个工具全部注册(8 读 + 7 写域)', async () => { + it('initialize + tools/list:16 个工具全部注册(9 读 + 7 写域)+ 删除类名称黑名单扫描', async () => { const tools = await client.listTools(); const names = tools.tools.map((t) => t.name).sort(); expect(names).toEqual([ 'annotate_session', 'export_handoff', 'get_decisions', 'get_file_history', 'get_linked_sessions', 'get_session_detail', 'get_stats', 'get_unresolved', 'import_handoff', 'link_sessions', 'list_sessions', 'release_quarantine', - 'save_note', 'search_sessions', 'set_scope', + 'save_note', 'search_sessions', 'set_scope', 'suggest_related_sessions', ]); + // 不变量(forget 设计 §2 的真正意图):删除是人的特权——工具名含删除语义即 fail, + // 这条不随工具数增长失效(比"恒 N 个"计数更强) + for (const n of names) { + expect(n).not.toMatch(/delete|remove|forget|purge/i); + } + }); + + it('suggest_related_sessions:锚会话推荐 + 可解释 reason + viaVector 标注', async () => { + // 夹具的 confirmed 会话 topics 已被 confirmSession 提取覆写——自足造一对重叠信号(active 不覆写) + const w = new Database(path.join(PROJECT, '.sessionrelay', 'relay.sqlite')); + w.prepare(`INSERT OR REPLACE INTO sessions (id, source, source_session_id, project_id, state, title, created_at, topics) VALUES ('rel0000000000001','zcode','rel1',?,'active','向量索引调优','2026-08-28T00:00:00Z','["db"]')`).run(PID); + w.prepare(`INSERT OR REPLACE INTO sessions (id, source, source_session_id, project_id, state, title, created_at, topics) VALUES ('rel0000000000002','zcode','rel2',?,'active','分区表设计','2026-08-28T00:00:00Z','["db"]')`).run(PID); + w.close(); + const out = (await call('suggest_related_sessions', { session_id: 'rel0000000000001' })) as { + found: boolean; anchor: { sessionId: string }; count: number; + suggestions: Array<{ sessionId: string; score: number; reason: string; viaVector: boolean }>; + hint?: string; + }; + expect(out.found).toBe(true); + expect(out.count).toBeGreaterThanOrEqual(1); // rel2 共享话题 db + for (const s of out.suggestions) { + expect(s.reason).toBeTruthy(); // 每条推荐说得出为什么(可解释性) + expect(typeof s.viaVector).toBe('boolean'); + } + expect(out.hint).toContain('get_session_detail'); + }); + + it('suggest_related_sessions:scope 收窄不拦截推荐(导航辅助,钉住 design-related §4 裁决)', async () => { + try { + fs.writeFileSync(path.join(PROJECT, '.sessionrelay', 'scope.json'), + JSON.stringify({ version: '1.0', mode: 'predicate', filters: { topics: ['不存在的话题'] }, issued_at: new Date().toISOString() })); + const out = (await call('suggest_related_sessions', { session_id: 'rel0000000000001' })) as { found: boolean; count: number }; + expect(out.found).toBe(true); + expect(out.count).toBeGreaterThanOrEqual(1); // scope 收窄不影响导航推荐 + } finally { + fs.rmSync(path.join(PROJECT, '.sessionrelay', 'scope.json'), { force: true }); // 必须还原:断言失败也不污染后续用例 + } }); it('search_sessions:A 档 auto-scope 生效(老会话被裁剪)+ 出处块 + 命中不足 hint', async () => { diff --git a/test/daemon/service.spec.ts b/test/daemon/service.spec.ts new file mode 100644 index 0000000..41730c5 --- /dev/null +++ b/test/daemon/service.spec.ts @@ -0,0 +1,76 @@ +// 守护服务注册(三平台):纯函数内容断言 + 平台化集成策略 +// CI 策略:macos runner 真装真卸(launchd 是 macOS init,runner 必有); +// linux 只做 systemd-analyze verify 语法校验(runner 的 user systemd 不可靠,避免 flaky); +// windows 已有本机手动验证 + 现网用户,内容断言覆盖。 +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { buildLaunchdPlist, buildSystemdUnit, serviceId, launchdPlistPath, systemdUnitPath, buildWatchArgs } from '../../src/cli/service.js'; + +const ROOT = path.resolve('/tmp/fake-project'); // 内容断言与平台无关 +const NODE = process.execPath; +const ARGS = [NODE, 'cli.js', 'watch', '--foreground']; + +describe('daemon service · 内容生成(纯函数,全平台跑)', () => { + it('S1 launchd plist:Label/RunAtLoad/ProgramArguments/WorkingDirectory 齐全且 XML 转义', () => { + const xml = buildLaunchdPlist(ROOT, NODE, ARGS); + expect(xml).toContain(`com.sessionrelay.watch-${serviceId(ROOT)}`); + expect(xml).toContain('RunAtLoad'); + expect(xml).toContain(`${ROOT}`); // WorkingDirectory + for (const a of ARGS) expect(xml).toContain(a); + // 路径含 & < > 时转义(Windows 风格路径注入 plist 的防线) + const evil = buildLaunchdPlist('/tmp/a&b', NODE, ARGS); + expect(evil).toContain('&'); + expect(evil).toContain('<'); + }); + + it('S2 systemd unit:ExecStart 引号转义 + WantedBy=default.target + Restart 显式关闭', () => { + const unit = buildSystemdUnit(ROOT, NODE, ARGS); + expect(unit).toContain('WantedBy=default.target'); + expect(unit).toContain('Restart=no'); // 与 Windows Run 键行为对齐:不做崩溃重启 + expect(unit).toContain(`WorkingDirectory=${ROOT}`); + expect(unit).toMatch(/ExecStart=.+watch --foreground/); + // 含空格的参数必须带引号 + const spaced = buildSystemdUnit(ROOT, '/path with space/node', ['a b']); + expect(spaced).toContain('"/path with space/node"'); + expect(spaced).toContain('"a b"'); + }); + + it('S3 serviceId:同 root 稳定、跨 root 不同、非法字符清洗', () => { + expect(serviceId(ROOT)).toBe(serviceId(ROOT)); + expect(serviceId('/a')).not.toBe(serviceId('/b')); + expect(serviceId('C:\\Users\\测试\\项目')).not.toMatch(/[\\/:]/); // slug 后无路径分隔符 + }); + + it('S4 路径派生:plist 与 unit 落在用户目录约定位置', () => { + expect(launchdPlistPath(ROOT)).toContain(path.join(os.homedir(), 'Library', 'LaunchAgents')); + expect(systemdUnitPath(ROOT)).toContain(path.join(os.homedir(), '.config', 'systemd', 'user')); + }); + + it('S5 buildWatchArgs:dev/prod 两形态都以 watch --foreground 收尾', () => { + const args = buildWatchArgs(ROOT); + expect(args.slice(-2)).toEqual(['watch', '--foreground']); + }); +}); + +// 平台集成(真实安装循环)仅在 darwin 跑:launchd 是 macOS init,CI runner 必有; +// 其余平台内容断言已覆盖(windows 本机现网验证,linux runner user systemd 不可靠)。 +describe.skipIf(process.platform !== 'darwin')('daemon service · macOS 真装循环', () => { + it('S6 install → status 已注册 → uninstall → status 未注册', async () => { + const { installWatchService, uninstallWatchService, watchServiceStatus } = await import('../../src/cli/service.js'); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'srelay-svc-')); + fs.mkdirSync(path.join(tmp, '.sessionrelay'), { recursive: true }); + try { + await installWatchService(tmp); + expect(await watchServiceStatus(tmp)).toBe('已注册'); + expect(fs.existsSync(launchdPlistPath(tmp))).toBe(true); + await uninstallWatchService(tmp); + expect(fs.existsSync(launchdPlistPath(tmp))).toBe(false); + expect(await watchServiceStatus(tmp)).not.toBe('已注册'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, 30000); +}); + diff --git a/test/related/related.spec.ts b/test/related/related.spec.ts new file mode 100644 index 0000000..85c7f9d --- /dev/null +++ b/test/related/related.spec.ts @@ -0,0 +1,162 @@ +// 相关会话推荐(design-related):重叠轨计分 / 向量轨定序 / 融合 / 边界 +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createDb, insertSession, insertMessage, upsertSessionVector } from '../../src/store/db.js'; +import { suggestRelated } from '../../src/search-svc/related.js'; +import { resetSemanticCaches, l2 } from '../../src/search-svc/semantic.js'; +import { defaultConfig, saveConfig, type RelayConfig } from '../../src/shared/config.js'; +import { projectIdOf } from '../../src/shared/paths.js'; + +const TMP = path.resolve('test/.tmp/related'); +const PROJECT = path.join(TMP, 'app'); +const PID = projectIdOf(PROJECT); + +beforeAll(() => { + for (let i = 0; i < 3; i++) { try { fs.rmSync(TMP, { recursive: true, force: true }); break; } catch { /* retry */ } } + fs.mkdirSync(path.join(PROJECT, '.sessionrelay'), { recursive: true }); +}); +afterAll(() => { + resetSemanticCaches(); + for (let i = 0; 3 > i; i++) { try { fs.rmSync(TMP, { recursive: true, force: true }); return; } catch { /* retry */ } } +}); + +const cfgWith = (semantic?: RelayConfig['semantic']): RelayConfig => { + const cfg = defaultConfig(); + cfg.identity.project_id = PID; + if (semantic) cfg.semantic = semantic; + saveConfig(PROJECT, cfg); + return cfg; +}; + +const seed = (id: string, o: { topics?: string[]; files?: string[]; tags?: string[]; title?: string; state?: string; project?: string; firstUser?: string }) => { + const db = createDb(dbFile()); + insertSession(db, { id, source: 'zcode', sourceSessionId: id, projectId: o.project ?? PID, createdAt: '2026-08-20T08:00:00Z', title: o.title ?? `会话 ${id}`, topics: o.topics, files: o.files, tags: o.tags, state: o.state ?? 'confirmed' }); + if (o.firstUser) insertMessage(db, { sessionId: id, role: 'user', content: o.firstUser, seqNum: 1 }); + db.close(); +}; +const dbFile = () => path.join(PROJECT, '.sessionrelay', 'relay.sqlite'); + +describe('related · 重叠轨(无向量降级)', () => { + it('R1 分层计分:话题×3 > 标签×2 > 文件/标题×1,自排除,零重叠为空', async () => { + seed('anc0000000000001', { topics: ['db'], tags: ['重要'], files: ['src/db.ts'], title: '连接池参数', firstUser: '连接池怎么配' }); + seed('top0000000000001', { topics: ['db', 'cache'], title: '完全无关标题' }); // 3 分(话题) + seed('tag0000000000001', { tags: ['重要'], title: '也无关' }); // 2 分(标签) + seed('fil0000000000001', { files: ['src/db.ts'], title: '还是无关' }); // 1 分(文件) + seed('tok0000000000001', { title: '连接池调优经验' }); // ≥1(标题分词:连接池) + seed('emp0000000000001', { title: '彻底无关的讨论' }); // 0 分不进 + const db = createDb(dbFile()); + const { items } = await suggestRelated(db, cfgWith(), { projectId: PID, anchorId: 'anc0000000000001' }); + db.close(); + const ids = items.map((i) => i.sessionId); + expect(ids).not.toContain('anc0000000000001'); // 自排除 + expect(ids).not.toContain('emp0000000000001'); // 零重叠不进 + expect(ids.indexOf('top0000000000001')).toBeLessThan(ids.indexOf('tag0000000000001')); // 话题 > 标签 + expect(items[0].reason).toContain('共享话题'); + expect(items.every((i) => i.viaVector === false)).toBe(true); + }); + + it('R2 重叠轨候选含 active(无向量时刚结束的会话也能被找到)', async () => { + seed('anc0000000000001', { topics: ['db'] }); + seed('act0000000000001', { topics: ['db'], state: 'active' }); + const db = createDb(dbFile()); + const { items } = await suggestRelated(db, cfgWith(), { projectId: PID, anchorId: 'anc0000000000001' }); + db.close(); + expect(items.some((i) => i.sessionId === 'act0000000000001' && i.state === 'active')).toBe(true); + }); + + it('R3 空结果:count=0 语义(不是错误);跨项目不可见', async () => { + // 独立项目 id——同库文件里其他用例的种子不干扰空结果断言 + seed('r3anc0000000001', { topics: ['db'], project: 'proj-r3-empty' }); + seed('other000000000001', { topics: ['db'], project: 'proj-other' }); + const db = createDb(dbFile()); + const none = await suggestRelated(db, cfgWith(), { projectId: 'proj-r3-empty', anchorId: 'r3anc0000000001' }); + expect(none.items).toHaveLength(0); + // 其他项目的同话题会话不可见(跨项目隔离) + const proj2 = await suggestRelated(db, cfgWith(), { projectId: 'proj-other', anchorId: 'other000000000001' }); + expect(proj2.items.every((i) => i.sessionId !== 'r3anc0000000001')).toBe(true); + db.close(); + }); + + it('R4 limit 硬顶 10(design §4 滥用防护)', async () => { + seed('anc0000000000001', { topics: ['db'] }); + for (let i = 0; i < 14; i++) seed(`bulk${String(i).padStart(10, '0')}00`, { topics: ['db'] }); + const db = createDb(dbFile()); + const { items } = await suggestRelated(db, cfgWith(), { projectId: PID, anchorId: 'anc0000000000001', limit: 99 }); + db.close(); + expect(items.length).toBeLessThanOrEqual(10); + }); +}); + +describe('related · 向量轨(注入向量 / FakeEmbedder)', () => { + it('R5 余弦定序 + 阈值裁剪(FakeEmbedder + 注入向量,model=fake-ci)', async () => { + process.env.SRELAY_SEMANTIC_FAKE = '1'; + resetSemanticCaches(); + try { + seed('anc0000000000001', { title: '锚' }); + seed('near000000000001', { title: '近邻' }); + seed('far00000000000001', { title: '远邻' }); + const AX = l2(new Float32Array([1, 0, 0, 0])); + const w = createDb(dbFile()); + upsertSessionVector(w, 'anc0000000000001', 'fake-ci', AX); + upsertSessionVector(w, 'near000000000001', 'fake-ci', l2(new Float32Array([0.95, 0.1, 0, 0]))); // cos≈0.995 + upsertSessionVector(w, 'far00000000000001', 'fake-ci', l2(new Float32Array([0, 0, 1, 0]))); // cos=0 + w.close(); + const db = createDb(dbFile()); + const { items } = await suggestRelated(db, cfgWith({ enabled: true, model: 'fake-ci', threshold: 0.4 }), { projectId: PID, anchorId: 'anc0000000000001' }); + db.close(); + // 向量轨命中 near,far 被阈值裁掉;near 无重叠元数据 → 纯向量信号 + expect(items[0].sessionId).toBe('near000000000001'); + expect(items[0].viaVector).toBe(true); + expect(items[0].reason).toContain('向量相似'); + expect(items.some((i) => i.sessionId === 'far00000000000001')).toBe(false); + } finally { delete process.env.SRELAY_SEMANTIC_FAKE; resetSemanticCaches(); } + }); + + it('R6 anchor 无向量(active)→ 即时嵌入(只算不入库)', async () => { + process.env.SRELAY_SEMANTIC_FAKE = '1'; + resetSemanticCaches(); + try { + seed('ancActive0000001', { title: '进行中的数据库讨论', state: 'active', firstUser: '连接池问题' }); + seed('dbConf000000001', { title: '已确认的历史会话', state: 'confirmed' }); + // dbConf 的向量 = FakeEmbedder 嵌 anchor 的同款输入文本 → cos=1 确定命中 + const { FakeEmbedder, semanticInputOf } = await import('../../src/search-svc/semantic.js'); + const e = new FakeEmbedder(); + const anchorInput = semanticInputOf('进行中的数据库讨论', '连接池问题'); + const w = createDb(dbFile()); upsertSessionVector(w, 'dbConf000000001', 'fake-ci', await e.embed(anchorInput)); w.close(); + const db = createDb(dbFile()); + const { items, viaVectorAny } = await suggestRelated(db, cfgWith({ enabled: true, model: 'fake-ci' }), { projectId: PID, anchorId: 'ancActive0000001' }); + db.close(); + expect(viaVectorAny).toBe(true); // active 锚即时嵌入了 + expect(items.some((i) => i.sessionId === 'dbConf000000001' && i.viaVector)).toBe(true); + // 即时嵌入不入库 + const w2 = createDb(dbFile()); + expect((w2.prepare('SELECT COUNT(*) n FROM session_vectors WHERE session_id = ?').get('ancActive0000001') as { n: number }).n).toBe(0); + w2.close(); + } finally { delete process.env.SRELAY_SEMANTIC_FAKE; resetSemanticCaches(); } + }); + + it('R7 融合:向量优先 + 重叠补足剩余名额去重', async () => { + process.env.SRELAY_SEMANTIC_FAKE = '1'; + resetSemanticCaches(); + try { + seed('anc0000000000001', { title: '数据库连接池', topics: ['db'] }); + seed('vec000000000001', { title: '完全无关标题(靠向量命中)' }); + seed('ovl000000000001', { title: '数据库备份', topics: ['db'] }); // 重叠轨命中(共享话题 db) + const { FakeEmbedder } = await import('../../src/search-svc/semantic.js'); + const e = new FakeEmbedder(); + // vec 的向量与锚标题"数据库连接池"3-gram 重叠?造一个确定命中的:用锚文本自身嵌入 + const w = createDb(dbFile()); + upsertSessionVector(w, 'vec000000000001', 'fake-ci', await e.embed('数据库连接池')); + w.close(); + const db = createDb(dbFile()); + const { items } = await suggestRelated(db, cfgWith({ enabled: true, model: 'fake-ci' }), { projectId: PID, anchorId: 'anc0000000000001', limit: 5 }); + db.close(); + const vec = items.find((i) => i.sessionId === 'vec000000000001'); + const ovl = items.find((i) => i.sessionId === 'ovl000000000001'); + expect(vec?.viaVector).toBe(true); + expect(ovl?.viaVector).toBe(false); // 重叠补足 + if (vec && ovl) expect(items.indexOf(vec)).toBeLessThan(items.indexOf(ovl)); // 向量在前 + } finally { delete process.env.SRELAY_SEMANTIC_FAKE; resetSemanticCaches(); } + }); +}); diff --git a/test/semantic/semantic.spec.ts b/test/semantic/semantic.spec.ts index d06a1ec..a942847 100644 --- a/test/semantic/semantic.spec.ts +++ b/test/semantic/semantic.spec.ts @@ -230,6 +230,25 @@ describe('semantic · 兼容与降级', () => { db.close(); }); + it('C6 毒丸不占名额:失败目标排最前时,本周期其余目标仍全部嵌上(自审修复回归)', async () => { + const db = createDb(); + // poison 最近事件排最前;修复前 SQL limit=2 会取出 poison+fresh1,过滤后仅 1 条产出 + insertSession(db, { id: 'poison0000000001', source: 'zcode', sourceSessionId: 'p1', projectId: PID, createdAt: '2026-08-26T08:00:00Z', title: '毒丸', state: 'confirmed' }); + db.prepare('UPDATE sessions SET last_event_at = ? WHERE id = ?').run('2026-08-26T12:00:00Z', 'poison0000000001'); + insertSession(db, { id: 'fresh10000000001', source: 'zcode', sourceSessionId: 'f1', projectId: PID, createdAt: '2026-08-25T08:00:00Z', title: '新会话一', state: 'confirmed' }); + insertSession(db, { id: 'fresh20000000001', source: 'zcode', sourceSessionId: 'f2', projectId: PID, createdAt: '2026-08-25T08:00:00Z', title: '新会话二', state: 'confirmed' }); + process.env.SRELAY_SEMANTIC_FAKE = '1'; + const cfg = cfgWith({ enabled: true, model: 'fake-ci' }); + resetSemanticCaches(); + const { markSemanticFailed } = await import('../../src/search-svc/semantic.js'); + markSemanticFailed('poison0000000001'); + try { + const { embedded } = await digestSemantic(db, cfg, { projectId: PID, limit: 2 }); + expect(embedded).toBe(2); // 修复前是 1(poison 吃掉一个名额) + } finally { delete process.env.SRELAY_SEMANTIC_FAKE; resetSemanticCaches(); } + db.close(); + }); + it('C5 CLI 文案钉子:semantic 命令注册与 README 联动', () => { const bin = fs.readFileSync(path.resolve('src/bin/srelay.ts'), 'utf8'); expect(bin).toContain("command('semantic')"); diff --git a/test/smoke/dist.spec.ts b/test/smoke/dist.spec.ts index 8e994f9..cc57721 100644 --- a/test/smoke/dist.spec.ts +++ b/test/smoke/dist.spec.ts @@ -67,6 +67,6 @@ const run = (args: string[], cwd: string, env: Record = {}) => await client.connect(transport); expect(client.getServerVersion()).toEqual({ name: 'sessionrelay', version: pkg.version }); const tools = await client.listTools(); - expect(tools.tools).toHaveLength(15); + expect(tools.tools).toHaveLength(16); }, 30000); });