diff --git a/CHANGELOG.md b/CHANGELOG.md index 803148f..9321ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ 所有显著变更将记录在此文件中。 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本遵循 [SemVer](https://semver.org/lang/zh-CN/)。 +## [0.2.5] - 2026-09-03 + +### 新增 +- **遗忘权 `srelay forget`**:删除权交还给人——整条会话(含决策)从本库彻底消失,AI/MCP 保持零删除能力(工具恒 15 个) + - 两阶段确认:无 `--yes` 仅预览(年龄/移除/保留/双向链接对方/imported 警示),`--yes` 单事务执行 + - **双防复活闸**:`.sessionrelayignore` 新增 `session:/` 精确规则(主防线,跨 rebuild 存活)+ 墓碑表(次级防线)——原始文件还在磁盘,但本库永不重新收录 + - 前缀歧义防护:多命中时列出候选表格拒绝执行,绝不静默猜一个 + - 乐观锁:预览到执行之间数据变化(如守护新捕消息)→ 重统计 diff 拒绝执行 + - `--all` 整库重置:守护运行中拒绝 + `--confirm ` 逐字确认 + `forgot-at-.txt` 库外摘要 + - `--history` / `--history --verbose`:遗忘审计永久可查 +- schema v3:`forget_tombstones` / `forget_log` / `forget_detail`(旧库打开自动迁移,降级打开明确报错) + +### 变更 +- `save_note` 返回话术补充"可由用户以 srelay forget 移除";`archive --hard` 帮助引导 forget(防复活缺口明示) +- README 新增「遗忘权」小节(含 archive 与 forget 选型口诀) +- `srelay save` 命中遗忘闸时给出非静默提示(曾被 forget 的会话不会被静默吞掉) + +### 测试 +- 新增 `test/forget/` 43 用例(按三轮迭代的测试用例集 v3):功能/检索不命中/误用防护/并发乐观锁/整库重置,以及**防复活对抗**——用真实源文件(JSONL 字节游标 + SQLite rowid 游标双源型)验证删后增量 sync、rebuild、手动 save 三条路径均不复活 + ## [0.2.4] - 2026-09-01 ### 测试 diff --git a/README.md b/README.md index f4f5eed..5d79a3e 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,20 @@ stateDiagram-v2 - **attach**:开新会话前挂载指定历史会话(最高优先级谓词) - **热更新**:scope 改动下一次调用立即生效 +### 🧹 遗忘权(`srelay forget`) +AI(MCP 工具)永远没有删除能力——**删除是人的特权**。需要时由你亲手执行: + +```bash +srelay forget a3f8c2d1 # 预览:将删除什么、保留什么 +srelay forget a3f8c2d1 --yes # 执行:整条会话彻底消失(含决策),不可逆 +srelay forget --history # 审计:什么时候删了什么 +``` + +- **选型口诀**:空间与老化用 `archive`(保留决策骨架);让一条对话彻底消失、永不回来,用 `forget` +- **双防复活闸**:`.sessionrelayignore` 精确规则(主,跨 rebuild 存活)+ 墓碑表(次,防 ignore 被清理)——原始文件还在磁盘,但本库永不重新收录 +- **前缀歧义防护**:前缀命中多个会话时列出候选拒绝执行,绝不静默猜一个 +- **注意**:forget 是项目级操作;已通过 `.hop` 交接包交出去的记忆不在管辖内(删除权已随包交出) + ### 📦 HOP 交接协议(`hop/1.0`,开放格式) - [独立协议规格](spec/hop-1.0.md)(MIT,产品中立,欢迎第三方实现读取器) - sha256 逐文件完整性校验(篡改整体拒绝) diff --git a/docs/design-forget.md b/docs/design-forget.md new file mode 100644 index 0000000..98e53e3 --- /dev/null +++ b/docs/design-forget.md @@ -0,0 +1,279 @@ +# `srelay forget` 设计方案 · v4(三角色评审折入版,实现依据) + +> 目标:把"删除权"交还给人(CLI),AI 保持零删除权。可追加的记忆有了受控出口。 +> 演进:v1→v3 三轮自审查(13 项修订)→ 三角色评审(PM/架构师/设计师,7 项发现)→ v4。 +> 评审记录见 §8/§9。 + +--- + +## 1. 问题定义 + +当前 15 个 MCP 工具 + CLI 全部只能追加或修改,无法删除: + +| 困境 | 现状 | +|---|---| +| AI 写错笔记(save_note 直接进决策库) | 永久污染检索与决策,无法撤回 | +| 隐私对话("删掉这条") | 只进不出,与本地优先叙事矛盾 | +| 测试/垃圾数据清场 | 只能开 SQLite 手改,破坏封装 | +| annotate 越写越肥 | 错误注解只增不减 | + +**边界(不可协商)**:AI(MCP 工具)永远不获得删除能力。删除是人的特权。 + +## 2. 能力矩阵 + +| 能力 | 载体 | 说明 | +|---|---|---| +| 删自建内容(note/注解/链接) | CLI `srelay forget` | AI 写错的,人清 | +| 删单条会话(含 auto 捕获) | CLI,带预览确认 | 隐私删除 | +| 删整个项目记忆库 | CLI,双重确认 | 测试/清场 | +| MCP 删除工具 | **不做** | AI 永不删 | +| 删除原始会话文件 | **不做** | 原始文件是唯一事实源 | + +### 2.1 `forget` 与 `archive --hard` 的裁决(评审 P0,用户已待拍板) + +产品里已存在两个删除语义,用户不会读文档,必须给出唯一裁决表: + +| | `archive`(默认) | `archive --hard` | **`forget`** | +|---|---|---|---| +| 用户意图 | **降级**(省空间、归档老化) | 批量清理 | **抹除**(隐私/纠错/精确单点) | +| 删什么 | 正文 + FTS(决策/话题/摘要/标题保留) | 同左且全量 | **整个会话消失**(含决策列) | +| 防复活闸 | 不需要(会话行还在) | **无**(源文件残留可被 sync 重捕——现状缺口,文档标注) | **双闸**(ignore `session:` + 墓碑) | +| 可逆 | rebuild --force 可恢复 | 否(除非源文件在) | 否(防复活闸生效时源文件也不再生效) | +| 粒度 | 按老化策略批量 | 按老化策略批量 | 单会话/note/全库 | +| 审计 | cleanup_log | cleanup_log | forget_log + detail | + +**选型口诀(进 README 与 `--help`)**:空间与老化用 `archive`;让一条对话彻底消失、永不回来,用 `forget`。 +`archive --hard` 的防复活缺口不在本轮修复(它面向回填窗口外的老会话,mtime 已过 cutoff), +但在其 `--help` 中加一行提示:"若需防止源文件再次被收录,用 srelay forget"。 + +## 3. 架构设计 + +### 3.1 删除语义分层 + +| 层 | 对象 | 手段 | +|---|---|---| +| L2 记录删除 | sessions 行 | SQL DELETE(已验证:FK CASCADE 连带 messages/session_links/transfer_log;messages 与 sessions 的 FTS 外部内容触发器在 CASCADE 下正常同步——已用 better-sqlite3 实验证明) | +| L3 投影失效 | decisions/topics/files_mentioned 均为 sessions 列 | 随行消失 | + +L1(部分消息删除)明确不做——破坏"原文完整"承诺,只做会话/笔记粒度。 + +### 3.2 防复活(核心难题) + +被删会话的原始文件仍在磁盘,守护 30s 扫描 + rebuild 全量重扫,有两条复活路径。 +断路器设计(v2 修订,v3 砍掉 --no-ignore): + +1. **精确 ignore 规则(新前缀 `session:`)**:`.sessionrelayignore` 追加 + `session:zcode/a3f8c2d1`。ignore 匹配器(`src/capture/ignore.ts`)新增分支: + `source + source_session_id` 精确匹配(`DiscoveredSession` 在两处 ingest 调用点都携带 + 此二者,见 §3.3)。**取代 v1 的"追加 glob"**——v1 的方案要么太粗(source: 全源屏蔽) + 要么易误伤(title: 关键词),且文本模糊匹配不可审计。 + **此规则强制追加,不可选退**——它是防复活主防线。 +2. **墓碑表 `forget_tombstones`**:`source + source_session_id` 唯一键。 + 次级防线:ignore 规则被用户手删后的兜底。 + **载入形态(评审 A2,定为规范)**:sync 两处入口启动时各调一次 + `loadTombstones(db): Set`(key = `source:sid`,整表一次载入), + 与 `loadIgnoreRules` 同构;**禁止 per-discovered-session 的点查**——墓碑表常态 + <100 行,Set 常驻零成本,防止未来有人顺手写成 per-message 查询。 +3. ~~游标推进~~:v2 起不做(cursor 结构 per-adapter,CLI 无法构造"文件尾";只挡增量 + 不挡 rebuild)。游标自然推进无害。 + +### 3.3 复活路径全分析(v2 新增,v1 缺失) + +| 路径 | 触发条件 | 挡板 | +|---|---|---| +| 增量 sync(守护 30s / `srelay sync`) | 原始文件新字节 | ignore `session:` + 墓碑 | +| **rebuild 全量重建**(建新库,墓碑随旧库进 .bak!) | 用户主动 rebuild | **ignore `session:` 规则存活于根目录文件**,`runSync` 两处入口(sync.ts:75 增量、sync.ts:117 手动注入)+ rebuild 复用的同一条 `loadIgnoreRules` 链路都会命中;**墓碑在 rebuild 后不存在**(新库无此表数据),所以墓碑只是次级防线 | +| runSync sessions 注入入口(sync.ts:112 manual origin) | save/导入路径 | 同上 ignore 检查已在 112-121 覆盖 | + +结论:**ignore `session:` 规则是第一防线(跨 rebuild 存活),墓碑是第二防线(防 ignore 文件被用户清理)**。与 v1 相反(v1 把墓碑当主防线)。 + +### 3.4 审计:forget_log(删除本身被记住) + +```sql +CREATE TABLE forget_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + triggered_by TEXT NOT NULL, -- 'cli:forget' + mode TEXT NOT NULL, -- note | session | all + criteria TEXT NOT NULL, + sessions_affected INTEGER NOT NULL, + messages_affected INTEGER NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE forget_detail ( + forget_log_id INTEGER NOT NULL REFERENCES forget_log(id) ON DELETE CASCADE, + session_id TEXT NOT NULL, + title TEXT, source TEXT, message_count INTEGER, created_at TEXT +); +``` + +"删了什么"永久可查(`srelay forget --history`),检索不索引它。先例:cleanup_log/cleanup_detail。 + +**规范决策(评审 A1)**:`forget_detail.session_id` 为**裸 TEXT、故意无外键**—— +被删行的 id 必须留在审计里,而审计链自身永不删除。与 `cleanup_detail.session_id` 同例。 +此为显式决策而非疏漏,禁止后续"补上 FK"(会级联破坏审计)。 + +### 3.5 CLI 交互(两阶段确认) + +```bash +srelay forget # 预览(无参不删——防误触) +srelay forget --yes # 执行 +srelay forget --session / --note # 类型过滤(id 形如 note-xxx 时自动识别) +srelay forget --all # 整库重置:① 守护运行中直接拒绝(照抄 rebuild 拦截逻辑) + # ② 要求 --confirm 输入项目 id 逐字匹配(防脚本误触) +srelay forget --history # 审计:默认紧凑表(时间/模式/影响数,评审 D3) +srelay forget --history --verbose # 展开每次影响的会话明细(对齐 archive --history --verbose 风格) +``` + +**前缀歧义防护(v3 新增)**:`findSessionByPrefix` 是 `LIKE prefix% LIMIT 1`—— +`a` 会静默命中 `a3f8...` 也可能本意是 `a9...`,查询场景无害、**删除场景致命**。 +forget 不复用它:改为先按前缀 COUNT,命中 >1 时列出全部候选并拒绝执行, +命中 =1 才进入预览。完整 id(32/16 位 hash 或 note- 前缀)直接精确匹配。 +歧义输出**表格化(评审 D2)**:`id · 标题 · 日期 · 来源 · 条数` 逐行列出 + +提示"用更多字符或完整 id 重试",不用一句话 error 打发。 + +预览(对齐 archive 的视觉语言,评审 D1): + +``` +📊 遗忘预览(不可逆) +────────────────────────────────────────── +会话 a3f8c2d1 「数据库选型(PG vs MongoDB)」 + 来源 zcode · 2026-08-20(13 天前)· 12 条消息 · 1 决策 · imported 否 + 移除:对话正文 · 决策 · 话题 · 双向链接(对方 2 条:f1e2d3c4、b2a3c4d5) + 保留:原始会话文件(磁盘上不受影响,但本库不再收录) + 写入防复活闸:ignore session: 规则 + 墓碑 + 审计:本次操作将记入 forget_log + 确认执行加 --yes +``` + +(会话年龄必须显示——用户对 id 无感、对"是不是那条三个月前的"有感。) + +### 3.6 执行流程(单事务) + +``` +1. 解析 id:完整 id 精确匹配;前缀先 COUNT,>1 拒绝并列出候选(见 §3.5) +2. 预览统计(含双向链接对方列表 + imported 标记) +3. --yes 后单事务: + a. sessions DELETE → CASCADE(FTS 双表触发器自动同步,已实验验证) + b. forget_tombstones INSERT(若有原始文件;note/imported 跳过) + c. .sessionrelayignore 追加 session: 规则(默认行为;--no-ignore 砍掉,见 §3.7) + d. forget_log + forget_detail +4. 非 note 会话:warn 用户原始文件仍在磁盘,产品库不再收录 +``` + +**并发安全(v3 新增)**:守护进程 30s 一次 sync 与 forget 并发写同一 WAL 库。 +单事务 + `busy_timeout=5000` 已保证 SQL 层不撕裂;但"预览→--yes"两阶段之间 +守护可能新捕消息,**执行时重新统计并 diff 预览数字**,不一致则提示重跑预览(乐观锁语义)。 + +### 3.7 --all 整库重置(v3 强化) + +照抄 rebuild 的守护拦截(`isDaemonAlive` → 运行中直接拒绝),确认方式为 +`--confirm `(逐字匹配项目 id,不 --yes)。执行 = 关库文件三连删 +(sqlite/-wal/-shm)+ 重建空库 + 保留 ignore 文件(含全部 session: 规则—— +这正是防复活关键:库没了规则还在)。forget_log 随库删除,仅保留 +`.sessionrelay/forgot-at-.txt` 摘要(何时删了整个库,最后一行审计)。 + +### 3.8 跨项目语义(v3 新增) + +被删会话若已被 `.hop` 导出到其他项目,**对端不受影响也不通知**(各自项目独立库, +本产品无中心服务器)。forget `--history` 的 detail 行记录 origin,方便用户自查扩散。 +文档明示:**forget 是项目级操作,不是全局 GDPR 删除按钮**;导出包一旦交出, +删除权已不在本工具管辖内(与"导出脱敏"防线分工)。 + +### 3.9 审计日志增长(v3 新增) + +forget_log 无限增长问题:单条审计 <200B,个人项目删除频率极低(周级别个位数), +10 年量级 <1MB——**不做自动清理**;`--history --json` 供外部工具归档。 + +## 4. 兼容性与文档联动 + +- schema +2 表 +1 列类规则前缀;`user_version` 2→3,createDb 自动建表(M 迁移模式照旧) +- `.hop` 导出不含墓碑与审计;导入方不继承删除(交接知识不交接删除史) +- rebuild:ignore 规则存于根目录文件,跨 rebuild 存活(见 §3.3) +- MCP 契约测试加断言:工具清单恒 15 个 +- **话术联动(评审 P2)**:`save_note` 返回文案改为 + "笔记已可被 search / get_decisions / export 检索;可由用户以 srelay forget 移除"—— + 防 AI 基于旧话术向用户担保"永久可查" +- **AI 标准答复(评审 P3)**:search_sessions 等 15 工具的 description 不动, + 但用户文档写明:当用户要求 AI 删记忆时,AI 的标准答复是 + "我没有删除能力;请运行 srelay forget <会话id>,或告诉我 id 我帮你查出来" +- **文档入口(评审 P3)**:README 在"Scope 检索边界"之后新增 + "### 🧹 遗忘权(srelay forget)"小节(含 §2.1 选型口诀); + CLI 注册在数据命令组(save/rebuild 附近),description 一句话点明与 archive 的区别 + +## 5. 测试计划 + +1. 单元:ignore `session:` 匹配、墓碑 skip、审计行数、前缀歧义拒绝 +2. 集成:删后 search/decisions/detail 全链路不命中;FTS integrity-check 通过 +3. **复活对抗**(每个挡板单独测):删→runSync 两入口→不复活;删→rebuild→不复活; + 删→清空 ignore 文件→sync→墓碑挡住→不复活 +4. 契约:MCP 工具清单恒 15 +5. dist 冒烟 + pack-e2e 补 forget 路径 +6. **并发(v3)**:预览后人为插入新消息再 --yes → 断言 diff 警告且不执行 + +实现期已知盲区(设计评审判定就地裁决,不另开评审):删除后 `source_files` 游标行残留, +`get_file_history` 可能返回零会话文件——实现时在查询处过滤已无会话的文件行(以 sessions 存在为准),并补单测。 + +## 6. 不做什么 + +- MCP 删除工具(永久) +- 部分消息删除(破坏原文完整承诺) +- 跨项目删除(scope 边界) +- 删原始文件(事实源不可碰) +- 游标推进(v2 降级为不做,理由见 §3.2) + +## 7. 开放问题(交用户裁决) + +1. ~~--all 确认形态~~ → v3 已定:`--confirm ` 逐字匹配(输入项目名) +2. ~~--no-ignore 价值~~ → v3 已定:砍掉(ignore 追加是防复活主防线,不可选退) +3. forget 后 scope_log 历史引用是否标注"已删" → 维持不标(审计表已可查,标注会让 scope_log 语义复杂化) + +## 8. 审查记录 + +### 第一轮(架构与复活路径)——6 项修订 +| # | v1 缺陷 | 证据 | v2 修订 | +|---|---|---|---| +| 1 | "ignore 追加会话标识"无对应机制——现有匹配器只有 source:/title:/glob 三种 | ignore.ts 全文 | 新增 `session:` 精确前缀规则 | +| 2 | 墓碑被当主防线,但 rebuild 建新库、墓碑随 .bak 消失 | rebuild.ts:29 `createDb(tmp)` | 防线对调:ignore 为主(跨 rebuild 存活),墓碑为次 | +| 3 | "游标推到文件尾"不可实现——cursor 结构 per-adapter | recordCursor/T34 注释 | 首版不做游标推进 | +| 4 | 预览漏双向链接影响 | session_links 双向 PK | 预览必含对方列表 | +| 5 | "sync 入口查墓碑"未定位——实际两入口 | sync.ts:75 / 117 | 明确两处调用点 | +| 6 | 复活路径分析缺失 | rebuild.ts 全文 | §3.3 三路径表 | + +### 第二轮(对抗性与误用)——4 项修订 +| # | v2 缺陷 | 证据 | v3 修订 | +|---|---|---|---| +| 7 | 前缀解析复用 findSessionByPrefix——LIKE % LIMIT 1 多命中静默取一,删除场景致命 | db.ts findSessionByPrefix | 忘记复用;先 COUNT,>1 拒绝并列表 | +| 8 | --all 时间窗确认可被脚本 sleep 后二次调用绕过 | — | 改为 --confirm 逐字匹配 | +| 9 | forget 与守护 sync 并发:预览到执行之间守护可能新捕消息 | lock.ts/busy_timeout | 执行时重统计 diff,不一致则拒绝(乐观锁) | +| 10 | 跨项目扩散未定义:被删会话可能已随 .hop 导出 | import.ts origin | §3.8 明示项目级边界 + 文档声明 | + +### 第三轮(收尾扫描)——1 项修订 +| # | v2 缺陷 | 证据 | v3 修订 | +|---|---|---|---| +| 11 | forget_log 增长无交代 | maint.ts 无日志清理先例 | §3.9:体量估算 <1MB/10年,不清理 | +| 12 | --all 删除 forget_log 自身 → 审计链断 | — | 库外摘要文件 forgot-at-.txt | +| 13 | imported 会话删了无法 rebuild 回来,预览未警示 | rebuild.ts 仅搬迁 imported | 预览必含 imported 标记 | + +### 第四轮(三角色评审:产品经理 / 架构师 / 设计师)——7 项折入 v4 +| # | 级别 | 发现 | v4 修订 | +|---|---|---|---| +| 14 | P0 | 与 archive --hard 语义重叠,用户无从选择;且 --hard 无防复活闸 | §2.1 裁决表 + 选型口诀;archive --hard --help 加 forget 提示 | +| 15 | P1 | forget_detail.session_id 裸 TEXT 无 FK 会被当 bug 补 FK,级联破坏审计 | §3.4 显式规范决策 | +| 16 | P1 | 墓碑查询形态未定,留有 per-message 查询口子 | §3.2 定为 loadTombstones 整表 Set,禁止点查 | +| 17 | P2 | save_note 承诺"可检索"在 forget 时代成为假话 | §4 话术联动 | +| 18 | P2 | 可发现性未定义(CLI 位置/README/AI 标准答复) | §4 文档入口三处 | +| 19 | P2 | 预览缺年龄与保留/移除两栏(对齐 archive 视觉语言) | §3.5 预览模板 | +| 20 | P3 | 歧义报错体验与 --history 输出密度未设计 | §3.5 表格化候选;--history 默认紧凑 | + +--- + +## §10 实现落地备注(0.2.5 开发期追加;正文 v4 保持原样,以下为代码事实) + +> 原则:出入以代码事实为准,逐条回写。实现 = src/cli/forget.ts + store/db.ts(schema v3) + capture/ignore.ts(session: 前缀) + capture/sync.ts(复活路径);测试 = test/forget/ 四文件 43 例。 + +1. **§5 盲区关闭**:get_file_history / `srelay history` 均走 searchSessions(FTS JOIN sessions),不读 source_files 表——"零会话文件"路径不存在,未做任何过滤修改(测试 B9 把构造安全钉成回归基线)。 +2. **§3.6 乐观锁载体(补充设计)**:预览→--yes 是两次进程调用,diff 载体定为 `.sessionrelay/forget-pending.json` 快照(id/消息数/决策数/链接数)。--yes 时快照 id 匹配且数字变化 → 拒绝并提示重跑预览;无快照(用户直接 --yes)→ 直接执行,--yes 即确认。 +3. **§3.5 类型过滤语义**:`--session/--note` 实现为类型断言过滤器(类型不符直接拒绝,防误删);id 解析兜底支持 source_session_id 完整匹配(note-xxx / 源会话 ID 形态),前缀歧义防护(COUNT>1 拒绝并列候选表)仅作用于内部 id 前缀。 +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。 diff --git a/docs/test-forget.md b/docs/test-forget.md new file mode 100644 index 0000000..fdb7136 --- /dev/null +++ b/docs/test-forget.md @@ -0,0 +1,178 @@ +# `srelay forget` 测试用例集 · v3(三轮迭代定稿,实现完成后按此执行) + +> 依据:design-forget v4。断言只钉可观察行为(CLI 退出码/输出/DB 状态/文件系统),不钉内部实现。 +> 执行标记:【FTS-EXP】= 需在集成环境复验 FTS 同步;【EXP-VERIFIED】= 语法/行为已在 better-sqlite3 实测。 +> 自动化分层:A/B/C/D/F/G → vitest(test/forget/);E → vitest 双连接并发用例;H → 性能冒烟可选。 + +## A. 功能正路径(冒烟级) + +| ID | 前置 | 步骤 | 断言 | +|---|---|---|---| +| A1 | 已 init 项目,1 条 auto 会话 S1(12 msg + 1 决策 + 2 链接)+ 1 条 note N1 | `srelay forget S1前缀`(无 --yes) | 退出码 0;输出预览(标题/来源/条数/决策数/双向链接对方列表/会话年龄/imported 标记);**DB 无任何删除发生**(sessions 计数不变) | +| A2 | 同 A1 | `srelay forget S1前缀 --yes` | 退出码 0;sessions/messages/session_links 各少对应行;messages_fts 与 sessions_fts integrity-check 通过【FTS-EXP】 | +| A3 | 同 A1 | `srelay forget N1 --yes`(note) | note 行删除;**forget_tombstones 无行**(note 的 source_file='mcp:save_note' 非真实文件路径——判定条件必须是"真实文件存在且可被 adapter discover",不是字符串非空,v2 修订断言依据);ignore 文件**无** session: 规则追加 | +| A4 | 同 A2 | `srelay forget --history` | 退出码 0;输出含 1 条 mode=session 记录,sessions_affected=1,messages_affected=12 | +| A5 | 同 A3 | `srelay forget --history --json` | 合法 JSON,字段与 §3.4 schema 一致 | + +## B. 删除后全链路检索不命中(核心验收) + +| ID | 前置 | 步骤 | 断言 | +|---|---|---|---| +| B1 | 删除 S1(A2 完成) | `srelay search ` | 命中数 0(messages_fts + sessions_fts 双路) | +| B2 | 同 B1 | MCP `search_sessions` 同关键词 | count=0,无残留 provenance 指向 S1 | +| B2b | 同 B1 | FTS `integrity-check` 命令(【EXP-VERIFIED】语法可用) | 执行不抛错 = FTS 索引与内容表无幽灵行(v2 新增:防 search 双路都查不到但索引已烂的静默损坏) | +| B3 | 同 B1 | MCP `get_session_detail S1完整id` | 返回 not-found 语义(非 crash、非空对象) | +| B4 | 同 B1 | MCP `get_decisions` | 不含 S1 的决策文本 | +| B5 | 同 B1 | MCP `get_linked_sessions <对方id>` | A→B、B→A 双向都不再返回 S1 | +| B6 | 同 B1 | MCP `get_stats` / `srelay status --json` | 会话/消息计数与删除一致,无负数无悬挂;**不断言 dbSizeMB**(SQLite 删除不回缩文件,需 VACUUM,断言体积会误报——v2 修订) | +| B7 | 同 B1 | `srelay show S1完整id` | not-found 语义 | +| B8 | 删 note N1 | `srelay search ` | 0 命中(含 tags 检索路径) | +| B9 | S1 是唯一提及文件 X 的会话,删 S1 | MCP `get_file_history X` | count=0——**v2 翻转**:该工具走 searchSessions JOIN sessions(server.ts:210-214),构造上天然过滤已删会话,此用例把"构造安全"钉成回归基线;同时证伪 v4 设计文档里"可能返回零会话文件"的担忧(那条盲区记录据此关闭) | + +## C. 防复活对抗(本特性生死线,每挡板单测) + +| ID | 前置 | 步骤 | 断言 | +|---|---|---|---| +| C1 | 删 auto 会话 S1(ignore 规则已自动追加) | 手动触发增量 sync(模拟文件 mtime 变化) | S1 不复活;result.blocked 计数 +1(blocked_by_ignore);墓碑表无新增命中需要(被 ignore 短路) | +| C2 | 删 S1;**手动清空 .sessionrelayignore** | 触发 sync | S1 不复活(墓碑挡住);blocked 计数 +1 | +| C3 | 删 S1 | `srelay rebuild --force` | 重建后 S1 不存在;重建库 ignore 文件原样保留;墓碑表为空(新库)但 **C4 兜底成立** | +| C4 | 同 C3 完成的新库;清空 ignore | 触发 sync | S1 **复活**(两道闸都拆了——**这是预期行为**:用户明确拆掉双闸=撤回遗忘,须在文档标注);如判定为不应复活则实现必须加第三闸,回到设计 | +| C5 | 删 auto 会话 S1 | 向 S1 源**按其游标类型**追加新内容:JSONL 源(claude-code/codex/qoder)追加合法消息行;SQLite 源(zcode)向其源库 INSERT 新消息行(rowid 水位后移)→ sync | 新内容不入库(session: 规则按 source+sid 拦整个会话);**两种源型都要测**——游标语义不同(字节偏移 vs rowid),v2 修订:v1 只写了"追加字节"对 zcode 源不可执行 | +| C6 | 删 S1 后 captureSessions 注入入口(构造 S1 的 DiscoveredSession 直传入参) | 调用 captureSessions | 不复活(manual 入口同样命中 ignore+墓碑) | +| C7 | 删 S1 后用户 `srelay save`(manual 主动重存同一会话) | save → captureSessions | **被 ignore 拦截,重存失败**——这是预期行为(遗忘是明确决定,人工重存也应先清 ignore 规则),输出须提示"被 ignore 规则拦截(session:)"而非静默 0(v3 新增:场景串联时发现的双向语义,须实现确认提示文案) | + +## D. 确认与误用防护 + +| ID | 前置 | 步骤 | 断言 | +|---|---|---|---| +| D1 | ≥2 个会话 id 共享前缀(如 a3f8…/a3f9…) | `srelay forget a3f --yes` | 退出码非 0;**两个都不删**;输出表格化候选列表(id/标题/日期/来源/条数) | +| D2 | 前缀唯一命中 | `srelay forget <唯一前缀> --yes` | 正常预览→删除流程 | +| D3 | 完整 id | `srelay forget <完整32位id> --yes` | 直接预览(无歧义检查开销) | +| D4 | 不存在的 id | `srelay forget deadbeef --yes` | 退出码非 0;not-found 语义;DB 无变化 | +| D5 | 守护运行中 | `srelay forget --all` | 直接拒绝(isDaemonAlive 拦截),库文件不变 | +| D6 | 守护已停 | `srelay forget --all`(无 --confirm) | 拒绝执行,提示需要 --confirm | +| D7 | 同 D6 | `srelay forget --all --confirm 错误的项目id` | 拒绝;逐字匹配失败 | +| D8 | 同 D6 | `srelay forget --all --confirm <正确projectId>` | 库三连删(sqlite/-wal/-shm)+ 空库重建 + ignore 文件保留 + forgot-at-.txt 生成 | +| D9 | 同 D8 | 检查 .sessionrelay/ | forget_log 随库删除,但 forgot 摘要文件存在且含时间戳 | +| D10 | 同 D8 | `srelay status`(新库) | 空库状态正常输出,无 crash | +| D11 | 同 D8 | 检查 stats.json | 行为二选一且与实现一致(v2 提交设计裁决):随 --all 清空,或保留(纯事件计数无内容泄漏,可接受);测试钉住"实现做了什么"且文档写明,不允许含糊 | +| D12 | 导出过 .hop 后 forget 其中一条会话 | 再次 export | 新包不含被删会话;manifest.session_count 与包内实际一致;旧 transfer_log 行被 CASCADE 清走不阻塞新导出(export.ts 无 NOT FOUND 崩溃路径——前置验证过 buildExportData 按现库行构建) | + +## E. 并发与一致性 + +| ID | 前置 | 步骤 | 断言 | +|---|---|---|---| +| E1 | 预览 S1 后、--yes 前,守护向 S1 追加 2 条消息 | `srelay forget S1 --yes` | 执行时重统计 diff 命中(12→14)→ **拒绝执行**,提示重跑预览;S1 仍完整 | +| E2 | 同 E1 场景但消息数未变 | `--yes` | 正常执行 | +| E3 | 预览后 S1 被另一个 forget 先删了 | `--yes` | not-found 语义,无部分删除残留 | +| E4 | forget 执行中(事务内)并发 `srelay status` | 双连接同时 | status 读旧快照或等待 ≤5s(busy_timeout=5000),无脏读崩溃;**此用例 vitest 内双 better-sqlite3 连接实现,不开子进程**(v2 修订:双进程在 Windows CI 不稳定) | +| E5 | 预览后、--yes 前,**另一 CLI 进程删除了同一会话** | `--yes` | not-found 语义,无部分删除残留(与 E3 同语义不同路径:E3 用 DB 直改模拟,E5 用真实双 CLI——二选一进自动化,另一个手动执行一轮) | + +## F. 边界与特殊对象 + +| ID | 前置 | 步骤 | 断言 | +|---|---|---|---| +| F1 | imported 会话(.hop 导入) | `srelay forget ` | 预览含 imported 警示(删除后无法 rebuild 恢复);删除后 forget_tombstones **无行**(无本地源文件) | +| F2 | 7 字符短前缀(sessionIdOf 截断长度) | 查询 | 正常解析(不因 id 长度假设出错) | +| F3 | note id 前缀(note-xxxx) | `srelay forget note-xxxx --yes` | 识别为 note 类型,走 A3 断言 | +| F4 | state=active 的 auto 会话 | 删除 | 允许(forget 不受两阶段状态机约束——删除权在人不参与状态协商);deletePending 式限制**不适用** | +| F5 | 同一会话 id 删除两次 | 第二次 `--yes` | 第二次 not-found,不产生第二条 forget_log | +| F6 | 删除后被 CASCADE 的 transfer_log 行 | 查 transfer_log | 关联行消失(FK CASCADE),export 历史完整性文档已声明 | + +## G. 兼容性/回归 + +| ID | 步骤 | 断言 | +|---|---|---| +| G1 | v0.2.4 老库升级打开 | user_version 2→3 迁移自动完成;老功能回归(search/show/export 正常) | +| G1b | **降级**:新库(v3)被老版本 srelay 打开 | 明确报错"数据库由更新版本的 srelay 创建…请升级"(db.ts:156-159 现行行为),无半迁移状态(v2 新增——迁移用例只测升级不测降级是常规遗漏) | +| G2 | MCP 客户端 tools/list | **恒 15 工具**——新增任何删除类工具即 fail(防 AI 删除能力泄漏) | +| G3 | `srelay save`(manual origin 注入) | 行为不变(save 路径的 ignore 检查已有,session: 规则同样生效) | +| G4 | pack-e2e 加 forget 步骤 | 真实安装路径:init→save→forget→握手→15 工具 | +| G5 | `--help` 输出 | forget 描述含与 archive 的区分口诀;`--all` 提示 --confirm 要求 | +| G6 | save_note 返回文案 | 含"可由用户以 srelay forget 移除"(评审 P2 话术联动) | +| G7 | archive --hard --help | 含"若需防止源文件再次被收录,用 srelay forget"引导 | + +## H. 浅层非功能 + +| ID | 步骤 | 断言 | +|---|---|---| +| H1 | 删除 1000 msg 会话 | 执行 <2s(单事务 + CASCADE)【EXP-VERIFIED】1000 行事务插入实测 3ms,删除同量级 | +| H2 | 墓碑表 5000 行时 sync | discover 循环无 per-session 点查(性能回归:Set 载入);周期耗时不劣化(开放点 4:CI 噪声大,降级为本地手动项——v3 定) | +| H3 | `srelay forget --history` 100 条审计 | 输出 <1s,紧凑表默认 | + +## I. 前置构造规格(执行者必读) + +| fixture | 规格 | 复用 | +|---|---|---| +| auto 会话(功能/检索断言) | 直插 DB:insertSession/insertMessage + 手动维护 message_count(契约测试既有手法) | test/contract/mcp.spec.ts beforeAll | +| auto 会话(复活对抗专用) | **必须造真实源文件**:JSONL 源造 .jsonl 追加行;zcode 源造 sqlite 源库 INSERT 行——直插 DB 的会话无源文件,sync 根本不会发现它,复活对抗无从谈起(v3 补:v1/v2 未区分,照抄会写出假绿用例) | 造文件工具函数进 test/helpers | +| DiscoveredSession 注入 | `{source, sourceSessionId, sourceFile, title?, createdAt?, updatedAt?, sizeBytes, mtimeMs}`(types.ts:59-68) | C6/C7 | +| 链接对 | 两条会话 + session_links 直插(PK 三列:session_id/linked_session_id/kind) | | +| imported 会话 | insertImportedSession(无真实 source_file) | rebuild.ts 搬迁先例 | +| FTS 校验 | `INSERT INTO messages_fts(messages_fts, rowid, search_text) VALUES ('integrity-check', 0, '')`【EXP-VERIFIED】 | B2b | +| 大会话 | 事务循环 insert | H1 | + +## J. 执行清单(开发完成后按此验收) + +| 步骤 | 内容 | 通过标准 | +|---|---|---| +| 1 | 自动化:`npx vitest run test/forget/`(A/B/D/F/G + C1/C2/C3/C6/C7 + E1-E4) | 0 fail | +| 2 | 手动(Windows 本机):E5 双 CLI 竞态 + H2 墓碑压测 + `npm run e2e:pack`(G4) | 0 意外 | +| 3 | 三平台 CI push(ubuntu/windows/macos 自动跑步骤 1 可自动化部分) | 全绿 | +| 4 | 验收门:步骤 1-3 全过 **且** 契约测试"恒 15 工具"断言绿 | 才可发版 | + +C4/C5 的执行取决于开放点 1 的裁决(允许复活=按预期行为断言;不允许=实现加第三闸后必测)。 + +## 待裁决的开放点(需设计/用户确认,随 v3 定稿提交) + +1. C4 双闸拆除后允许复活——按"文档标注的预期行为"编写,需一句话确认 +2. F4 active 直接删(倾向)vs 先 confirm +3. B6 status 口径、D11 stats.json 去留——倾向不加/保留,需确认 +4. ~~H2 CI 噪声~~ → v3 已定:降级为本地手动项 +5. (v3)C7 人工重存被拦截的提示文案——实现时确认措辞 + +## 迭代记录 + +### 第一轮(自查:断言可执行性)——4 项修订 +| # | v1 缺陷 | 证据 | 修订 | +|---|---|---|---| +| 1 | B6 断言 status 体积——SQLite 删除不回缩(无 VACUUM),体积不变会误判 fail | status.ts:59 输出 dbSizeMB | B6 明确不断言体积 | +| 2 | A3 判定依据含糊("无原始文件")——note 的 source_file 是字符串 'mcp:save_note' 非空 | createNoteSession | 断言依据改为"真实文件存在且可被 discover" | +| 3 | 缺 FTS 幽灵行防线用例——search 双路 0 命中但索引静默损坏查不出 | 无 integrity-check 用例 | 新增 B2b【EXP-VERIFIED】 | +| 4 | E4 双进程并发在 Windows CI 不稳定(子进程 spawn + 文件锁时序) | E4 原描述 | 改为双连接实现;补 E5 双 CLI 路径手动执行 | + +### 第二轮(覆盖矩阵扫描)——5 项修订 +| # | 缺口 | 证据 | 修订 | +|---|---|---|---| +| 5 | 迁移用例只测升级不测降级 | db.ts:156 降级拒绝路径无覆盖 | 新增 G1b | +| 6 | C5 "追加字节"对 zcode 源不可执行——rowid 水位不认字节 | zcode/index.ts:53 cur={rowid} | C5 按源型分叉:JSONL 追加行 / SQLite INSERT | +| 7 | 删后再次 export 的行为未覆盖(包一致性 + transfer_log CASCADE 不阻塞) | export.ts buildExportData 按现库构建 | 新增 D12 | +| 8 | B 系漏了 get_file_history——且 v4 设计文档"零会话文件盲区"判断与代码不符 | server.ts:210 走 searchSessions JOIN sessions | 新增 B9 钉死安全行为;关闭设计文档该盲区记录 | +| 9 | --all 后 stats.json 去留未定 | counter.ts statsFile 独立于库文件 | 新增 D11(钉实现+文档写明,不猜) | + +### 第三轮(执行者走查)——3 项修订 +| # | 缺口 | 证据 | 修订 | +|---|---|---|---| +| 10 | C 系复活对抗用"直插 DB 会话"测不了——无源文件则 sync 根本发现不了它,写出来是假绿 | contract fixture 手法 + adapter.discover 语义 | 新增 §I:复活对抗必须造真实源文件,与功能断言的直插手法分离 | +| 11 | 场景串联漏路径:删后用户 `srelay save` 主动重存同一会话——ignore 会拦住人工操作,双向语义未定义 | save→captureSessions→sync.ts:117 ignore 检查 | 新增 C7(预期拦截+必须提示,禁止静默 0) | +| 12 | 前置构造规格散落各用例(DiscoveredSession 字段、FTS 校验语法、 fixture 手法),执行者要反查代码 | types.ts:59 / 【EXP-VERIFIED】 | 集中为 §I 构造规格表 + §J 验收动线(四步门禁) | + +--- + +## 执行记录(0.2.5 开发期回填;正文 v3 用例编号保持原样) + +**自动化落地:`test/forget/` 四文件 43 例全绿 + 全仓 171 例全绿 + pack-e2e(含 forget 环节)本机通过。** + +- functional.spec.ts:A1-A5、B1/B2b/B4/B5/B6/B7/B8/B9、D1-D4、F1/F3/F4/F5/F6(并入 D12)、G1/G1b/G5/G6/G7 +- resurrection.spec.ts:C1/C2/C3/C4(按开放点 1 默认裁决断言"复活=预期")/C5a(JSONL)/C5b(SQLite)/C6/C7 + 幂等补充 +- concurrency.spec.ts:E1(12→14 拒绝)/E2(重跑预览后执行)/E3/E4(双连接 WAL) +- all-reset.spec.ts:D5-D11 + 空库二次 --all 幂等 + +与 v3 用例的出入(以代码事实为准,见设计文档 §10): + +1. **F6 修订**:transfer_log 无 FK(审计表,session_ids 为 JSON 文本)——"关联行消失"与 schema 不符;实际行为=行保留且不悬挂(D12 验证 forget 后 export 正常),按此断言。 +2. **A3 断言精确化**:墓碑判定=needsBarriers(origin≠imported 且 source≠note),A3/F1 断言"不新增墓碑"而非全表为 0(此前用例已有墓碑)。 +3. **E2 补充步骤**:乐观锁被拒后须重跑预览刷新快照再 --yes——此即设计语义闭环。 +4. **E5(双 CLI 竞态)**:按 §J 归入本地手动项,未自动化。 +5. **G2(恒 15 工具)**:既有契约测试 mcp.spec.ts 已含,未重复建设。 +6. **G4**:pack-e2e 新增环节——save_note→CLI 预览(--json)→--yes 删除→--history 断言→search 不命中。 diff --git a/package-lock.json b/package-lock.json index 182896b..d27209a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sessionrelay", - "version": "0.2.2", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sessionrelay", - "version": "0.2.2", + "version": "0.2.5", "dependencies": { "@inquirer/prompts": "^8.7.0", "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index de18341..1ecbcd5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ewanjasper/sessionrelay", - "version": "0.2.4", + "version": "0.2.5", "description": "会话接力 SessionRelay — 属于项目、不属于任何厂商的本地记忆层(跨 Agent 会话记忆 / 中文检索 / MCP / HOP 交接协议)", "type": "module", "engines": { diff --git a/scripts/pack-e2e.mjs b/scripts/pack-e2e.mjs index 75a05fc..6e83161 100644 --- a/scripts/pack-e2e.mjs +++ b/scripts/pack-e2e.mjs @@ -84,9 +84,28 @@ try { 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`); + const call = async (name, args = {}) => { + const res = await client.callTool({ name, arguments: args }); + const text = res.content?.[0]?.text ?? '{}'; + return JSON.parse(text); + }; + // 6) 用户路径④(forget G4):save_note 造一条 → CLI forget 彻底删除 → 审计可查 + 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)`); + const pv = runBin(['forget', note.sessionId, '--json'], { cwd: proj, stdio: 'pipe' }); + const preview = JSON.parse(pv); + if (preview.id !== note.sessionId || preview.barriers !== false) fail(`forget 预览异常:${pv}`); + runBin(['forget', note.sessionId, '--yes'], { cwd: proj, stdio: 'pipe' }); + const hist = runBin(['forget', '--history', '--verbose'], { cwd: proj, stdio: 'pipe' }); + if (!hist.includes('note') || !hist.includes('1 会话')) fail(`forget --history 无删除记录:${hist}`); + // 删除后 search 不命中 + const search = runBin(['search', '遗忘验证', '--json'], { cwd: proj, stdio: 'pipe' }); + if (JSON.parse(search).hits?.some?.((h) => h.sessionId === note.sessionId)) fail('forget 后 note 仍可被检索'); + log('forget save_note→预览→删除→审计 ✓'); + console.log(`\n[pack-e2e] ✓ 全部通过 —— 真实安装路径在本机(${process.platform}/node ${process.versions.node.split('.')[0]})验证无误`); } finally { // Windows 下 sqlite WAL 句柄延迟释放会 EBUSY,重试几轮仍失败就留给系统临时目录自清 diff --git a/src/bin/srelay.ts b/src/bin/srelay.ts index 44101d8..a5dbe18 100644 --- a/src/bin/srelay.ts +++ b/src/bin/srelay.ts @@ -52,6 +52,19 @@ program .option('--force', '跳过确认/越过守护检查') .action(async (f) => { const { cmdRebuild } = await import('../cli/maint.js'); await cmdRebuild(f); }); +program + .command('forget [id]') + .description('彻底遗忘:整条会话(含决策)从本库消失,永不复活(空间老化用 archive,彻底消失用 forget)') + .option('--session ', '限定捕获会话(防误删笔记)') + .option('--note ', '限定笔记') + .option('--all', '整库重置(需 --confirm ,守护运行中拒绝)') + .option('--confirm ', '--all 的逐字确认(本项目 id)') + .option('--yes', '执行(无 --yes 仅预览)') + .option('--history', '查看遗忘审计') + .option('--verbose', '审计展开明细') + .option('--json', '机器格式') + .action(async (id: string | undefined, f) => { const { cmdForget } = await import('../cli/forget.js'); await cmdForget(f, id); }); + program .command('status') .description('透明度面板:模式/守护/计数/拦截/体积') @@ -233,7 +246,7 @@ program .option('--size mb', 'DB 超过此值时归档') .option('--source ', '只归档此来源') .option('--sessions ', '归档指定会话(逗号分隔)') - .option('--hard', '硬删除(含决策,不可恢复)') + .option('--hard', '硬删除(含决策,不可恢复)。注意:--hard 无防复活闸,源文件可能被 sync 重新收录;若需永不回来,用 srelay forget') .option('--dry-run', '只预览不执行') .option('--history', '查看归档历史') .option('--verbose', '历史详细模式') diff --git a/src/capture/ignore.ts b/src/capture/ignore.ts index 88864f3..ca24766 100644 --- a/src/capture/ignore.ts +++ b/src/capture/ignore.ts @@ -1,6 +1,7 @@ // .sessionrelayignore 匹配器(方针 §6.2 边界层,gitignore 子集) // Phase 1 语义:source: / title:<关键词> / 裸 glob 匹配源文件路径(目录尾 / 前缀匹配) // Phase 2 扩展:会话内容中的 files_mentioned 匹配(依赖元数据提取) +// v3(forget 设计 §3.2):session:/ 精确规则——防复活主防线,跨 rebuild 存活 import fs from 'node:fs'; import { ignoreFile } from '../shared/paths.js'; @@ -30,6 +31,7 @@ function globToRegExp(pat: string, dirOnly: boolean): RegExp { export interface IgnoreTarget { source: string; + sourceSessionId: string; // forget 防复活:session: 精确规则依赖 title: string | null; sourceFile: string; } @@ -37,6 +39,11 @@ export interface IgnoreTarget { export function isSessionBlocked(patterns: string[], t: IgnoreTarget): boolean { const filePosix = t.sourceFile.replace(/\\/g, '/'); for (const raw of patterns) { + if (raw.startsWith('session:')) { + // forget 墓碑规则:session:/ 精确匹配(主防线) + if (raw === `session:${t.source}/${t.sourceSessionId}`) return true; + continue; + } if (raw.startsWith('source:')) { if (t.source === raw.slice(7).trim()) return true; continue; @@ -53,3 +60,12 @@ export function isSessionBlocked(patterns: string[], t: IgnoreTarget): boolean { } return false; } + +/** forget 用:把 session: 精确规则追加进 .sessionrelayignore(强制,不可选退——设计 v4 §3.2) */ +export function appendSessionIgnoreRule(projectRoot: string, source: string, sourceSessionId: string): void { + const rule = `session:${source}/${sourceSessionId}`; + const file = ignoreFile(projectRoot); + const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''; + if (parseIgnore(existing).includes(rule)) return; // 幂等:重复 forget / 重复导入不堆叠 + fs.appendFileSync(file, `${existing.endsWith('\n') || existing === '' ? '' : '\n'}${rule}\n`); +} diff --git a/src/capture/sync.ts b/src/capture/sync.ts index 48b9976..49e6074 100644 --- a/src/capture/sync.ts +++ b/src/capture/sync.ts @@ -1,6 +1,6 @@ // 捕获同步引擎(技术方案 §5.1 / 改进方案 改动1 注册表化 + 改动3 compaction 警告) import { createDb, upsertCapturedSession, insertMessage, rollbackSession, - bumpMessageCount, getCursor, recordCursor } from '../store/db.js'; + bumpMessageCount, getCursor, recordCursor, loadTombstones } from '../store/db.js'; import type { DB } from '../store/db.js'; import type { RelayConfig } from '../shared/config.js'; import { projectIdOf, dbFile } from '../shared/paths.js'; @@ -52,6 +52,8 @@ export async function runSync(opts: SyncOptions): Promise { const db = opts.db ?? createDb(dbFile(root)); const projectId = cfg.identity.project_id ?? projectIdOf(root); const ignoreRules = loadIgnoreRules(root); + // forget 防复活次级防线(设计 v4 §3.2):入口整表载入一次,ingest 内 Set 判定 + const tombstones = loadTombstones(db); const backfillCutoffMs = opts.backfillDays ? (opts.now ?? new Date()).getTime() - opts.backfillDays * 86400_000 : -Infinity; @@ -71,14 +73,20 @@ export async function runSync(opts: SyncOptions): Promise { result.discovered++; if (ds.mtimeMs < backfillCutoffMs) continue; + // 墓碑(forget 次级防线):被遗忘会话的新字节直接丢弃,游标也不推进 + if (tombstones.has(`${ds.source}:${ds.sourceSessionId}`)) { + result.blocked++; + continue; + } + // 两层 ignore - if (isSessionBlocked(ignoreRules, { source: ds.source, title: ds.title ?? null, sourceFile: ds.sourceFile })) { + if (isSessionBlocked(ignoreRules, { source: ds.source, sourceSessionId: ds.sourceSessionId, title: ds.title ?? null, sourceFile: ds.sourceFile })) { result.blocked++; opts.stats?.increment('blocked_by_ignore'); continue; } - await ingestOne(db, ds, { mode, projectId, cfg, result, stats: opts.stats, ignoreRules, source, aConfig }); + await ingestOne(db, ds, { mode, projectId, cfg, result, stats: opts.stats, ignoreRules, tombstones, source, aConfig }); } } } finally { @@ -110,18 +118,26 @@ export async function captureSessions(opts: { const cfg = opts.config; const result: SyncStats = { mode: 'manual', discovered: opts.sessions.length, newSessions: 0, newMessages: 0, resumed: 0, badLines: 0, blocked: 0, warnings: [] }; const ignoreRules = loadIgnoreRules(opts.projectRoot); + const tombstones = loadTombstones(opts.db); const projectId = cfg.identity.project_id ?? projectIdOf(opts.projectRoot); ensureRegistered(opts.projectRoot); for (const ds of opts.sessions) { - if (isSessionBlocked(ignoreRules, { source: ds.source, title: ds.title ?? null, sourceFile: ds.sourceFile })) { + if (tombstones.has(`${ds.source}:${ds.sourceSessionId}`)) { + result.blocked++; + // C7:save 命中遗忘闸必须非静默(用户在场);自动守护路径(runSync)只计数防刷屏 + result.warnings.push(`会话「${ds.title ?? ds.sourceSessionId}」曾被 srelay forget,已拒绝重新收录(如确需恢复:删除墓碑表对应行与 .sessionrelayignore 的 session: 规则后 rebuild)`); + continue; + } + if (isSessionBlocked(ignoreRules, { source: ds.source, sourceSessionId: ds.sourceSessionId, title: ds.title ?? null, sourceFile: ds.sourceFile })) { result.blocked++; opts.stats?.increment('blocked_by_ignore'); + result.warnings.push(`会话「${ds.title ?? ds.sourceSessionId}」被忽略规则拦截(.sessionrelayignore),未存储`); continue; } const adapter = get(ds.source); if (!adapter) { result.warnings.push(`未知源:${ds.source}`); continue; } - await ingestOne(opts.db, ds, { mode: 'full', projectId, cfg, result, stats: opts.stats, ignoreRules, source: ds.source, aConfig: adapterConfig(cfg, ds.source), origin: 'manual' }); + await ingestOne(opts.db, ds, { mode: 'full', projectId, cfg, result, stats: opts.stats, ignoreRules, tombstones, source: ds.source, aConfig: adapterConfig(cfg, ds.source), origin: 'manual' }); } return result; } @@ -129,11 +145,17 @@ export async function captureSessions(opts: { async function ingestOne( db: DB, ds: DiscoveredSession, - ctx: { mode: string; projectId: string; cfg: RelayConfig; result: SyncStats; stats?: StatsCounter; ignoreRules: string[]; source: string; aConfig: import("../adapters/types.js").AdapterConfig; origin?: 'auto' | 'manual' }, + ctx: { mode: string; projectId: string; cfg: RelayConfig; result: SyncStats; stats?: StatsCounter; ignoreRules: string[]; tombstones: Set; source: string; aConfig: import("../adapters/types.js").AdapterConfig; origin?: 'auto' | 'manual' }, ): Promise { const adapter = get(ds.source); if (!adapter) return; + // 墓碑(forget 次级防线):读都不读,直接丢弃(主防线 session: ignore 在入口已挡) + if (ctx.tombstones.has(`${ds.source}:${ds.sourceSessionId}`)) { + ctx.result.blocked++; + return; + } + const cursorBefore = getCursor(db, ds.source, ds.sourceFile); const read = await adapter.readNew(ds, cursorBefore, ctx.aConfig); ctx.result.badLines += read.badLines; @@ -164,7 +186,7 @@ async function ingestOne( : ds.updatedAt ?? new Date().toISOString(); // 两层 ignore:入库前 title 复查(用已导入的 isSessionBlocked,不用动态 import) - if (isSessionBlocked(ctx.ignoreRules, { source: ds.source, title: ds.title ?? firstUserTitle, sourceFile: ds.sourceFile })) { + if (isSessionBlocked(ctx.ignoreRules, { source: ds.source, sourceSessionId: ds.sourceSessionId, title: ds.title ?? firstUserTitle, sourceFile: ds.sourceFile })) { ctx.result.blocked++; ctx.stats?.increment('blocked_by_ignore'); db.transaction(() => recordCursor(db, ds.source, ds.sourceFile, read.cursor, { badLines: read.badLines }))(); diff --git a/src/cli/forget.ts b/src/cli/forget.ts new file mode 100644 index 0000000..15beb7e --- /dev/null +++ b/src/cli/forget.ts @@ -0,0 +1,275 @@ +// srelay forget(设计 v4:把删除权交还给人,AI/MCP 保持零删除权) +// 语义:整条会话彻底消失(含决策),双防复活闸(ignore session: 主 + 墓碑次),审计永久可查。 +// 选型口诀:空间与老化用 archive;让一条对话彻底消失、永不回来,用 forget。 +import fs from 'node:fs'; +import path from 'node:path'; +import { loadConfig } from '../shared/config.js'; +import { dbFile, relayDir, findRelayRoot, ignoreFile } from '../shared/paths.js'; +import { + openExisting, createDb, getSessionFull, getLinkedSessions, + insertTombstone, insertForgetLog, finalizeForgetLog, insertForgetDetail, getForgetHistory, +} from '../store/db.js'; +import { appendSessionIgnoreRule } from '../capture/ignore.js'; +import { isDaemonAlive } from '../shared/lock.js'; +import { die, pc, fmtDate } from './ui.js'; +import type { DB } from '../store/db.js'; +import type { SessionFull } from '../store/db.js'; + +export interface ForgetFlags { + session?: string; + note?: string; + all?: boolean; + yes?: boolean; + confirm?: string; + history?: boolean; + verbose?: boolean; + json?: boolean; +} + +/** 乐观锁快照(设计 §3.6:预览→--yes 两阶段之间守护可能新捕消息,跨进程以文件传递) */ +interface PreviewSnapshot { + id: string; + messageCount: number; + decisionCount: number; + linkCount: number; + previewedAt: string; +} + +const snapshotFile = (root: string) => path.join(relayDir(root), 'forget-pending.json'); + +function readSnapshot(root: string): PreviewSnapshot | null { + try { return JSON.parse(fs.readFileSync(snapshotFile(root), 'utf8')) as PreviewSnapshot; } catch { return null; } +} +function writeSnapshot(root: string, s: PreviewSnapshot): void { + fs.writeFileSync(snapshotFile(root), JSON.stringify(s, null, 2)); +} +function clearSnapshot(root: string): void { + fs.rmSync(snapshotFile(root), { force: true }); +} + +function ageDays(iso: string | null): string { + if (!iso) return '未知'; + const d = Date.now() - new Date(iso).getTime(); + if (Number.isNaN(d) || d < 0) return '今天'; + const days = Math.floor(d / 86_400_000); + if (days === 0) return '今天'; + if (days < 30) return `${days} 天前`; + if (days < 365) return `${Math.floor(days / 30)} 个月前`; + return `${(days / 365).toFixed(1)} 年前`; +} + +interface ForgetCandidate { + id: string; title: string | null; source: string; createdAt: string | null; messageCount: number; +} + +/** + * 解析 id 引用(设计 §3.5 前缀歧义防护):完整 id 精确匹配;前缀先 COUNT, + * >1 列出候选拒绝(findSessionByPrefix 的 LIKE LIMIT 1 静默取一在删除场景致命,不复用); + * 最后兜底 source_session_id 完整匹配(note-xxx 形态)。 + */ +function resolveRef(db: DB, ref: string): { ok: true; row: SessionFull } | { ok: false; reason: 'not_found' } | { ok: false; reason: 'ambiguous'; candidates: ForgetCandidate[] } { + const exact = db.prepare('SELECT id FROM sessions WHERE id = ?').get(ref) as { id: string } | undefined; + if (exact) { + const row = getSessionFull(db, exact.id); + if (row) return { ok: true, row }; + } + const likes = db.prepare(` + SELECT id, title, source, created_at, message_count FROM sessions WHERE id LIKE ? ORDER BY COALESCE(last_event_at, created_at) DESC LIMIT 11 + `).all(ref + '%') as Array<{ id: string; title: string | null; source: string; created_at: string; message_count: number }>; + if (likes.length === 1) { + const row = getSessionFull(db, likes[0].id); + if (row) return { ok: true, row }; + } + if (likes.length > 1) { + return { ok: false, reason: 'ambiguous', candidates: likes.slice(0, 10).map((r) => ({ id: r.id, title: r.title, source: r.source, createdAt: r.created_at, messageCount: r.message_count })) }; + } + // source_session_id 完整匹配(note-xxx / 源会话 ID 形态;不做前缀——歧义防护) + const bySid = db.prepare('SELECT id FROM sessions WHERE source_session_id = ?').get(ref) as { id: string } | undefined; + if (bySid) { + const row = getSessionFull(db, bySid.id); + if (row) return { ok: true, row }; + } + return { ok: false, reason: 'not_found' }; +} + +function typeFilterMismatch(f: ForgetFlags, row: SessionFull): string | null { + if (f.note && row.source !== 'note') return `--note 指定笔记,但 ${row.id} 是 ${row.source} 会话`; + if (f.session && row.source === 'note') return `--session 指定会话,但 ${row.id} 是笔记(用 --note 或去掉过滤)`; + return null; +} + +/** note/imported 无本地源文件,不在复活路径上——跳过双闸(设计 §3.6) */ +const needsBarriers = (row: SessionFull) => row.origin !== 'imported' && row.source !== 'note'; + +export async function cmdForget(f: ForgetFlags, refArg?: string): Promise { + const root = findRelayRoot(process.cwd()); + if (!root) die('未找到 .sessionrelay(本项目尚未初始化)', '在项目根目录运行 srelay init'); + // 写命令不走 requireRoot 的懒启动守护——--all 必须能看见真实守护状态,且守护与删除无并发必要 + const cfg = loadConfig(root); + + // ── 审计历史模式 ── + if (f.history) { + const db = openExisting(dbFile(root)); + try { + const rows = getForgetHistory(db, { verbose: f.verbose }); + if (f.json) { console.log(JSON.stringify(rows, null, 2)); return; } + if (rows.length === 0) { console.log(pc.dim('(暂无遗忘记录)')); return; } + for (const row of rows) { + console.log(`${fmtDate(row.created_at as string)} ${row.mode} · ${row.sessions_affected} 会话 · ${row.messages_affected} 消息 ${pc.dim(`#${row.id}`)}`); + if (Array.isArray(row.details)) { + for (const d of row.details as Array>) { + console.log(` · ${d.session_id} 「${String(d.title ?? '').slice(0, 28)}」 · ${d.message_count}msg · ${d.source}`); + } + } + } + return; + } finally { db.close(); } + } + + // ── 整库重置(--all)── + if (f.all) { + await cmdForgetAll(root, cfg, f); + return; + } + + // ── 单会话删除 ── + if (!refArg) { + console.log(pc.red('用法:srelay forget [--yes] | --all --confirm | --history')); + process.exit(2); + } + + const db = openExisting(dbFile(root)); + try { + const resolved = resolveRef(db, refArg); + if (!resolved.ok) { + if (resolved.reason === 'not_found') { + die(`未找到会话:${refArg}`, '用 srelay list 查看完整 id'); + } + // 歧义:表格化候选(设计 §3.5 评审 D2),不用一句话 error 打发 + console.log(pc.red(`✗ 前缀「${refArg}」匹配 ${resolved.candidates.length}${resolved.candidates.length === 10 ? '+' : ''} 个会话,拒绝执行:`)); + for (const c of resolved.candidates) { + console.log(` ${c.id} 「${(c.title ?? '').slice(0, 24)}」 · ${c.source} · ${(c.createdAt ?? '').slice(0, 10)} · ${c.messageCount}msg`); + } + console.log(pc.dim(' 用更多字符或完整 id 重试')); + process.exit(2); + } + const row = resolved.row; + + const mismatch = typeFilterMismatch(f, row); + if (mismatch) die(mismatch); + + const links = getLinkedSessions(db, row.id); + const decisionCount = row.decisions.length; + const isNote = row.source === 'note'; + + // 预览模式(无 --yes 不删——防误触) + if (!f.yes) { + writeSnapshot(root, { id: row.id, messageCount: row.messageCount, decisionCount, linkCount: links.length, previewedAt: new Date().toISOString() }); + if (f.json) { + console.log(JSON.stringify({ + id: row.id, title: row.title, source: row.source, origin: row.origin, state: row.state, + createdAt: row.createdAt, ageDays: ageDays(row.createdAt), messageCount: row.messageCount, + decisionCount, links: links.map((l) => ({ id: l.sessionId, kind: l.kind, direction: l.direction })), + barriers: needsBarriers(row), reversible: false, + }, null, 2)); + return; + } + console.log(pc.cyan('📊 遗忘预览(不可逆)')); + console.log('─'.repeat(50)); + console.log(`会话 ${row.id} 「${(row.title ?? row.sourceSessionId).slice(0, 40)}」`); + console.log(` 来源 ${row.source} · ${(row.createdAt ?? '').slice(0, 10)}(${ageDays(row.createdAt)})· ${row.messageCount} 条消息 · ${decisionCount} 决策 · imported ${row.origin === 'imported' ? '是' : '否'}`); + const linkPart = links.length > 0 ? ` · 双向链接(对方 ${links.length} 条:${links.slice(0, 5).map((l) => l.sessionId).join('、')}${links.length > 5 ? '…' : ''})` : ''; + console.log(` ${pc.red('移除')}:对话正文 · 决策 · 话题${linkPart}`); + console.log(` ${pc.green('保留')}:${isNote || row.origin === 'imported' ? '(无本地源文件,不可重建)' : '原始会话文件(磁盘上不受影响,但本库不再收录)'}`); + if (needsBarriers(row)) console.log(` 写入防复活闸:ignore session: 规则 + 墓碑`); + if (row.origin === 'imported') console.log(pc.yellow(' ⚠️ imported 会话:删除后无法 rebuild 找回')); + console.log(pc.dim(' 审计:本次操作将记入 forget_log')); + console.log(pc.dim(' 确认执行加 --yes')); + return; + } + + // ── 执行(--yes):乐观锁 diff(设计 §3.6 并发安全) ── + const snap = readSnapshot(root); + if (snap && snap.id === row.id) { + if (snap.messageCount !== row.messageCount || snap.decisionCount !== decisionCount) { + die( + `预览后数据已变化(消息 ${snap.messageCount}→${row.messageCount},决策 ${snap.decisionCount}→${decisionCount}),已拒绝执行`, + '守护进程可能新捕了消息;重新运行不带 --yes 的预览确认最新数字后再执行', + ); + } + } + + const now = new Date().toISOString(); + const del = db.transaction(() => { + const logId = insertForgetLog(db, { triggeredBy: 'cli:forget', mode: isNote ? 'note' : 'session', criteria: refArg }); + insertForgetDetail(db, { + forgetLogId: logId, sessionId: row.id, title: row.title, source: row.source, + messageCount: row.messageCount, createdAt: row.createdAt, + }); + const changes = db.prepare('DELETE FROM sessions WHERE id = ?').run(row.id).changes; // FK CASCADE:messages/session_links;FTS 触发器同步 + if (needsBarriers(row)) insertTombstone(db, row.source, row.sourceSessionId, now); + finalizeForgetLog(db, logId, changes, row.messageCount); + return changes; + }); + const changes = del(); + clearSnapshot(root); + + // 库外文件操作(顺序:先库后文件;文件失败时墓碑在库内兜底) + if (changes > 0 && needsBarriers(row)) { + try { appendSessionIgnoreRule(root, row.source, row.sourceSessionId); } catch { /* 墓碑已兜底 */ } + } + + if (changes === 0) die(`会话 ${row.id} 已不存在(可能已被删除)`); + if (f.json) { + console.log(JSON.stringify({ ok: true, id: row.id, messageCount: row.messageCount, barriers: needsBarriers(row) })); + return; + } + console.log(pc.green('✓') + ` 已遗忘 ${row.id} 「${(row.title ?? row.sourceSessionId).slice(0, 40)}」(${row.messageCount} 条消息)`); + if (needsBarriers(row)) { + console.log(pc.dim(` 防复活闸已写入:${ignoreFile(root).replace(/\\/g, '/')} + 墓碑`)); + console.log(pc.yellow(` ⚠️ 原始会话文件仍在磁盘(${row.sourceFile ?? ''}),本库不再收录`)); + } + console.log(pc.dim(' 审计已记入 forget_log(srelay forget --history 查看)')); + } finally { + db.close(); + } +} + +/** --all 整库重置(设计 §3.7):守护拦截 + --confirm 逐字匹配 + 库三连删 + 库外摘要 */ +async function cmdForgetAll(root: string, cfg: ReturnType, f: ForgetFlags): Promise { + const alive = isDaemonAlive(root); + if (alive.alive) { + die(`守护进程运行中 (pid ${alive.pid}),整库重置会与写入竞争`, '先停止守护(srelay watch --uninstall 或任务管理器结束)再执行'); + } + const projectId = cfg.identity.project_id ?? root; + if (f.confirm !== projectId) { + die(`--all 需要 --confirm 逐字确认`, `本项目 id:${projectId}`); + } + + const main = dbFile(root); + const stats = { sessions: 0, messages: 0, logs: 0 }; + if (fs.existsSync(main)) { + const db = openExisting(main); + try { + stats.sessions = (db.prepare('SELECT COUNT(*) n FROM sessions').get() as { n: number }).n; + stats.messages = (db.prepare('SELECT COUNT(*) n FROM messages').get() as { n: number }).n; + stats.logs = (db.prepare('SELECT COUNT(*) n FROM forget_log').get() as { n: number }).n; + } finally { db.close(); } + } + + for (const ext of ['', '-wal', '-shm']) fs.rmSync(main + ext, { force: true }); + const fresh = createDb(main); // 重建空库(schema v3) + fresh.close(); + + // 库外摘要:forget_log 随库删除后的最后一行审计(设计 §3.7) + const ts = new Date(); + const summaryName = `forgot-at-${ts.toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.txt`; + fs.writeFileSync(path.join(relayDir(root), summaryName), + `整库遗忘(srelay forget --all)\n时间:${ts.toISOString()}\n项目:${projectId}\n删除:${stats.sessions} 会话 / ${stats.messages} 消息 / ${stats.logs} 条遗忘审计\n注意:.sessionrelayignore(含全部 session: 防复活规则)已保留——源文件不会再被收录。\n`); + + clearSnapshot(root); + if (f.json) { console.log(JSON.stringify({ ok: true, ...stats, summary: summaryName })); return; } + console.log(pc.green('✓') + ` 整库遗忘完成:${stats.sessions} 会话 · ${stats.messages} 消息已抹除,空库已重建`); + console.log(pc.dim(` 摘要:${summaryName}`)); + console.log(pc.dim(` .sessionrelayignore 已保留(防复活规则仍在,源文件不会重新入库)`)); +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index bd5bbd7..602f312 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -295,7 +295,7 @@ export function buildServer(root: string, db: DB, cfg: RelayConfig): McpServer { }, async (args) => { const id = createNoteSession(db, { projectId: project, title: args.title, content: args.content, tags: args.tags }); insertScopeLog(db, 'note', { id, title: args.title }, 'mcp:save_note'); - return toolOut({ ok: true, sessionId: id, source: 'note', state: 'confirmed', note: '笔记已可被 search / get_decisions / export 检索' }); + return toolOut({ ok: true, sessionId: id, source: 'note', state: 'confirmed', note: '笔记已可被 search / get_decisions / export 检索;可由用户以 srelay forget 移除' }); }); server.registerTool('export_handoff', { diff --git a/src/store/db.ts b/src/store/db.ts index 426b506..655ae45 100644 --- a/src/store/db.ts +++ b/src/store/db.ts @@ -9,7 +9,7 @@ export { dbFile } from '../shared/paths.js'; export type DB = Database.Database; -const SCHEMA_VERSION = 2; +const SCHEMA_VERSION = 3; const DDL = ` CREATE TABLE IF NOT EXISTS sessions ( @@ -145,6 +145,38 @@ CREATE TABLE IF NOT EXISTS cleanup_detail ( ); CREATE INDEX IF NOT EXISTS idx_cleanup_detail_log ON cleanup_detail(cleanup_log_id); CREATE INDEX IF NOT EXISTS idx_cleanup_detail_session ON cleanup_detail(session_id); + +-- ═══════════ v3:遗忘(srelay forget,设计 v4 §3.2/§3.4) ═══════════ +-- 墓碑:防复活次级防线(主防线是 .sessionrelayignore 的 session: 规则,跨 rebuild 存活; +-- 墓碑随库走,rebuild 后消失——故只是 ignore 被用户清理后的兜底) +CREATE TABLE IF NOT EXISTS forget_tombstones ( + source TEXT NOT NULL, + source_session_id TEXT NOT NULL, + forgot_at TEXT NOT NULL, + PRIMARY KEY (source, source_session_id) +); + +-- 审计:删除本身被永久记住(先例:cleanup_log/cleanup_detail)。 +-- 规范决策(设计 v4 §3.4 评审 A1):forget_detail.session_id 为裸 TEXT、故意无外键—— +-- 被删行的 id 必须留在审计里,审计链自身永不删除。禁止后续"补上 FK"(会级联破坏审计)。 +CREATE TABLE IF NOT EXISTS forget_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + triggered_by TEXT NOT NULL, + mode TEXT NOT NULL, + criteria TEXT NOT NULL, + sessions_affected INTEGER NOT NULL, + messages_affected INTEGER NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS forget_detail ( + forget_log_id INTEGER NOT NULL REFERENCES forget_log(id) ON DELETE CASCADE, + session_id TEXT NOT NULL, + title TEXT, + source TEXT, + message_count INTEGER, + created_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_forget_detail_log ON forget_detail(forget_log_id); `; export function createDb(file: string = ':memory:'): DB { @@ -529,6 +561,51 @@ export function insertCleanupDetail(db: DB, o: { ).run(o.cleanupLogId, o.sessionId, o.title, o.source, o.messageCount, o.decisionCount, new Date().toISOString()); } +// ── 遗忘:墓碑与审计(设计 v4 §3.2/§3.4;MCP 永不获得删除能力) ── + +/** 整表一次载入(规范形态,评审 A2:禁止 per-session 点查——常态 <100 行,Set 常驻零成本) */ +export function loadTombstones(db: DB): Set { + const rows = db.prepare('SELECT source, source_session_id FROM forget_tombstones').all() as Array<{ source: string; source_session_id: string }>; + return new Set(rows.map((r) => `${r.source}:${r.source_session_id}`)); +} + +export function insertTombstone(db: DB, source: string, sourceSessionId: string, at: string): void { + db.prepare('INSERT OR REPLACE INTO forget_tombstones (source, source_session_id, forgot_at) VALUES (?,?,?)') + .run(source, sourceSessionId, at); +} + +export function insertForgetLog(db: DB, o: { triggeredBy: string; mode: string; criteria: string }): number { + return Number(db.prepare( + 'INSERT INTO forget_log (triggered_by, mode, criteria, sessions_affected, messages_affected, created_at) VALUES (?,?,?,?,?,?)' + ).run(o.triggeredBy, o.mode, o.criteria, 0, 0, new Date().toISOString()).lastInsertRowid); +} + +export function finalizeForgetLog(db: DB, id: number, sessionsAffected: number, messagesAffected: number): void { + db.prepare('UPDATE forget_log SET sessions_affected = ?, messages_affected = ? WHERE id = ?') + .run(sessionsAffected, messagesAffected, id); +} + +export function insertForgetDetail(db: DB, o: { + forgetLogId: number; sessionId: string; title: string | null; source: string; + messageCount: number; createdAt: string | null; +}): void { + db.prepare( + 'INSERT INTO forget_detail (forget_log_id, session_id, title, source, message_count, created_at) VALUES (?,?,?,?,?,?)' + ).run(o.forgetLogId, o.sessionId, o.title, o.source, o.messageCount, o.createdAt); +} + +export function getForgetHistory(db: DB, opts?: { verbose?: boolean; logId?: number }): Array> { + if (opts?.logId) { + return db.prepare('SELECT session_id, title, source, message_count, created_at FROM forget_detail WHERE forget_log_id = ?').all(opts.logId) as Array>; + } + const logs = db.prepare('SELECT * FROM forget_log ORDER BY id DESC').all() as Array>; + if (!opts?.verbose) return logs; + return logs.map((l) => ({ + ...l, + details: db.prepare('SELECT session_id, title, source, message_count FROM forget_detail WHERE forget_log_id = ?').all(l.id), + })); +} + // ── 会话关联(P3-A 提前落地:link/get_linked 由 MCP 工具驱动) ── export function addSessionLink(db: DB, sessionId: string, linkedSessionId: string, kind: 'pinned' | 'continues' | 'related' = 'related'): number { diff --git a/test/forget/all-reset.spec.ts b/test/forget/all-reset.spec.ts new file mode 100644 index 0000000..be5caf3 --- /dev/null +++ b/test/forget/all-reset.spec.ts @@ -0,0 +1,102 @@ +// forget --all 整库重置(test-forget v3 · D5-D11) +// 独立 fixture:--all 会抹库,必须与其他用例隔离 +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createDb, openExisting, insertSession, countsByState } from '../../src/store/db.js'; +import { dbFile, ignoreFile, projectIdOf } from '../../src/shared/paths.js'; +import { loadConfig } from '../../src/shared/config.js'; +import { makeProject, runCli, fakeDaemon } from './helpers.js'; + +const TMP = path.resolve('test/.tmp/forget-all'); +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 */ } } + makeProject(PROJECT); + process.chdir(PROJECT); + const db = createDb(dbFile(PROJECT)); + insertSession(db, { id: 'aaa10000000000001', source: 'zcode', sourceSessionId: 'z1', projectId: PID, createdAt: '2026-08-20T08:00:00Z', title: '会话一' }); + insertSession(db, { id: 'aaa20000000000001', source: 'zcode', sourceSessionId: 'z2', projectId: PID, createdAt: '2026-08-21T08:00:00Z', title: '会话二' }); + db.close(); + // stats.json 存在(D11 检查 --all 是否保留) + fs.writeFileSync(path.join(PROJECT, '.sessionrelay', 'stats.json'), JSON.stringify({ counters: { forget: 1 } })); +}); + +afterAll(() => { + process.chdir(path.resolve('.')); + for (let i = 0; 3 > i; i++) { try { fs.rmSync(TMP, { recursive: true, force: true }); return; } catch { /* retry */ } } +}); + +describe('forget · --all 整库重置', () => { + it('D5 守护运行中:直接拒绝,库文件不变', async () => { + const before = fs.statSync(dbFile(PROJECT)).size; + fakeDaemon(PROJECT); + const { cmdForget } = await import('../../src/cli/forget.js'); + const r = await runCli(() => cmdForget({ all: true, confirm: PID })); + expect(r.exitCode).toBe(1); + expect(r.cap.err.join('\n')).toContain('守护进程运行中'); + expect(fs.statSync(dbFile(PROJECT)).size).toBe(before); // 库未动 + fs.rmSync(path.join(PROJECT, '.sessionrelay', 'lock')); // 停守护,供后续用例 + }); + + it('D6 缺 --confirm:拒绝并提示要求', async () => { + const { cmdForget } = await import('../../src/cli/forget.js'); + const r = await runCli(() => cmdForget({ all: true })); + expect(r.exitCode).toBe(1); + expect(r.cap.err.join('\n')).toContain('--confirm'); + expect(fs.existsSync(dbFile(PROJECT))).toBe(true); + }); + + it('D7 --confirm 错误项目 id:逐字匹配失败', async () => { + const { cmdForget } = await import('../../src/cli/forget.js'); + const r = await runCli(() => cmdForget({ all: true, confirm: 'wrong-project' })); + expect(r.exitCode).toBe(1); + expect(r.cap.err.join('\n')).toContain(PID); // 提示正确值 + expect(fs.existsSync(dbFile(PROJECT))).toBe(true); + }); + + it('D8/D9 正确 --confirm:三连删 + 空库重建 + ignore 保留 + forgot-at 摘要', async () => { + fs.writeFileSync(ignoreFile(PROJECT), 'session:zcode/z1\n'); // 模拟已有防复活规则 + const { cmdForget } = await import('../../src/cli/forget.js'); + const r = await runCli(() => cmdForget({ all: true, confirm: PID })); + expect(r.exitCode).toBeNull(); + // 空库已重建(schema v3) + const db = openExisting(dbFile(PROJECT)); + expect(db.pragma('user_version', { simple: true })).toBe(3); + expect(Object.keys(countsByState(db, PID)).length).toBe(0); + db.close(); + // ignore 保留(防复活关键:库没了规则还在) + expect(fs.readFileSync(ignoreFile(PROJECT), 'utf8')).toContain('session:zcode/z1'); + // 摘要文件(forget_log 随库删除后的最后一行审计) + const dir = fs.readdirSync(path.join(PROJECT, '.sessionrelay')); + const summary = dir.find((f) => /^forgot-at-\d{8,14}\.txt$/.test(f)); + expect(summary).toBeTruthy(); + const text = fs.readFileSync(path.join(PROJECT, '.sessionrelay', summary!), 'utf8'); + expect(text).toContain('2 会话'); // 删除前统计进了摘要 + expect(text).toContain(PID); + }); + + it('D10 空库后续命令正常(不 crash)', () => { + const db = openExisting(dbFile(PROJECT)); + expect(() => countsByState(db, PID)).not.toThrow(); + db.close(); + // 新库可正常写入(下次 sync 不受影响) + const db2 = openExisting(dbFile(PROJECT)); + insertSession(db2, { id: 'bbb10000000000001', source: 'zcode', sourceSessionId: 'z9', projectId: PID, createdAt: '2026-08-25T08:00:00Z', title: '重置后新会话' }); + db2.close(); + }); + + it('D11 stats.json:保留(纯事件计数无内容泄漏——钉住实现行为)', () => { + expect(fs.readFileSync(path.join(PROJECT, '.sessionrelay', 'stats.json'), 'utf8')).toContain('forget'); + }); + + it('D8b 二次 --all 在空库上也安全(幂等语义)', async () => { + const { cmdForget } = await import('../../src/cli/forget.js'); + const r = await runCli(() => cmdForget({ all: true, confirm: PID })); + expect(r.exitCode).toBeNull(); + expect(fs.existsSync(dbFile(PROJECT))).toBe(true); + void loadConfig; + }); +}); diff --git a/test/forget/concurrency.spec.ts b/test/forget/concurrency.spec.ts new file mode 100644 index 0000000..047be5c --- /dev/null +++ b/test/forget/concurrency.spec.ts @@ -0,0 +1,96 @@ +// forget 并发与一致性(test-forget v3 · E 组) +// E4 用 vitest 内双 better-sqlite3 连接实现(不开子进程——Windows CI 不稳定,v2 修订) +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createDb, openExisting, insertSession, insertMessage, getSession, countsByState } from '../../src/store/db.js'; +import { dbFile, projectIdOf } from '../../src/shared/paths.js'; +import { makeProject, runCli } from './helpers.js'; + +const TMP = path.resolve('test/.tmp/forget-e'); +const PROJECT = path.join(TMP, 'app'); +const PID = projectIdOf(PROJECT); +const S1 = 'e1f100000000000a'; + +beforeAll(() => { + for (let i = 0; i < 3; i++) { try { fs.rmSync(TMP, { recursive: true, force: true }); break; } catch { /* retry */ } } + makeProject(PROJECT); + process.chdir(PROJECT); +}); + +afterAll(() => { + process.chdir(path.resolve('.')); + for (let i = 0; 3 > i; i++) { try { fs.rmSync(TMP, { recursive: true, force: true }); return; } catch { /* retry */ } } +}); + +function seed(id: string, msgs: number): void { + const db = createDb(dbFile(PROJECT)); + insertSession(db, { id, source: 'zcode', sourceSessionId: `z-${id}`, projectId: PID, createdAt: '2026-08-20T08:00:00Z', title: `会话 ${id}` }); + for (let i = 1; i <= msgs; i++) insertMessage(db, { sessionId: id, role: i % 2 ? 'user' : 'assistant', content: `消息 ${i}`, seqNum: i }); + db.prepare('UPDATE sessions SET message_count = ? WHERE id = ?').run(msgs, id); + db.close(); +} + +describe('forget · E 并发与一致性', () => { + it('E1 乐观锁:预览后守护追加 2 条消息 → --yes 重统计 diff 命中 → 拒绝执行,会话完整', async () => { + seed(S1, 12); + const { cmdForget } = await import('../../src/cli/forget.js'); + const pv = await runCli(() => cmdForget({}, S1)); + expect(pv.exitCode).toBeNull(); // 快照已写(12 msg) + // 模拟守护并发:直插 2 条 + 维护计数(fixture 手法) + const db = openExisting(dbFile(PROJECT)); + insertMessage(db, { sessionId: S1, role: 'user', content: '并发新消息 1', seqNum: 13 }); + insertMessage(db, { sessionId: S1, role: 'assistant', content: '并发新消息 2', seqNum: 14 }); + db.prepare('UPDATE sessions SET message_count = 14 WHERE id = ?').run(S1); + db.close(); + const r = await runCli(() => cmdForget({ yes: true }, S1)); + expect(r.exitCode).toBe(1); + expect(r.cap.err.join('\n')).toContain('12→14'); + const db2 = openExisting(dbFile(PROJECT)); + expect(getSession(db2, S1)).toBeDefined(); // 仍完整 + db2.close(); + }); + + it('E2 数字未变:重新预览(刷新快照)后 --yes 正常执行', async () => { + const { cmdForget } = await import('../../src/cli/forget.js'); + // E1 被拒后正确动作:重跑预览(14 msg 快照)再执行——乐观锁语义闭环 + const pv = await runCli(() => cmdForget({}, S1)); + expect(pv.exitCode).toBeNull(); + const r = await runCli(() => cmdForget({ yes: true }, S1)); + expect(r.exitCode).toBeNull(); + const db = openExisting(dbFile(PROJECT)); + expect(getSession(db, S1)).toBeUndefined(); + db.close(); + }); + + it('E3 预览后被另一进程先删:--yes not-found,无部分删除残留、无第二条审计', async () => { + seed('e1f200000000000b', 3); + const { cmdForget } = await import('../../src/cli/forget.js'); + await runCli(() => cmdForget({}, 'e1f200000000000b')); + const db0 = openExisting(dbFile(PROJECT)); + db0.prepare('DELETE FROM sessions WHERE id = ?').run('e1f200000000000b'); + const logs0 = (db0.prepare('SELECT COUNT(*) n FROM forget_log').get() as { n: number }).n; + db0.close(); + const r = await runCli(() => cmdForget({ yes: true }, 'e1f200000000000b')); + expect(r.exitCode).toBe(1); + const db = openExisting(dbFile(PROJECT)); + expect((db.prepare('SELECT COUNT(*) n FROM forget_log').get() as { n: number }).n).toBe(logs0); + db.close(); + }); + + it('E4 双连接并发:写事务进行中读连接无脏读崩溃(WAL 快照读)', () => { + seed('e1f300000000000c', 5); + const a = openExisting(dbFile(PROJECT)); + const b = openExisting(dbFile(PROJECT)); + a.prepare('BEGIN IMMEDIATE').run(); + a.prepare('UPDATE sessions SET title = ? WHERE id = ?').run('事务中的新标题', 'e1f300000000000c'); + // 读连接:WAL 下读旧快照,不阻塞不崩溃 + const counts = countsByState(b, PID); + expect(Object.values(counts).reduce((x, y) => x + y, 0)).toBeGreaterThanOrEqual(1); + a.prepare('COMMIT').run(); + const after = b.prepare('SELECT title FROM sessions WHERE id = ?').get('e1f300000000000c') as { title: string }; + expect(after.title).toBe('事务中的新标题'); // 提交后可见 + a.close(); + b.close(); + }); +}); diff --git a/test/forget/functional.spec.ts b/test/forget/functional.spec.ts new file mode 100644 index 0000000..72726de --- /dev/null +++ b/test/forget/functional.spec.ts @@ -0,0 +1,371 @@ +// forget 功能与误用防护(test-forget v3 · A/B/D1-D4/F/G 组) +// 手法:直插 DB(§I:功能断言可用直插;复活对抗见 resurrection.spec,必须真实源文件) +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 { + createDb, openExisting, insertSession, insertMessage, insertImportedSession, + createNoteSession, addSessionLink, getLinkedSessions, getSession, + countsByState, getForgetHistory, insertTransferLog, +} from '../../src/store/db.js'; +import { dbFile, ignoreFile, projectIdOf } from '../../src/shared/paths.js'; +import { searchSessions } from '../../src/search-svc/engine.js'; +import { listDecisions } from '../../src/store/db.js'; +import { runExport } from '../../src/relay/export.js'; +import { loadConfig } from '../../src/shared/config.js'; +import { makeProject, runCli } from './helpers.js'; + +const TMP = path.resolve('test/.tmp/forget-a'); +const PROJECT = path.join(TMP, 'app'); +const PID = projectIdOf(PROJECT); + +// 直插 id 设计:S1/S2 共享前缀 a3f(D1 歧义用);S1B 唯一 +const S1 = 'a3f8c2d100000001'; // 12 msg + 1 决策 + 双向链接 +const S1B = 'a3f9c2d100000001'; // 与 S1 共享 "a3f" +const S2 = 'f1e2d3c400000001'; // 链接对方 +let N1 = ''; // note 内部 id(createNoteSession 返回) +let IMP = ''; // imported 内部 id + +beforeAll(() => { + for (let i = 0; i < 3; i++) { try { fs.rmSync(TMP, { recursive: true, force: true }); break; } catch { /* retry */ } } + makeProject(PROJECT); + process.chdir(PROJECT); + const db = createDb(dbFile(PROJECT)); + insertSession(db, { id: S1, source: 'zcode', sourceSessionId: 'z-s1', projectId: PID, createdAt: '2026-08-20T08:00:00Z', title: '数据库选型(PG vs MongoDB)', topics: ['db'] }); + for (let i = 1; i <= 12; i++) insertMessage(db, { sessionId: S1, role: i % 2 ? 'user' : 'assistant', content: i === 3 ? '磁悬浮轴承选型讨论:决定采用 PG' : `消息 ${i}:普通内容`, seqNum: i, createdAt: '2026-08-20T08:00:00Z' }); + db.prepare('UPDATE sessions SET message_count = 12, decisions = ?, last_event_at = ? WHERE id = ?') + .run(JSON.stringify([{ text: '决定采用 PostgreSQL', seq: 3 }]), '2026-08-20T09:00:00Z', S1); + insertSession(db, { id: S1B, source: 'claude-code', sourceSessionId: 'c-s1b', projectId: PID, createdAt: '2026-08-22T08:00:00Z', title: '另一个 a3f 会话', topics: ['misc'] }); + insertMessage(db, { sessionId: S1B, role: 'user', content: '无关内容', seqNum: 1, createdAt: '2026-08-22T08:00:00Z' }); + db.prepare('UPDATE sessions SET message_count = 1, last_event_at = ? WHERE id = ?').run('2026-08-22T08:00:00Z', S1B); + insertSession(db, { id: S2, source: 'zcode', sourceSessionId: 'z-s2', projectId: PID, createdAt: '2026-08-21T08:00:00Z', title: '链接对方会话', topics: ['db'] }); + insertMessage(db, { sessionId: S2, role: 'user', content: '对方内容', seqNum: 1, createdAt: '2026-08-21T08:00:00Z' }); + db.prepare('UPDATE sessions SET message_count = 1 WHERE id = ?').run(S2); + addSessionLink(db, S1, S2, 'related'); + addSessionLink(db, S2, S1, 'related'); + N1 = createNoteSession(db, { projectId: PID, title: '发布里程碑笔记', content: '决定 v0.3.0 加入遗忘权功能。', tags: ['发布', '里程碑'] }); + const imp = (() => { + insertImportedSession(db, { + source: 'zcode', sourceSessionId: 'z-imp', projectId: PID, title: '导入的老会话', + createdAt: '2026-07-01T00:00:00Z', lastEventAt: '2026-07-02T00:00:00Z', messageCount: 3, + topics: ['legacy'], decisions: [], summaryRule: null, author: '张三', + importedFrom: 'hop', originProject: 'other-proj', contentHash: 'abc123', sourceFile: null, + }); + return db.prepare("SELECT id FROM sessions WHERE source_session_id = 'z-imp' AND origin = 'imported'").get() as { id: string }; + })(); + IMP = imp.id; + db.close(); +}); + +afterAll(() => { + process.chdir(path.resolve('.')); + for (let i = 0; i < 3; i++) { try { fs.rmSync(TMP, { recursive: true, force: true }); return; } catch { /* retry */ } } +}); + +const sessionCount = (db: Database.Database) => (db.prepare('SELECT COUNT(*) n FROM sessions').get() as { n: number }).n; +const messageCount = (db: Database.Database, sid: string) => (db.prepare('SELECT COUNT(*) n FROM messages WHERE session_id = ?').get(sid) as { n: number }).n; + +// ══════════ A 组:功能正路径 ══════════ + +describe('forget · A 功能正路径', () => { + it('A1 预览(无 --yes):输出完整预览要素,DB 零删除', async () => { + const db = openExisting(dbFile(PROJECT)); + const before = sessionCount(db); + db.close(); + const { cmdForget } = await import('../../src/cli/forget.js'); + const r = await runCli(() => cmdForget({}, 'a3f8c2d10000')); // S1 唯一前缀(S1B 是 a3f9…) + expect(r.exitCode).toBeNull(); + const text = r.cap.out.join('\n'); + expect(text).toContain('遗忘预览'); + expect(text).toContain('数据库选型(PG vs MongoDB)'); + expect(text).toContain('zcode'); + expect(text).toContain('12 条消息'); + expect(text).toContain('1 决策'); + expect(text).toContain(S2); // 双向链接对方 + expect(text).toContain('imported 否'); + expect(text).toContain('天前'); // 会话年龄 + expect(text).toContain('移除'); + expect(text).toContain('保留'); + const db2 = openExisting(dbFile(PROJECT)); + expect(sessionCount(db2)).toBe(before); // 无任何删除 + db2.close(); + }); + + it('A2 执行 --yes:行数减少 + FTS integrity-check 通过', async () => { + const { cmdForget } = await import('../../src/cli/forget.js'); + const r = await runCli(() => cmdForget({ yes: true }, 'a3f8c2d10000')); + expect(r.exitCode).toBeNull(); + const db = openExisting(dbFile(PROJECT)); + expect(getSession(db, S1)).toBeUndefined(); + expect(messageCount(db, S1)).toBe(0); + // FTS 外部内容表无幽灵行(B2b 前置) + db.prepare(`INSERT INTO messages_fts(messages_fts, rowid, search_text) VALUES ('integrity-check', 0, '')`).run(); + db.prepare(`INSERT INTO sessions_fts(sessions_fts, rowid, meta_text) VALUES ('integrity-check', 0, '')`).run(); + // 双向链接 CASCADE + expect(getLinkedSessions(db, S2).some((l) => l.sessionId === S1)).toBe(false); + db.close(); + }); + + it('A3 删 note:无墓碑无 ignore 规则(source_file 判定走"可 discover 的真实文件",非字符串非空)', async () => { + const db0 = openExisting(dbFile(PROJECT)); + const tsBefore = (db0.prepare('SELECT COUNT(*) n FROM forget_tombstones').get() as { n: number }).n; // A2 的 S1 墓碑已存在 + db0.close(); + const { cmdForget } = await import('../../src/cli/forget.js'); + const r = await runCli(() => cmdForget({ yes: true, note: true }, N1)); + expect(r.exitCode).toBeNull(); + const db = openExisting(dbFile(PROJECT)); + const ts = (db.prepare('SELECT COUNT(*) n FROM forget_tombstones').get() as { n: number }).n; + expect(ts).toBe(tsBefore); // note 不在复活路径上,不新增墓碑 + db.close(); + expect(fs.readFileSync(ignoreFile(PROJECT), 'utf8').includes(`session:note/`)).toBe(false); // 无 note 规则追加 + }); + + it('A4/A5 --history:紧凑表 + json 合法', async () => { + const { cmdForget } = await import('../../src/cli/forget.js'); + const db = openExisting(dbFile(PROJECT)); + const r1 = await runCli(() => cmdForget({ history: true })); + expect(r1.exitCode).toBeNull(); + expect(r1.cap.out.join('\n')).toContain('session · 1 会话 · 12 消息'); + const r2 = await runCli(() => cmdForget({ history: true, json: true })); + const rows = JSON.parse(r2.cap.out.join('')) as Array>; + expect(rows.length).toBeGreaterThanOrEqual(2); // session + note + expect(rows[0]).toHaveProperty('sessions_affected'); + expect(rows[0]).toHaveProperty('messages_affected'); + // 详明模式展开明细 + const r3 = await runCli(() => cmdForget({ history: true, verbose: true })); + expect(r3.cap.out.join('\n')).toContain(S1); + db.close(); + }); +}); + +// ══════════ B 组:删除后全链路检索不命中 ══════════ + +describe('forget · B 删除后检索不命中', () => { + it('B1 正文与元数据双路 0 命中 + B2b FTS 完整性 + B7 getSession not-found', () => { + const db = openExisting(dbFile(PROJECT)); + const hits = searchSessions(db, { project: PID, query: '磁悬浮轴承' }); + expect(hits.length).toBe(0); + expect(getSession(db, S1)).toBeUndefined(); + db.close(); + }); + + it('B4 决策不含被删会话的决策', () => { + const db = openExisting(dbFile(PROJECT)); + const decs = listDecisions(db, PID); + expect(decs.some((d) => d.text.includes('PostgreSQL') && d.sessionId === S1)).toBe(false); + db.close(); + }); + + it('B5 双向链接:对方视角也不再返回 S1(A2 已删 out 方向,此处全量断言)', () => { + const db = openExisting(dbFile(PROJECT)); + expect(getLinkedSessions(db, S1)).toEqual([]); + expect(getLinkedSessions(db, S2).filter((l) => l.sessionId === S1)).toEqual([]); + db.close(); + }); + + it('B6 计数一致无负数(不断言 dbSizeMB——SQLite 删除不回缩文件)', () => { + const db = openExisting(dbFile(PROJECT)); + const counts = countsByState(db, PID); + const total = Object.values(counts).reduce((a, b) => a + b, 0); + expect(total).toBeGreaterThanOrEqual(2); // S1B + S2 + IMP + expect(Object.values(counts).every((n) => n >= 0)).toBe(true); + db.close(); + }); + + it('B8 note 删除后(A3 已删)标签检索 0 命中', () => { + const db = openExisting(dbFile(PROJECT)); + const hits = searchSessions(db, { project: PID, query: '里程碑 发布' }); + expect(hits.some((h) => h.sessionId === N1)).toBe(false); + db.close(); + }); + + it('B9 get_file_history 构造安全:唯一提及文件 X 的会话删除后 0 命中(JOIN sessions 天然过滤)', () => { + const db = openExisting(dbFile(PROJECT)); + // 构造 S1B 为唯一提及 src/legacy/x.ts 的会话,删之 + insertMessage(db, { sessionId: S1B, role: 'assistant', content: '这个文件 src/legacy/x.ts 要重构', seqNum: 2, createdAt: '2026-08-22T08:10:00Z' }); + const before = searchSessions(db, { project: PID, query: '"src/legacy/x.ts"' }); + expect(before.some((h) => h.sessionId === S1B)).toBe(true); + db.close(); + return (async () => { + const { cmdForget } = await import('../../src/cli/forget.js'); + await runCli(() => cmdForget({ yes: true }, 'a3f9c2d1')); + const db2 = openExisting(dbFile(PROJECT)); + const after = searchSessions(db2, { project: PID, query: '"src/legacy/x.ts"' }); + expect(after.length).toBe(0); + db2.close(); + })(); + }); + + it('B2b FTS integrity-check(两表,防静默损坏)', () => { + const db = openExisting(dbFile(PROJECT)); + expect(() => db.prepare(`INSERT INTO messages_fts(messages_fts, rowid, search_text) VALUES ('integrity-check', 0, '')`).run()).not.toThrow(); + expect(() => db.prepare(`INSERT INTO sessions_fts(sessions_fts, rowid, meta_text) VALUES ('integrity-check', 0, '')`).run()).not.toThrow(); + db.close(); + }); +}); + +// ══════════ D1-D4:前缀与误用 ══════════ + +describe('forget · D 误用防护', () => { + it('D1 前缀歧义:多命中列表格候选 + 拒绝执行 + 两者都不删', async () => { + // 此时 S1 已删;构造 f1e3 与 S2(f1e2…) 共享前缀 f1e + const db = openExisting(dbFile(PROJECT)); + insertSession(db, { id: 'f1e3000000000001', source: 'zcode', sourceSessionId: 'z-s3', projectId: PID, createdAt: '2026-08-23T08:00:00Z', title: '第三个会话', topics: ['x'] }); + const before = sessionCount(db); + db.close(); + const { cmdForget } = await import('../../src/cli/forget.js'); + const r = await runCli(() => cmdForget({ yes: true }, 'f1e')); // f1e2d3c4… 与 f1e3… 双命中 + expect(r.exitCode).toBe(2); + const text = r.cap.out.join('\n'); + expect(text).toContain('f1e2d3c400000001'); + expect(text).toContain('f1e3000000000001'); + expect(text).toContain('用更多字符或完整 id 重试'); + const db2 = openExisting(dbFile(PROJECT)); + expect(sessionCount(db2)).toBe(before); // 两个都不删 + db2.close(); + }); + + it('D2 唯一前缀正常删除流程', async () => { + const { cmdForget } = await import('../../src/cli/forget.js'); + const r = await runCli(() => cmdForget({ yes: true }, 'f1e30000')); + expect(r.exitCode).toBeNull(); + const db = openExisting(dbFile(PROJECT)); + expect(getSession(db, 'f1e3000000000001')).toBeUndefined(); + db.close(); + }); + + it('D4 不存在的 id:not-found + DB 无变化', async () => { + const db0 = openExisting(dbFile(PROJECT)); + const before = sessionCount(db0); + const logs0 = (db0.prepare('SELECT COUNT(*) n FROM forget_log').get() as { n: number }).n; + db0.close(); + const { cmdForget } = await import('../../src/cli/forget.js'); + const r = await runCli(() => cmdForget({ yes: true }, 'deadbeef')); + expect(r.exitCode).toBe(1); + expect(r.cap.err.join('\n')).toContain('未找到'); + const db = openExisting(dbFile(PROJECT)); + expect(sessionCount(db)).toBe(before); + expect((db.prepare('SELECT COUNT(*) n FROM forget_log').get() as { n: number }).n).toBe(logs0); + db.close(); + }); +}); + +// ══════════ F 组:边界对象 ══════════ + +describe('forget · F 边界', () => { + it('F1 imported:预览警示 + 删除后无墓碑(无本地源)', async () => { + const db0 = openExisting(dbFile(PROJECT)); + const tsBefore = (db0.prepare('SELECT COUNT(*) n FROM forget_tombstones').get() as { n: number }).n; // 此前用例(A2/B9/D2)已产生墓碑 + db0.close(); + const { cmdForget } = await import('../../src/cli/forget.js'); + const pv = await runCli(() => cmdForget({}, IMP)); + expect(pv.exitCode).toBeNull(); + const text = pv.cap.out.join('\n'); + expect(text).toContain('imported 是'); + expect(text).toContain('无法 rebuild 找回'); + const r = await runCli(() => cmdForget({ yes: true }, IMP)); + expect(r.exitCode).toBeNull(); + const db = openExisting(dbFile(PROJECT)); + expect((db.prepare('SELECT COUNT(*) n FROM forget_tombstones').get() as { n: number }).n).toBe(tsBefore); // imported 不新增 + db.close(); + }); + + it('F3 note-xxxx 前缀:按 sourceSessionId 识别 note 类型', async () => { + // 重新造一条 note,用其 sourceSessionId(note-xxx 形态)删 + const db = openExisting(dbFile(PROJECT)); + const nid = createNoteSession(db, { projectId: PID, title: '临时笔记', content: '决定删除这条临时记录。' }); + const row = db.prepare('SELECT source_session_id FROM sessions WHERE id = ?').get(nid) as { source_session_id: string }; + expect(row.source_session_id.startsWith('note-')).toBe(true); + db.close(); + const { cmdForget } = await import('../../src/cli/forget.js'); + const r = await runCli(() => cmdForget({ yes: true }, row.source_session_id)); + expect(r.exitCode).toBeNull(); + const db2 = openExisting(dbFile(PROJECT)); + expect(getSession(db2, nid)).toBeUndefined(); + db2.close(); + }); + + it('F4 state=active 的 auto 会话可直接删(删除权在人不参与状态协商)', async () => { + const db = openExisting(dbFile(PROJECT)); + insertSession(db, { id: 'abcd00000000000f', source: 'zcode', sourceSessionId: 'z-act', projectId: PID, createdAt: '2026-08-25T08:00:00Z', title: '活跃会话', state: 'active' }); + insertMessage(db, { sessionId: 'abcd00000000000f', role: 'user', content: '正在进行的讨论', seqNum: 1 }); + db.close(); + const { cmdForget } = await import('../../src/cli/forget.js'); + const r = await runCli(() => cmdForget({ yes: true }, 'abcd00000000000f')); + expect(r.exitCode).toBeNull(); + const db2 = openExisting(dbFile(PROJECT)); + expect(getSession(db2, 'abcd00000000000f')).toBeUndefined(); + db2.close(); + }); + + it('F5 重复删除:第二次 not-found,不产生第二条 forget_log', async () => { + // S1 早在 A2 已删;统计基线后再次 --yes + const db = openExisting(dbFile(PROJECT)); + const logs0 = (db.prepare('SELECT COUNT(*) n FROM forget_log').get() as { n: number }).n; + db.close(); + const { cmdForget } = await import('../../src/cli/forget.js'); + const r = await runCli(() => cmdForget({ yes: true }, 'a3f8c2d100000001')); + expect(r.exitCode).toBe(1); + const db2 = openExisting(dbFile(PROJECT)); + expect((db2.prepare('SELECT COUNT(*) n FROM forget_log').get() as { n: number }).n).toBe(logs0); + db2.close(); + }); + + it('F6/D12 级联完整性 + 删除后 export 不受残留影响(transfer_log 无 FK,行保留不悬挂)', async () => { + const db = openExisting(dbFile(PROJECT)); + // session_links FK CASCADE 已在 A2 验证;transfer_log 是审计表(session_ids 为 JSON 文本,无 FK) + insertTransferLog(db, 'export', path.join(TMP, 'pre.hop'), null, null, [S2, 'zzz000000000000z']); + db.close(); + const { cmdForget } = await import('../../src/cli/forget.js'); + const r = await runCli(() => cmdForget({ yes: true }, S2)); + expect(r.exitCode).toBeNull(); + // 删除后再 export:不 crash、不含已删会话 + const db2 = openExisting(dbFile(PROJECT)); + const cfg = loadConfig(PROJECT); + const out = runExport({ root: PROJECT, cfg, db: db2, output: path.join(TMP, 'after.hop') }); + expect(out.sessionCount).toBeGreaterThanOrEqual(0); + const transfer = db2.prepare('SELECT COUNT(*) n FROM transfer_log').get() as { n: number }; + expect(transfer.n).toBeGreaterThanOrEqual(1); // 手动 1 行 + runExport 自记 1 行;历史行保留(审计语义,非悬挂引用) + db2.close(); + }); +}); + +// ══════════ G 组:兼容性回归 ══════════ + +describe('forget · G 兼容性', () => { + it('G1 v2 老库升级:user_version 2→3 自动迁移,三新表就位', () => { + const old = path.join(TMP, 'v2db', 'relay.sqlite'); + fs.mkdirSync(path.dirname(old), { recursive: true }); + const db = createDb(old); + db.pragma('user_version = 2'); // 模拟 v0.2.4 老库 + db.exec('DROP TABLE forget_tombstones; DROP TABLE forget_log; DROP TABLE forget_detail;'); + db.close(); + const db2 = openExisting(old); + expect(db2.pragma('user_version', { simple: true })).toBe(3); + for (const t of ['forget_tombstones', 'forget_log', 'forget_detail']) { + expect(db2.prepare(`SELECT COUNT(*) n FROM ${t}`).get()).toBeTruthy(); + } + db2.close(); + }); + + it('G1b 降级拒绝:更高版本库被打开时报错(无半迁移状态)', () => { + const fut = path.join(TMP, 'v4db', 'relay.sqlite'); + fs.mkdirSync(path.dirname(fut), { recursive: true }); + const db = createDb(fut); + db.pragma('user_version = 4'); // 模拟未来版本创建的库 + db.close(); + expect(() => openExisting(fut)).toThrow(/更新版本的 srelay/); + }); + + it('G5/G6/G7 文案钉子:forget 口诀 / save_note 话术 / archive --hard 引导', async () => { + const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); // cwd 已被 chdir,必须绝对定位 + const bin = fs.readFileSync(path.join(REPO, 'src/bin/srelay.ts'), 'utf8'); + expect(bin).toContain('空间老化用 archive,彻底消失用 forget'); + expect(bin).toContain('--confirm '); + expect(bin).toContain('用 srelay forget'); // archive --hard 提示 + const server = fs.readFileSync(path.join(REPO, 'src/mcp/server.ts'), 'utf8'); + expect(server).toContain('可由用户以 srelay forget 移除'); + }); +}); diff --git a/test/forget/helpers.ts b/test/forget/helpers.ts new file mode 100644 index 0000000..4254968 --- /dev/null +++ b/test/forget/helpers.ts @@ -0,0 +1,48 @@ +// forget 测试公用(test-forget v3 §I 构造规格) +// 关键纪律:功能/检索断言可直插 DB;复活对抗(resurrection.spec)必须造真实源文件。 +import fs from 'node:fs'; +import path from 'node:path'; +import { vi } from 'vitest'; +import { defaultConfig, saveConfig, type RelayConfig } from '../../src/shared/config.js'; +import { projectIdOf } from '../../src/shared/paths.js'; +import type { RelayConfig as Cfg } from '../../src/shared/config.js'; + +export class CliExit extends Error { + constructor(public code: number) { super(`process.exit(${code})`); } +} + +export interface CliCapture { out: string[]; err: string[] } + +/** 捕获 CLI 输出与退出(die/用法错误走 process.exit——必须拦截防杀测试进程) */ +export async function runCli(fn: () => Promise): Promise<{ exitCode: number | null; cap: CliCapture }> { + const cap: CliCapture = { out: [], err: [] }; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { cap.out.push(a.map(String).join(' ')); }); + const errSpy = vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => { cap.err.push(a.map(String).join(' ')); }); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { throw new CliExit(code ?? 0); }) as never); + try { + await fn(); + return { exitCode: null, cap }; + } catch (e) { + if (e instanceof CliExit) return { exitCode: e.code, cap }; + throw e; + } finally { + logSpy.mockRestore(); errSpy.mockRestore(); exitSpy.mockRestore(); + } +} + +export function makeProject(dir: string, over: Partial = {}): { root: string; pid: string; cfg: RelayConfig } { + fs.mkdirSync(path.join(dir, '.sessionrelay'), { recursive: true }); + const cfg = defaultConfig(); + const pid = projectIdOf(dir); + cfg.identity.project_id = pid; + cfg.capture.sources = ['claude-code']; + Object.assign(cfg.capture, over); + saveConfig(dir, cfg); + return { root: dir, pid, cfg }; +} + +/** 伪造运行中的守护(D5:心跳新鲜 + pid 存活——用测试进程自身 pid) */ +export function fakeDaemon(root: string): void { + fs.writeFileSync(path.join(root, '.sessionrelay', 'lock'), + JSON.stringify({ pid: process.pid, heartbeat: Date.now(), at: new Date().toISOString() })); +} diff --git a/test/forget/resurrection.spec.ts b/test/forget/resurrection.spec.ts new file mode 100644 index 0000000..559b93e --- /dev/null +++ b/test/forget/resurrection.spec.ts @@ -0,0 +1,210 @@ +// forget 防复活对抗(test-forget v3 · C 组——本特性生死线) +// §I 纪律:本文件必须造真实源文件(JSONL + sqlite 源)——直插 DB 的会话 sync 根本不会发现, +// 复活对抗无从谈起。C5 两种源型分叉:JSONL 追加行(字节游标)/ zcode 源库 INSERT(rowid 游标)。 +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { runSync, captureSessions } from '../../src/capture/sync.js'; +import { resetConn } from '../../src/adapters/zcode/index.js'; +import { createDb, openExisting, listSessions, countMessages } from '../../src/store/db.js'; +import { dbFile, ignoreFile, projectIdOf } from '../../src/shared/paths.js'; +import { runRebuild } from '../../src/capture/rebuild.js'; +import { loadConfig } from '../../src/shared/config.js'; +import { makeProject, runCli } from './helpers.js'; + +const TMP = path.resolve('test/.tmp/forget-c'); +const PROJECT = path.join(TMP, 'app'); +const CLAUDE_BASE = path.join(TMP, 'claude-projects'); +const ZCODE_DB = path.join(TMP, 'zcode', 'db.sqlite'); +const PID = projectIdOf(PROJECT); + +const claudeLine = (i: number, role: 'user' | 'assistant', text: string) => + JSON.stringify({ type: role, message: { role, content: text }, timestamp: `2026-08-2${Math.min(8, 1 + i % 8)}T10:00:00Z`, sessionId: 's', cwd: PROJECT, isSidechain: false }); + +function writeClaudeSession(id: string, lines: string[]): string { + const slug = PROJECT.replace(/[\\/:]/g, '-'); + const dir = path.join(CLAUDE_BASE, slug); + fs.mkdirSync(dir, { recursive: true }); + const fp = path.join(dir, `${id}.jsonl`); + fs.writeFileSync(fp, lines.join('\n') + '\n'); + return fp; +} + +function buildFakeZcodeDb(): void { + fs.mkdirSync(path.dirname(ZCODE_DB), { recursive: true }); + const z = new Database(ZCODE_DB); + z.exec(` + CREATE TABLE IF NOT EXISTS session (id text primary key, project_id text, directory text not null, title text not null, + time_created integer not null, time_updated integer not null); + CREATE TABLE IF NOT EXISTS message (id text primary key, session_id text not null, time_created integer not null, data text not null, sequence integer); + CREATE TABLE IF NOT EXISTS part (id text primary key, message_id text not null, session_id text not null, + time_created integer, time_updated integer, data text not null, sequence integer); + `); + z.prepare('INSERT OR REPLACE INTO session VALUES (?,?,?,?,?,?)').run('sess_z1', 'proj_x', PROJECT, 'zcode 会话:编排策略', 1787800000000, 1787800600000); + const insM = z.prepare('INSERT INTO message VALUES (?,?,?,?,?)'); + const insP = z.prepare('INSERT INTO part VALUES (?,?,?,?,?,?,?)'); + insM.run('m1', 'sess_z1', 1787800000001, JSON.stringify({ role: 'user', time: { created: 1 } }), 1); + insP.run('m1_p0', 'm1', 'sess_z1', 1, 1, JSON.stringify({ type: 'text', text: '编排策略怎么选' }), 0); + z.close(); +} + +let S1_ID = ''; // claude 会话内部 id +let Z1_ID = ''; // zcode 会话内部 id + +beforeAll(() => { + for (let i = 0; i < 3; i++) { try { fs.rmSync(TMP, { recursive: true, force: true }); break; } catch { /* retry */ } } + makeProject(PROJECT, { claude_projects_dir: CLAUDE_BASE, zcode_db_path: ZCODE_DB, sources: ['claude-code', 'zcode'] }); + process.chdir(PROJECT); + writeClaudeSession('forget-target', [ + claudeLine(1, 'user', '隐私话题:删掉这条讨论'), + claudeLine(2, 'assistant', '好的这条会被遗忘'), + ]); + buildFakeZcodeDb(); +}); + +afterAll(() => { + resetConn(); // 释放 zcode 缓存连接(Windows 删除锁) + process.chdir(path.resolve('.')); + for (let i = 0; 3 > i; i++) { try { fs.rmSync(TMP, { recursive: true, force: true }); return; } catch { /* retry */ } } +}); + +const syncOnce = async () => { + const db = openExisting(dbFile(PROJECT)); + try { + return await runSync({ projectRoot: PROJECT, config: loadConfig(PROJECT), db }); + } finally { db.close(); } +}; + +// ══════════ C 组:防复活对抗(顺序链) ══════════ + +describe('forget · C 防复活对抗(真实源文件)', () => { + it('前置:首次 sync 捕获两条源会话', async () => { + const s = await syncOnce(); + expect(s.newSessions).toBe(2); + const db = openExisting(dbFile(PROJECT)); + const rows = listSessions(db, { projectId: PID }); + S1_ID = rows.find((r) => r.source === 'claude-code')!.id; + Z1_ID = rows.find((r) => r.source === 'zcode')!.id; + db.close(); + }); + + it('C1 删除后增量 sync:ignore 规则挡住(主防线),blocked+1,不复活', async () => { + const { cmdForget } = await import('../../src/cli/forget.js'); + const r = await runCli(() => cmdForget({ yes: true }, S1_ID)); + expect(r.exitCode).toBeNull(); + // ignore 规则已强制追加 + const ig = fs.readFileSync(ignoreFile(PROJECT), 'utf8'); + expect(ig).toContain('session:claude-code/forget-target'); + // 模拟文件变化:追加新行 + const slug = PROJECT.replace(/[\\/:]/g, '-'); + fs.appendFileSync(path.join(CLAUDE_BASE, slug, 'forget-target.jsonl'), claudeLine(3, 'user', '追加的新消息不应入库') + '\n'); + const s = await syncOnce(); + expect(s.blocked).toBeGreaterThanOrEqual(1); + const db = openExisting(dbFile(PROJECT)); + expect(listSessions(db, { projectId: PID }).some((x) => x.id === S1_ID)).toBe(false); + db.close(); + }); + + it('C2 清空 ignore 文件后 sync:墓碑挡住(次级防线),不复活', async () => { + fs.writeFileSync(ignoreFile(PROJECT), '# 用户清理了规则\n'); + const s = await syncOnce(); + expect(s.blocked).toBeGreaterThanOrEqual(1); + const db = openExisting(dbFile(PROJECT)); + expect(listSessions(db, { projectId: PID }).some((x) => x.id === S1_ID)).toBe(false); + db.close(); + // 还原 ignore 规则(C3 依赖) + fs.appendFileSync(ignoreFile(PROJECT), 'session:claude-code/forget-target\n'); + }); + + it('C3 rebuild --force:ignore 规则跨库存活,重建后不复活;新库墓碑为空', async () => { + const cfg = loadConfig(PROJECT); + const res = await runRebuild({ root: PROJECT, cfg, force: true }); + expect(res.autoSessions).toBe(1); // 只剩 zcode 会话 + const db = openExisting(dbFile(PROJECT)); + const rows = listSessions(db, { projectId: PID }); + expect(rows.some((x) => x.source === 'claude-code' && x.source_session_id === 'forget-target')).toBe(false); + expect((db.prepare('SELECT COUNT(*) n FROM forget_tombstones').get() as { n: number }).n).toBe(0); // 新库无墓碑 + db.close(); + expect(fs.readFileSync(ignoreFile(PROJECT), 'utf8')).toContain('session:claude-code/forget-target'); // 规则原样保留 + }); + + it('C4 双闸都拆:复活(预期行为——用户明确拆闸=撤回遗忘,开放点 1 默认裁决)', async () => { + fs.writeFileSync(ignoreFile(PROJECT), ''); // C3 后新库无墓碑 → 只剩这一道闸 + const s = await syncOnce(); + expect(s.blocked).toBe(0); + const db = openExisting(dbFile(PROJECT)); + const back = listSessions(db, { projectId: PID }).find((x) => x.source === 'claude-code' && x.source_session_id === 'forget-target'); + expect(back).toBeDefined(); // 复活 = 撤回遗忘 + db.close(); + }); + + it('C5a JSONL 源(字节游标):删除后向源文件追加行,session: 规则拦整个会话', async () => { + const { cmdForget } = await import('../../src/cli/forget.js'); + const db0 = openExisting(dbFile(PROJECT)); + const again = listSessions(db0, { projectId: PID }).find((x) => x.source === 'claude-code')!; + await runCli(() => cmdForget({ yes: true }, again.id)); + db0.close(); + const slug = PROJECT.replace(/[\\/:]/g, '-'); + fs.appendFileSync(path.join(CLAUDE_BASE, slug, 'forget-target.jsonl'), claudeLine(4, 'assistant', '再追加一条也不入库') + '\n'); + const s = await syncOnce(); + expect(s.blocked).toBeGreaterThanOrEqual(1); + const db = openExisting(dbFile(PROJECT)); + expect(listSessions(db, { projectId: PID }).some((x) => x.source === 'claude-code')).toBe(false); + db.close(); + }); + + it('C5b SQLite 源(rowid 游标):删除后向 zcode 源库 INSERT 新消息,不入库', async () => { + const { cmdForget } = await import('../../src/cli/forget.js'); + await runCli(() => cmdForget({ yes: true }, Z1_ID)); + const ig = fs.readFileSync(ignoreFile(PROJECT), 'utf8'); + expect(ig).toContain('session:zcode/sess_z1'); + const z = new Database(ZCODE_DB); + z.prepare('INSERT INTO message VALUES (?,?,?,?,?)').run('m99', 'sess_z1', 1787999999000, JSON.stringify({ role: 'user' }), 99); + z.prepare('INSERT INTO part VALUES (?,?,?,?,?,?,?)').run('m99_p0', 'm99', 'sess_z1', 1, 1, JSON.stringify({ type: 'text', text: 'rowid 水位后的新消息' }), 0); + z.close(); + const s = await syncOnce(); + expect(s.blocked).toBeGreaterThanOrEqual(1); + const db = openExisting(dbFile(PROJECT)); + expect(listSessions(db, { projectId: PID }).some((x) => x.source === 'zcode')).toBe(false); + db.close(); + }); + + it('C6 captureSessions 注入入口(save/导入路径):不复活', async () => { + const db = openExisting(dbFile(PROJECT)); + const result = await captureSessions({ + projectRoot: PROJECT, config: loadConfig(PROJECT), db, + sessions: [{ + source: 'claude-code', sourceSessionId: 'forget-target', + sourceFile: path.join(CLAUDE_BASE, PROJECT.replace(/[\\/:]/g, '-'), 'forget-target.jsonl'), + title: '注入的隐私会话', sizeBytes: 100, mtimeMs: Date.now(), + }], + }); + expect(result.blocked).toBe(1); + expect(result.newSessions).toBe(0); + db.close(); + }); + + it('C7 save 主动重存被遗忘会话:拦截 + 非静默提示(双向语义)', async () => { + const db = openExisting(dbFile(PROJECT)); + const result = await captureSessions({ + projectRoot: PROJECT, config: loadConfig(PROJECT), db, + sessions: [{ + source: 'claude-code', sourceSessionId: 'forget-target', + sourceFile: path.join(CLAUDE_BASE, PROJECT.replace(/[\\/:]/g, '-'), 'forget-target.jsonl'), + title: '用户想重新存的会话', sizeBytes: 100, mtimeMs: Date.now(), + }], + }); + expect(result.blocked).toBe(1); + // 必须有非静默提示(墓碑语义),禁止静默 0 + expect(result.warnings.join('\n')).toContain('forget'); + db.close(); + }); + + it('C7b 幂等:重复 forget / 重复导入不堆叠 ignore 规则', async () => { + const ig = fs.readFileSync(ignoreFile(PROJECT), 'utf8'); + const count = (ig.match(/session:claude-code\/forget-target/g) ?? []).length; + expect(count).toBe(1); + void countMessages; + }); +});