diff --git a/README.en.md b/README.en.md index f96490a..7862686 100644 --- a/README.en.md +++ b/README.en.md @@ -70,7 +70,7 @@ Codex already has useful primitives, but not the same complete public memory sur - local persistent sessions and rollout logs - local `cam doctor` / feature-output signals for `memories` and `codex_hooks` -`codex-auto-memory` fills that gap with a companion-first design instead of pretending native Codex memory is already ready for daily use. +`codex-auto-memory` fills that gap with a companion-first design and only a narrow compatibility seam instead of pretending native Codex memory is already ready for daily use. Near-term UX work stays focused on clearer `cam memory` / `cam session` reviewer flows. ## Who this is for @@ -79,7 +79,7 @@ Good fit: - Codex users who want a Claude-style auto memory workflow today - teams that want fully local, auditable, editable Markdown memory - maintainers who need worktree-shared project memory with worktree-local continuity -- projects that want a future native migration path without changing the user mental model +- projects that want the user mental model to stay stable even if official Codex surfaces evolve later Not a good fit: @@ -93,7 +93,7 @@ Not a good fit: | :-- | :-- | | Automatic post-session sync | extracts stable knowledge from Codex rollout JSONL and writes it back into Markdown memory | | Markdown-first memory | `MEMORY.md` and topic files are the product surface, not hidden cache | -| Compact startup injection | injects quoted `MEMORY.md` indexes plus topic refs instead of eager topic loading | +| Compact startup injection | injects only the quoted `MEMORY.md` startup files that actually enter the payload, plus on-demand topic refs, instead of eager topic loading | | Worktree-aware storage | shares project memory across worktrees while keeping local continuity isolated | | Optional session continuity | separates temporary working state from durable memory | | Reviewer surfaces | exposes `cam memory`, `cam session`, and `cam audit` for review and debugging | @@ -106,13 +106,13 @@ Not a good fit: | Local Markdown memory | Built in | No complete public contract | Yes | | `MEMORY.md` startup entrypoint | Built in | No | Yes | | 200-line startup budget | Built in | No | Yes | -| Topic files on demand | Built in | No | Partial: startup injects structured topic refs and reads details on demand | +| Topic files on demand | Built in | No | Partial: startup exposes structured topic refs for later on-demand reads | | Session continuity | Community patterns | No complete public contract | Yes, as a separate companion layer | | Worktree-shared project memory | Built in | No public contract | Yes | | Inspect / audit memory | `/memory` | No equivalent | `cam memory` | -| Native hooks / memory integration | Built in | Experimental / under development | Planned compatibility seam | +| Native hooks / memory integration | Built in | Experimental / under development | Compatibility seam only | -`cam memory` is intentionally an inspection and audit surface. It exposes the quoted index files that actually made it into the startup payload, the startup budget, on-demand topic refs, edit paths, and recent durable sync audit events behind `--recent [count]`. +`cam memory` is intentionally an inspection and audit surface. It exposes the quoted startup files that actually made it into the startup payload, currently the scoped `MEMORY.md` / index content, plus the startup budget, on-demand topic refs, edit paths, and recent durable sync audit events behind `--recent [count]`. Those topic refs are lookup pointers, not proof that topic bodies were eagerly loaded at startup. Those recent sync events come from `~/.codex-auto-memory/projects//audit/sync-log.jsonl` and only cover sync-flow `applied`, `no-op`, and `skipped` events. Manual `cam remember` / `cam forget` updates stay outside that audit stream by design. When primary memory files were written but the reviewer sidecar did not complete, `cam memory` will try to expose a pending sync recovery marker so reviewers can see that partial-success state explicitly; that marker is only cleared when the same rollout/session later completes successfully, not by an unrelated successful sync. Explicit updates still happen through `cam remember`, `cam forget`, or direct Markdown edits rather than a `/memory`-style in-command editor. @@ -158,6 +158,7 @@ After each session ends, `cam` automatically extracts knowledge from the Codex r ```bash cam memory # show active memory files and startup budget cam session status # show session continuity state +cam session refresh # regenerate continuity from provenance and replace the selected scope cam remember "Always use pnpm instead of npm" # manually record a preference cam forget "old debug note" # remove a stale entry cam audit # check the repository for unexpected sensitive content @@ -169,9 +170,12 @@ cam audit # check the repository for unexpected sensitive content | :-- | :-- | | `cam run` / `cam exec` / `cam resume` | compile startup memory and launch Codex through the wrapper | | `cam sync` | manually sync the latest rollout into durable memory | -| `cam memory` | inspect startup-loaded index files, on-demand topic refs, startup budget, edit paths, and durable sync audit events via `--recent [count]` | +| `cam memory` | inspect the quoted startup files that actually entered the payload, on-demand topic refs, startup budget, edit paths, and durable sync audit events via `--recent [count]` | | `cam remember` / `cam forget` | explicitly add or remove durable memory | -| `cam session save` / `load` / `status` / `clear` | manage the separate session continuity layer and expose a pending continuity recovery marker when needed | +| `cam session save` | merge / incremental save; append rollout-derived continuity without cleaning stale state immediately | +| `cam session refresh` | replace / clean regeneration; rebuild continuity from the selected provenance and replace the selected scope | +| `cam session load` / `status` | continuity reviewer surface for the latest audit drill-down, compact prior preview, and any pending continuity recovery marker | +| `cam session clear` / `open` | clear active continuity files or open the local continuity directory | | `cam audit` | run privacy and secret-hygiene checks against the repository | | `cam doctor` | inspect local companion wiring and native-readiness posture | @@ -179,7 +183,10 @@ cam audit # check the repository for unexpected sensitive content - `cam audit`: repository-level privacy and secret-hygiene audit. - `cam memory --recent [count]`: durable sync audit for recent `applied`, `no-op`, and `skipped` sync events, without mixing in manual `remember` / `forget`. -- `cam session save|load|status`: continuity audit surface for the latest diagnostics, latest rollout, and latest audit drill-down; `load` / `status` text output additionally shows a compact prior preview that excludes the latest entry and coalesces consecutive repeats, all three `--json` variants continue to return raw recent audit entries, and a pending continuity recovery marker appears when continuity Markdown was written but audit persistence failed. +- `cam session save`: the merge path for the continuity audit surface. It records the latest diagnostics, latest rollout, and latest audit drill-down, but it remains an incremental save and does not immediately clean polluted state. +- `cam session refresh`: the replace path for the continuity audit surface. It regenerates continuity from selected provenance and replaces the selected scope; `--json` additionally exposes `action`, `writeMode`, and `rolloutSelection`. +- `cam session load|status`: reviewer surface for the latest continuity diagnostics, latest rollout, latest audit drill-down, and a compact prior audit preview sourced from the continuity audit log that excludes the latest entry, coalesces consecutive repeats, and is not a full prior-history replay. Their `--json` output continues to expose raw recent audit entries. +- `pending continuity recovery marker`: a visible warning that continuity Markdown was written but the audit sidecar failed. It is not a general repair mechanism and is not equivalent to `cam session refresh`. ## How it works @@ -187,7 +194,7 @@ cam audit # check the repository for unexpected sensitive content - `local-first and auditable` - `Markdown files are the product surface` -- `companion-first today, native migration seam tomorrow` +- `companion-first, with a narrow compatibility seam` - `session continuity` stays separate from durable memory ### Runtime flow @@ -195,7 +202,7 @@ cam audit # check the repository for unexpected sensitive content ```mermaid flowchart TD A[Start Codex session] --> B[Compile startup memory] - B --> C[Inject quoted MEMORY.md plus topic refs] + B --> C[Inject quoted MEMORY.md startup files plus on-demand topic refs] C --> D[Run Codex] D --> E[Read rollout JSONL] E --> F[Extract durable memory operations] @@ -206,8 +213,8 @@ flowchart TD ### Why the project does not switch to native memory yet -- public Codex docs still do not define a full, stable native memory contract equivalent to Claude Code, and local `cam doctor --json` continues to treat `memories` / `codex_hooks` only as migration signals rather than a trusted primary path -- local source inspection is useful for migration planning, but not a stable product contract +- public Codex docs still do not define a full, stable native memory contract equivalent to Claude Code, and local `cam doctor --json` continues to treat `memories` / `codex_hooks` only as readiness signals rather than a trusted primary path +- local source inspection is useful when re-evaluating the compatibility seam, but not a stable product contract - the repository therefore stays companion-first until public docs, runtime behavior, and CI-verifiable stability all improve together ## Storage layout @@ -278,13 +285,13 @@ Current public-ready status: ### v0.2 - stronger contradiction handling -- richer `cam memory` and `cam session` reviewer surfaces -- better continuity diagnostics and reviewer packets -- seam-preserving bridge work for future hook support +- clearer `cam memory` and `cam session` reviewer UX +- tighter continuity diagnostics and reviewer packets +- keep a compatibility seam for future hook surfaces ### v0.3+ -- native adapter once official Codex memory and hooks stabilize +- continue tracking official Codex memory and hook surfaces without implying a primary-path change - optional GUI or TUI browser - stronger cross-session diagnostics and confidence surfaces diff --git a/README.md b/README.md index 282631d..f1b1964 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Claude Code 已经公开了一套相对清晰的 auto memory 产品契约: - 本地 persistent sessions / rollout logs - 本地 `cam doctor` / feature output 里可见的 `memories`、`codex_hooks` signal -`codex-auto-memory` 的价值,就是在官方 native memory 还没有稳定公开之前,先提供一条干净、可审计、可迁移的 companion-first 路线。 +`codex-auto-memory` 的价值,就是在官方 native memory 还没有稳定公开之前,先提供一条干净、可审计的 companion-first 路线,并只保留一条窄 compatibility seam。当前 UX 规划重点是继续收紧 `cam memory` / `cam session` 的 reviewer 体验。 ## 这个项目适合谁 @@ -79,7 +79,7 @@ Claude Code 已经公开了一套相对清晰的 auto memory 产品契约: - 想在 Codex 中获得更接近 Claude-style auto memory 工作流的用户 - 希望 memory 完全本地、完全可编辑、可以直接放进 Git 审查语境里的团队 - 需要在多个 worktree 之间共享 project memory,同时保留 worktree-local continuity 的工程流 -- 希望未来迁移到官方 native memory 时,不需要重建用户心智模型的维护者 +- 希望未来即使官方 surface 变化,也不需要重建用户心智模型的维护者 不适合: @@ -93,7 +93,7 @@ Claude Code 已经公开了一套相对清晰的 auto memory 产品契约: | :-- | :-- | | 自动 memory 同步 | 会话结束后从 Codex rollout JSONL 中提取稳定、未来有用的信息并写回 Markdown memory | | Markdown-first | `MEMORY.md` 与 topic files 就是产品表面,而不是内部缓存 | -| 紧凑启动注入 | 启动时只注入紧凑的 `MEMORY.md` 索引和 topic refs,不做 eager topic loading | +| 紧凑启动注入 | 启动时只注入真正进入 payload 的 quoted `MEMORY.md` startup files,并附带按需 topic refs,不做 eager topic loading | | worktree-aware | project memory 在同一 git 仓库的 worktree 间共享,project-local 仍保持隔离 | | session continuity | 临时 working state 与 durable memory 分层存储、分层加载 | | reviewer surface | `cam memory` / `cam session` / `cam audit` 为维护者和 reviewer 提供可核查的审查入口 | @@ -106,13 +106,13 @@ Claude Code 已经公开了一套相对清晰的 auto memory 产品契约: | 本地 Markdown memory | Built in | 没有完整公开契约 | 支持 | | `MEMORY.md` 启动入口 | Built in | 没有 | 支持 | | 200 行启动预算 | Built in | 没有 | 支持 | -| topic files 按需读取 | Built in | 没有 | 部分支持,启动时注入路径引用并按需读取 | +| topic files 按需读取 | Built in | 没有 | 部分支持,启动时暴露 topic refs,供后续按需读取 | | 跨会话 continuity | 社区方案较多 | 没有完整公开契约 | 作为独立 companion layer 支持 | | worktree 共享 project memory | Built in | 没有公开契约 | 支持 | | inspect / audit memory | `/memory` | 无等价命令 | `cam memory` | -| native hooks / memory | Built in | Experimental / under development | 当前只保留迁移 seam | +| native hooks / memory | Built in | Experimental / under development | 当前只保留 compatibility seam | -`cam memory` 当前是 inspection / audit surface:它会暴露真正进入 startup payload 的 quoted index files、startup budget、按需 topic refs、edit paths,以及 `--recent [count]` 下的 recent durable sync audit。 +`cam memory` 当前是 inspection / audit surface:它会暴露真正进入 startup payload 的 quoted startup files(当前是各 scope 的 `MEMORY.md` / index 内容)、startup budget、按需 topic refs、edit paths,以及 `--recent [count]` 下的 recent durable sync audit。这里的 topic refs 只是按需定位信息,不表示 topic body 已在启动阶段 eager 读取。 这些 recent sync events 来自 `~/.codex-auto-memory/projects//audit/sync-log.jsonl`,只覆盖 sync flow 的 `applied` / `no-op` / `skipped` 事件,不包含 manual `cam remember` / `cam forget`。 如果主 memory 文件已经写入,但 reviewer sidecar(audit / processed-state)没有完整落盘,`cam memory` 会尽力暴露一个 pending sync recovery marker,帮助 reviewer 识别 partial-success 状态;该 marker 只会在同一 rollout/session 后续成功补齐时清理,不会被不相关的成功 sync 顺手抹掉。 显式更新仍通过 `cam remember`、`cam forget` 或直接编辑 Markdown 文件完成,而不是提供 `/memory` 风格的命令内编辑器。 @@ -158,6 +158,7 @@ cam run ```bash cam memory # 查看当前 memory 文件和 startup budget cam session status # 查看 session continuity 状态 +cam session refresh # 从选定 provenance 重新生成并覆盖 continuity cam remember "Always use pnpm instead of npm" # 手动记录偏好 cam forget "old debug note" # 删除过时记录 cam audit # 检查仓库有没有意外的敏感内容 @@ -169,9 +170,12 @@ cam audit # 检查仓库有没有意外的敏感内容 | :-- | :-- | | `cam run` / `cam exec` / `cam resume` | 编译 startup memory 并通过 wrapper 启动 Codex | | `cam sync` | 手动把最近 rollout 同步进 durable memory | -| `cam memory` | 查看 startup 实际加载的 index files、按需 topic refs、startup budget、edit paths,以及 `--recent [count]` 下的 durable sync audit | +| `cam memory` | 查看真正进入 startup payload 的 quoted startup files、按需 topic refs、startup budget、edit paths,以及 `--recent [count]` 下的 durable sync audit | | `cam remember` / `cam forget` | 显式新增或删除 memory | -| `cam session save` / `load` / `status` / `clear` | 管理独立的 session continuity layer,并在需要时暴露 pending continuity recovery marker | +| `cam session save` | merge / incremental save;从 rollout 增量写入 continuity,不主动清掉已有污染状态 | +| `cam session refresh` | replace / clean regeneration;从选定 provenance 重新生成 continuity 并覆盖所选 scope | +| `cam session load` / `status` | continuity reviewer surface;显示 latest audit drill-down、compact prior preview,以及 pending continuity recovery marker | +| `cam session clear` / `open` | 清理当前 active continuity,或打开 local continuity 目录 | | `cam audit` | 做仓库级隐私 / secret hygiene 审查 | | `cam doctor` | 检查当前 companion wiring 与 native readiness posture | @@ -179,7 +183,10 @@ cam audit # 检查仓库有没有意外的敏感内容 - `cam audit`: 仓库级的 privacy / secret hygiene 审计。 - `cam memory --recent [count]`: durable sync audit,查看 recent `applied` / `no-op` / `skipped` sync 事件,不混入 manual `remember` / `forget`。 -- `cam session save|load|status`: continuity audit surface,查看最新 continuity diagnostics、latest rollout 与 latest audit drill-down;其中 `load` / `status` 的文本输出会额外显示 compact prior preview(排除 latest,并收敛连续重复项),三个命令的 `--json` 都会继续返回 raw recent audit entries;当 continuity Markdown 已写入但 audit 失败时,还会暴露 pending continuity recovery marker。 +- `cam session save`: continuity audit surface 的 merge 路径,记录最新 continuity diagnostics、latest rollout 与 latest audit drill-down;它是 incremental save,不会立刻把已有污染状态“洗干净”。 +- `cam session refresh`: continuity audit surface 的 replace 路径,从选定 provenance 重新生成 continuity,并覆盖所选 scope;`--json` 会额外暴露 `action`、`writeMode` 与 `rolloutSelection`。 +- `cam session load|status`: reviewer surface,继续展示 latest continuity diagnostics、latest rollout、latest audit drill-down,以及 compact prior audit preview(来自 continuity audit log,排除 latest,并收敛连续重复项,不是完整 prior history 回放);两个命令的 `--json` 继续返回 raw recent audit entries。 +- `pending continuity recovery marker`: continuity Markdown 已写入但 audit sidecar 失败时的可见警告;它不等于 `cam session refresh` 会自动修复一切,只会在逻辑身份匹配的后续成功写入后被清理。 ## 工作方式 @@ -187,7 +194,7 @@ cam audit # 检查仓库有没有意外的敏感内容 - `local-first and auditable` - `Markdown files are the product surface` -- `companion-first today, native migration seam tomorrow` +- `companion-first, with a narrow compatibility seam` - `session continuity` 与 `durable memory` 明确分离 ### 运行流 @@ -195,7 +202,7 @@ cam audit # 检查仓库有没有意外的敏感内容 ```mermaid flowchart TD A[启动 Codex 会话] --> B[编译 startup memory] - B --> C[注入 quoted MEMORY.md 与 topic refs] + B --> C[注入 quoted MEMORY.md startup files 与按需 topic refs] C --> D[运行 Codex] D --> E[读取 rollout JSONL] E --> F[提取 durable memory 操作] @@ -207,7 +214,7 @@ flowchart TD ### 为什么不是直接上 native memory - 官方公开文档尚未给出完整、稳定、等价于 Claude Code 的 native memory 契约;本地 `cam doctor --json` 也仍把 `memories` / `codex_hooks` 视为未进入 trusted primary path 的 signal -- 本地观察与 source inspection 可以作为 migration signal,但不能直接升级成稳定产品契约 +- 本地观察与 source inspection 可以作为重评 compatibility seam 的线索,但不能直接升级成稳定产品契约 - 因此项目默认仍然坚持 companion-first,直到官方文档、运行时稳定性和 CI 可验证性都足够强 ## 存储布局 @@ -278,13 +285,13 @@ Session continuity: ### v0.2 - 更稳的 contradiction handling -- 更好的 `cam memory` / `cam session` 审查体验 -- continuity diagnostics 与 reviewer packet 继续打磨 -- 为未来 hook bridge 保留 seam +- 更清晰的 `cam memory` / `cam session` 审查 UX +- continuity diagnostics 与 reviewer packet 继续收紧信息层次 +- 继续保留对未来 hook surface 的 compatibility seam ### v0.3+ -- 在官方 native memory / hooks 足够稳定后提供 native adapter +- 继续跟踪官方 Codex memory / hooks surfaces,不预设主路径变更 - 可选 GUI / TUI browser - 更强的跨会话 diagnostics 与 confidence surfaces diff --git a/docs/README.en.md b/docs/README.en.md index 4066423..6b3ee9e 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -3,7 +3,7 @@ [简体中文](./README.md) | [English](./README.en.md) > This is the documentation entry point for `codex-auto-memory`. -> If you are new to the repository, start with the main [README](../README.en.md). If you need design boundaries, migration posture, or reviewer guidance, use the routes below. +> If you are new to the repository, start with the main [README](../README.en.md). If you need design boundaries, compatibility posture, or reviewer guidance, use the routes below. ## Reading paths @@ -33,7 +33,7 @@ | :-- | :-- | :-- | | [Claude reference contract](./claude-reference.en.md) | defines which public Claude Code memory behaviors this project intentionally mirrors | English / [中文](./claude-reference.md) | | [Architecture](./architecture.en.md) | explains startup injection, sync flow, continuity, and storage layout | English / [中文](./architecture.md) | -| [Native migration strategy](./native-migration.en.md) | explains why the project remains companion-first and what would justify migration later | English / [中文](./native-migration.md) | +| [Native migration strategy](./native-migration.en.md) | explains why the project remains companion-first and how the compatibility seam would be re-evaluated later | English / [中文](./native-migration.md) | ## Runtime and maintainer docs diff --git a/docs/README.md b/docs/README.md index ea75af4..dc4f506 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,7 +3,7 @@ [简体中文](./README.md) | [English](./README.en.md) > 这里是 `codex-auto-memory` 的文档入口页。 -> 如果你是第一次进入仓库,建议先读默认 [README](../README.md);如果你要深入设计边界、迁移姿态或 reviewer 视角,再从这里进入对应文档。 +> 如果你是第一次进入仓库,建议先读默认 [README](../README.md);如果你要深入设计边界、兼容性姿态或 reviewer 视角,再从这里进入对应文档。 ## 阅读路径 @@ -33,7 +33,7 @@ | :-- | :-- | :-- | | [Claude Code 参考契约](./claude-reference.md) | 说明本项目主动对齐的 Claude Code memory 契约边界 | 中文 / [English](./claude-reference.en.md) | | [架构设计](./architecture.md) | 解释 startup injection、sync、continuity 与存储布局 | 中文 / [English](./architecture.en.md) | -| [Native migration 策略](./native-migration.md) | 说明为什么当前仍然 companion-first,以及未来何时可以迁移 | 中文 / [English](./native-migration.en.md) | +| [Native migration 策略](./native-migration.md) | 说明为什么当前仍然 companion-first,以及未来如何重评 compatibility seam | 中文 / [English](./native-migration.en.md) | ## 运行时与维护文档 diff --git a/docs/architecture.en.md b/docs/architecture.en.md index fd22c26..5ddcac3 100644 --- a/docs/architecture.en.md +++ b/docs/architecture.en.md @@ -21,14 +21,14 @@ The shared goal is to keep memory auditable, editable, and migration-friendly in - startup indexes must remain concise - topic files are the detail layer - session continuity must remain separate from durable memory -- companion-first is the mainline; native migration is only a seam +- companion-first is the mainline; a compatibility seam remains explicit ## System overview ```mermaid flowchart TD A[cam run / exec / resume] --> B[Compile startup memory] - B --> C[Inject quoted MEMORY.md plus topic refs] + B --> C[Inject quoted MEMORY.md startup files plus on-demand topic refs] C --> D[Run Codex] D --> E[Read rollout JSONL after session] E --> F[Extract durable memory operations] @@ -52,9 +52,9 @@ Startup currently does the following: Important implementation traits: -- each `MEMORY.md` is injected as quoted local data -- structured topic file refs are appended -- topic entry bodies are not eagerly loaded +- each `MEMORY.md` is injected as quoted startup files +- structured topic file refs are appended as on-demand lookup pointers +- topic entry bodies are not eagerly loaded at startup - session continuity, when enabled, is injected as a separate block ## 2. Post-session sync path @@ -154,10 +154,10 @@ Current public Codex surfaces still do not expose a Claude-equivalent native mem - do not mutate tracked repository files just to inject memory - compile memory outside the user repository -- inject memory as quoted data rather than implicit policy +- inject memory as quoted startup files rather than implicit policy - keep continuity separate from durable memory at injection time -## 8. Native migration seam +## 8. Compatibility seam The architecture keeps these replacement boundaries explicit: @@ -166,7 +166,7 @@ The architecture keeps these replacement boundaries explicit: - `MemoryStore` - `RuntimeInjector` -That allows future migration to replace the integration layer without rewriting the user mental model. +That keeps the integration layer replaceable without rewriting the user mental model. ## 9. Validation priorities diff --git a/docs/architecture.md b/docs/architecture.md index 6847fb1..bea4d6b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,14 +21,14 @@ - startup index must stay concise - topic files are the detail layer - session continuity must remain separate from durable memory -- companion-first is the mainline; native migration is a seam +- companion-first is the mainline; a compatibility seam remains explicit ## 系统总览 ```mermaid flowchart TD A[cam run / exec / resume] --> B[编译 startup memory] - B --> C[注入 quoted MEMORY.md 与 topic refs] + B --> C[注入 quoted MEMORY.md startup files 与按需 topic refs] C --> D[运行 Codex] D --> E[会话结束后读取 rollout JSONL] E --> F[提取 durable memory operations] @@ -52,9 +52,9 @@ flowchart TD 当前实现中,startup injection 的特征是: -- 各 scope 的 `MEMORY.md` 以 quoted local data 注入 -- 额外附带结构化 topic file refs -- 不 eager 读取 topic entry bodies +- 各 scope 的 `MEMORY.md` 以 quoted startup files 注入 +- 额外附带结构化 topic file refs,作为按需定位信息 +- startup 不 eager 读取 topic entry bodies - 允许 session continuity 作为单独 block 注入 ## 2. Post-session sync path @@ -154,10 +154,10 @@ project-local continuity 适合放: - 不改动用户仓库里的 tracked files 来完成注入 - 由 companion runtime 在外部编译 memory -- 把 memory 作为 quoted data 注入,而不是隐式 prompt policy +- 把 memory 作为 quoted startup files 注入,而不是隐式 prompt policy - continuity block 与 durable memory block 明确分开 -## 8. Native migration seam +## 8. Compatibility seam 当前架构保留了几个关键替换点: @@ -166,7 +166,7 @@ project-local continuity 适合放: - `MemoryStore` - `RuntimeInjector` -这样未来迁移时可以替换 integration layer,而不是推翻用户心智模型。 +这样未来若需要重评接入方式,可以替换 integration layer,而不是推翻用户心智模型。 ## 9. 验证重点 diff --git a/docs/native-migration.en.md b/docs/native-migration.en.md index e2ff4b5..b83fa2f 100644 --- a/docs/native-migration.en.md +++ b/docs/native-migration.en.md @@ -2,7 +2,7 @@ [简体中文](./native-migration.md) | [English](./native-migration.en.md) -> `codex-auto-memory` is not designed to stay wrapper-only forever. The point is to remain companion-first until official native memory and hook surfaces are stable enough to trust, while preserving a clean migration seam for later. +> This document records the compatibility seam and re-evaluation criteria that `codex-auto-memory` keeps while remaining companion-first. It does not imply a planned primary-path change. ## One-page conclusion @@ -10,7 +10,7 @@ Three conclusions matter most right now: - native Codex memory and hooks are not ready to be the trusted primary path - companion mode is not a temporary hack; it is the current mainline implementation -- migration should happen only when public docs, local stability, and CI-verifiable behavior all improve together +- re-evaluation is only justified when public docs, local stability, and CI-verifiable behavior improve together ## Current reality @@ -21,7 +21,7 @@ Official Codex public materials already confirm some useful building blocks: - multi-agent workflows - resume and fork flows -Local runtime behavior and `cam doctor --json` also expose migration-related signals: +Local runtime behavior and `cam doctor --json` also expose readiness signals: - rollout JSONL - `memories` @@ -39,7 +39,7 @@ From official public materials, it is safe to say: - feature maturity docs still place some capabilities in experimental or under-development categories - the current public surface does not yet define a full, stable memory contract equivalent to Claude Code -### Local observation is only a migration signal +### Local observation is only a readiness signal Source inspection or local runtime behavior may reveal: @@ -47,7 +47,7 @@ Source inspection or local runtime behavior may reveal: - feature flags - config shapes -Those can guide migration planning, but they should not be presented as stable public guarantees. +Those can guide integration re-evaluation, but they should not be presented as stable public guarantees. ## Why the project does not switch to native today @@ -61,9 +61,9 @@ Those can guide migration planning, but they should not be presented as stable p That is why the default conclusion remains: - companion-first -- native migration only when ready +- keep only a compatibility seam while companion-first remains the default path -## What must stay stable across migration +## What must stay stable if official surfaces change Even if the plumbing changes later, the user mental model should stay as stable as possible: @@ -74,7 +74,7 @@ Even if the plumbing changes later, the user mental model should stay as stable - a strict separation between session continuity and durable memory - inspect, audit, and explicit correction as part of the workflow -## Required compatibility seams +## Required compatibility seam To make later migration possible, the current implementation should keep these boundaries explicit: @@ -83,32 +83,20 @@ To make later migration possible, the current implementation should keep these b - `MemoryStore` - `RuntimeInjector` -As long as those seams remain real, the repository can replace the integration layer without rewriting the product model. +As long as those seams remain real, the repository can re-evaluate integration choices without rewriting the product model. -## Recommended migration phases - -### Phase 1: Companion-first +## Current operating rule - keep rollout JSONL as the primary session source - keep wrapper-based startup injection - keep Markdown as the primary memory surface - keep session continuity as a separate companion layer - -### Phase 2: Hybrid - -- only consider optional native bridges when both `cam doctor` and public docs improve -- keep wrapper fallback -- preserve the Markdown contract and scope model - -### Phase 3: Native-first - -- move only when native behavior is public, stable, and testable -- if native behavior cannot preserve the Markdown-first and topic-file model, keep a strict compatibility mode +- keep only an explicit compatibility seam for future native surfaces, without implying a switch phase ## Decision rule -Do not migrate simply because a native flag exists. -Migration becomes reasonable only when all of the following are true: +Do not rewrite the roadmap simply because a native flag exists. +Re-evaluation becomes reasonable only when all of the following are true: - official public documentation is sufficiently explicit - behavior is stable across releases @@ -123,3 +111,5 @@ Migration becomes reasonable only when all of the following are true: - Codex changelog: - Codex config basics: - Codex config reference: + + diff --git a/docs/native-migration.md b/docs/native-migration.md index eaf575d..dc8e1e0 100644 --- a/docs/native-migration.md +++ b/docs/native-migration.md @@ -2,7 +2,7 @@ [简体中文](./native-migration.md) | [English](./native-migration.en.md) -> `codex-auto-memory` 的目标不是永远停留在 wrapper 方案,而是在官方 native memory / hooks 真正稳定前,坚持 companion-first,并为未来迁移保留 clean seam。 +> 本文记录的是 `codex-auto-memory` 在 companion-first 前提下保留的 compatibility seam 与重评条件,不预设主路径变更。 ## 一页结论 @@ -10,7 +10,7 @@ - native Codex memory / hooks 还不能作为 trusted primary path - companion mode 不是临时凑合方案,而是当前主线实现 -- 迁移应在“公开文档 + 本地稳定性 + CI 可验证性”同时成立后再发生 +- 只有在“公开文档 + 本地稳定性 + CI 可验证性”同时改善时,才值得重评 integration choice ## 当前现实 @@ -21,7 +21,7 @@ Codex 的官方公开资料已经能确认一些对本项目有价值的基础 - multi-agent workflows - resume / fork -本地运行时与 `cam doctor --json` 还能看到一些迁移相关 signal: +本地运行时与 `cam doctor --json` 还能看到一些 readiness signal: - rollout JSONL - `memories` @@ -39,7 +39,7 @@ Codex 的官方公开资料已经能确认一些对本项目有价值的基础 - feature maturity 页面把部分能力放在 experimental / under-development 语境里 - 当前公开面没有给出完整、稳定、等价于 Claude Code 的 memory 产品契约 -### 本地观察只能作为 migration signal +### 本地观察只能作为 readiness signal 本地 source inspection 或 runtime observation 可能会显示: @@ -47,7 +47,7 @@ Codex 的官方公开资料已经能确认一些对本项目有价值的基础 - 某些 feature flags - 某些配置项 -这些信息可以帮助我们做迁移预判,但不能在公开文档里写成稳定 API 保证。 +这些信息可以帮助我们判断是否值得重评接入方式,但不能在公开文档里写成稳定 API 保证。 ## 为什么当前不能直接切到 native @@ -61,9 +61,9 @@ Codex 的官方公开资料已经能确认一些对本项目有价值的基础 因此当前默认结论仍然是: - companion-first -- native migration only when ready +- 只保留 compatibility seam,主路径仍然默认 companion-first -## 迁移时必须保持稳定的东西 +## 即使官方 surface 变化,也必须保持稳定的东西 无论未来底层是否切到 native,用户心智模型尽量不要变: @@ -74,7 +74,7 @@ Codex 的官方公开资料已经能确认一些对本项目有价值的基础 - session continuity 与 durable memory 分离 - inspect / audit / explicit correction 的基本使用方式 -## 当前必须保留的替换 seam +## 当前必须保留的 compatibility seam 为了保证未来可以迁移,当前实现必须继续显式保留: @@ -83,32 +83,20 @@ Codex 的官方公开资料已经能确认一些对本项目有价值的基础 - `MemoryStore` - `RuntimeInjector` -只要这些边界仍然真实存在,未来就可以替换 integration layer,而不是推翻产品模型。 +只要这些边界仍然真实存在,未来若需要重评接入方式,就可以替换 integration layer,而不是推翻产品模型。 -## 推荐迁移阶段 - -### Phase 1:Companion-first +## 当前运行规则 - 继续使用 rollout JSONL - 继续使用 wrapper startup injection - 继续把 Markdown 作为主存储表面 - 继续把 session continuity 当作独立 companion layer - -### Phase 2:Hybrid - -- 只有在 `cam doctor` 与官方 docs 同时改善时,才考虑可选 native bridge -- 保留 wrapper fallback -- 保留 Markdown contract 与 scope model - -### Phase 3:Native-first - -- 只有在 native 能稳定、公开、可测试地满足核心契约后才进入 -- 如果 native 不能完整保留 Markdown-first 与 topic-file model,仍需保留严格兼容模式 +- 对 future native surfaces 只保留显式 compatibility seam,不预设切换阶段 ## 决策规则 -不要因为“看到了某个 native flag”就迁移。 -只有当以下条件同时成立时,迁移才是合理的: +不要因为“看到了某个 native flag”就重写路线图。 +只有当以下条件同时成立时,才值得重新评估 integration choice: - 官方公开文档足够明确 - 多个版本之间行为稳定 @@ -124,4 +112,4 @@ Codex 的官方公开资料已经能确认一些对本项目有价值的基础 - Codex config basics: - Codex config reference: - + diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 07da508..f68ebe7 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -14,6 +14,7 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Confirm `docs/claude-reference.md` still reflects the Claude-style contract the code is trying to mimic. - Confirm `docs/native-migration.md` still matches the current compatibility seams in code. - Confirm public wording still keeps `cam memory` as an inspect/audit surface, `cam session` as a compact continuity surface, and the project as companion-first rather than native-ready. +- Confirm `docs/session-continuity.md` matches the current `cam session` command surface and reviewer semantics, especially the wording split between `save`, `refresh`, and recovery markers. ## Code and runtime checks @@ -21,6 +22,8 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - Run `pnpm test` - Run `pnpm build` - Run `cam audit` +- Run `cam session refresh --json` and confirm `action`, `writeMode`, and `rolloutSelection` reflect the selected provenance. +- Run `cam session load --json` and confirm older JSON consumers still receive the existing core fields. - Run `cam session status --json` and confirm the latest explicit audit drill-down matches the newest audit-log entry when present. - Run a local smoke flow: - `cam init` @@ -28,6 +31,7 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - `cam memory --recent --print-startup` - `cam session status` - `cam session save` + - `cam session refresh` - `cam session load --print-startup` - `cam forget "..."` - `cam doctor` diff --git a/docs/session-continuity.md b/docs/session-continuity.md index 3285f7b..b9e94c0 100644 --- a/docs/session-continuity.md +++ b/docs/session-continuity.md @@ -127,6 +127,13 @@ Current implementation rules: - candidate explicit untried ideas - Codex output must still pass local structural validation after the CLI writes JSON - if the model output is malformed, missing required layers, or returns an evidence-empty summary while the rollout clearly contains command / file / next-step evidence, the system falls back to the heuristic summarizer +- `cam session save` and wrapper auto-save still prefer the latest primary project rollout and skip forked/subagent reviewer rollouts by default; explicit `cam session save --rollout ` still lets a reviewer target a specific file on purpose +- `cam session refresh` uses a different provenance selector: + - explicit `--rollout ` always wins, including subagent rollouts + - otherwise it checks, in order, a scope-matching pending continuity recovery marker, a scope-matching latest continuity audit entry, and then the latest primary project rollout + - scope matching is exact: `both` only matches `both` + - if a higher-priority matching marker or audit entry exists but its rollout file cannot be read, refresh fails instead of silently falling through to a lower-priority source +- refresh provenance is about regenerating the currently active continuity from a trusted source, not about generally “grabbing the latest session” This keeps Codex-backed continuity as the primary quality path while preserving a deterministic local fallback for degraded sessions or brittle model output. @@ -148,10 +155,18 @@ Session continuity is available by command immediately: ```bash cam session status cam session save +cam session refresh cam session load cam session clear +cam session open ``` +Command contract: + +- `cam session save` keeps merge semantics and remains the same path used by wrapper auto-save +- `cam session refresh` ignores existing continuity during generation and replaces the selected scope from a fresh base state +- `cam session refresh` does not call `clear` before writing; replace means direct overwrite of the target state, not delete-then-save + `cam session load` now renders: - shared project continuity @@ -160,9 +175,16 @@ cam session clear - the latest continuity generation path and fallback status - the latest rollout path - a small latest-generation drill-down for evidence counts and written continuity paths -- a compact prior-generation preview sourced from the continuity audit log that excludes the latest entry and coalesces consecutive repeats +- a compact prior-generation audit preview sourced from the continuity audit log that excludes the latest entry, coalesces consecutive repeats, and does not attempt to replay full prior history + +`cam session status` now renders the latest generation path, the latest rollout path, the audit-log location, the same latest-generation drill-down, and the same compact prior-generation audit preview without printing the full shared/local continuity bodies. -`cam session status` now renders the latest generation path, the latest rollout path, the audit-log location, the same latest-generation drill-down, and the same compact prior-generation preview without printing the full shared/local continuity bodies. +`cam session refresh` renders a compact reviewer surface only: + +- it does not print the full continuity body +- `--json` keeps the existing save payload shape and adds `action`, `writeMode`, and `rolloutSelection` +- default `scope=both` replaces both layers together; single-layer scopes only replace the targeted layer +- in `sessionContinuityLocalPathStyle="claude"` mode, replace rewrites the current active local file and does not delete historical `.tmp` files Automatic injection and automatic saving are disabled by default. @@ -176,13 +198,15 @@ Continuity generation now keeps a separate reviewer-oriented audit log: ~/.codex-auto-memory/projects//audit/session-continuity-log.jsonl ``` -Each save records: +Each save, refresh, or wrapper auto-save records: - whether the preferred path was `codex` or `heuristic` - which path actually produced the saved continuity - why Codex fell back when it did - evidence counts for commands, file writes, next steps, and untried items - the rollout path and written continuity files +- `trigger`: `manual-save`, `manual-refresh`, or `wrapper-auto-save` +- `writeMode`: `merge` or `replace` This information is intentionally **not** written into the continuity Markdown files themselves. @@ -190,10 +214,18 @@ Reason: - the continuity files should stay compact and human-editable - reviewer/debug data belongs in an audit surface, not in the working-state note itself -- the latest audit entry now remains exposed explicitly as `latestContinuityAuditEntry` through `cam session save --json`, `cam session load --json`, and `cam session status --json` +- the latest audit entry now remains exposed explicitly as `latestContinuityAuditEntry` through `cam session save --json`, `cam session refresh --json`, `cam session load --json`, and `cam session status --json` - the compatibility summary field `latestContinuityDiagnostics` still exposes the latest path/fallback view for existing consumers -- the same commands now also expose raw recent audit entries so reviewers can verify short history without opening the JSONL directly -- the default `load` / `status` text surfaces now show the latest rollout, the latest evidence counts and written paths, plus a compact prior preview without becoming a dedicated history browser +- the same commands now also expose raw recent audit entries so reviewers can verify a short audit window without opening the JSONL directly +- the default `load` / `status` text surfaces now show the latest rollout, the latest evidence counts and written paths, plus a compact prior audit preview without becoming a dedicated history browser +- compact prior audit preview grouping now includes normalized `trigger` and `writeMode`, so a save and a refresh from the same rollout are still shown as distinct reviewer events + +Recovery marker rules stay narrow: + +- a continuity recovery record is still written only when audit append fails +- a successful refresh clears only a logically matching marker +- unrelated markers stay visible +- recovery metadata may include `trigger` and `writeMode` for explanation, but identity matching still uses the existing logical provenance fields rather than those display-oriented fields ## Startup behavior @@ -255,16 +287,19 @@ This design was informed by three reference buckets: ### Official Claude Code docs -- memory contract -- settings boundary -- hook lifecycle -- subagent memory boundaries +- memory contract: +- settings boundary: +- hook lifecycle: +- subagent memory boundaries: Those sources justify keeping durable memory compact, auditable, and Markdown-first while treating temporary continuity as a separate companion concern. ### Official Codex docs and runtime surface -- Codex CLI, config, AGENTS, resume/fork, and feature flags +- Codex CLI overview: +- Codex feature maturity: +- Codex config basics: +- Codex config reference: - current local `memories` / `codex_hooks` readiness from `cam doctor` These sources justify the current implementation choice: diff --git a/src/cli.ts b/src/cli.ts index 9c1f413..b863156 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -116,6 +116,15 @@ async function main(): Promise { .action(async (options) => { process.stdout.write(`${await runSession("save", options)}\n`); }); + sessionCommand + .command("refresh") + .description("Regenerate session continuity from provenance and replace the selected scope") + .option("--json", "Print JSON output") + .option("--rollout ", "Specific rollout JSONL file to summarize") + .option("--scope ", "Target continuity scope: project, project-local, or both", "both") + .action(async (options) => { + process.stdout.write(`${await runSession("refresh", options)}\n`); + }); sessionCommand .command("load") .description("Load current session continuity summary") diff --git a/src/lib/commands/memory.ts b/src/lib/commands/memory.ts index 10dba4c..cd47329 100644 --- a/src/lib/commands/memory.ts +++ b/src/lib/commands/memory.ts @@ -198,6 +198,8 @@ export async function runMemory(options: MemoryOptions = {}): Promise { `Auto memory enabled: ${runtime.loadedConfig.config.autoMemoryEnabled}`, `Config files: ${runtime.loadedConfig.files.length ? runtime.loadedConfig.files.join(", ") : "none"}`, `Startup budget: ${startupBudget.usedLines}/${startupBudget.maxLines} lines | Refs: global ${refCountsByScope.global.startupFiles}/${refCountsByScope.global.topicFiles}, project ${refCountsByScope.project.startupFiles}/${refCountsByScope.project.topicFiles}, project-local ${refCountsByScope.projectLocal.startupFiles}/${refCountsByScope.projectLocal.topicFiles}`, + "Startup loaded files are the index files actually quoted into the current startup payload.", + "Topic files on demand stay as references until a later read needs them.", ...(configUpdateMessage ? [configUpdateMessage] : []), ...runtime.loadedConfig.warnings.map((warning) => `Warning: ${warning}`), "", diff --git a/src/lib/commands/session.ts b/src/lib/commands/session.ts index 43a32db..928e674 100644 --- a/src/lib/commands/session.ts +++ b/src/lib/commands/session.ts @@ -1,9 +1,14 @@ -import { findLatestProjectRollout } from "../domain/rollout.js"; +import { + findLatestProjectRollout, + parseRolloutEvidence +} from "../domain/rollout.js"; import { openPath } from "../util/open.js"; import { buildSessionContinuityAuditEntry, formatSessionContinuityAuditDrillDown, formatSessionContinuityDiagnostics, + normalizeSessionContinuityAuditTrigger, + normalizeSessionContinuityWriteMode, toSessionContinuityDiagnostics } from "../domain/session-continuity-diagnostics.js"; import { @@ -17,13 +22,26 @@ import { } from "../domain/session-continuity.js"; import type { ContinuityRecoveryRecord, + SessionContinuityAuditTrigger, SessionContinuityAuditEntry, - SessionContinuityScope + SessionContinuityScope, + SessionContinuityWriteMode } from "../types.js"; import { SessionContinuitySummarizer } from "../extractor/session-continuity-summarizer.js"; import { buildRuntimeContext } from "./common.js"; -type SessionAction = "status" | "save" | "load" | "clear" | "open"; +type SessionAction = "status" | "save" | "refresh" | "load" | "clear" | "open"; +type SessionRuntime = Awaited>; +type RolloutSelectionKind = + | "explicit-rollout" + | "pending-recovery-marker" + | "latest-audit-entry" + | "latest-primary-rollout"; + +interface RolloutSelection { + kind: RolloutSelectionKind; + rolloutPath: string; +} interface SessionOptions { cwd?: string; @@ -37,6 +55,27 @@ const recentContinuityAuditLimit = 5; const recentContinuityPreviewReadLimit = 10; const recentContinuityPreviewGroupLimit = 3; +interface PersistSessionContinuityOptions { + runtime: SessionRuntime; + rolloutPath: string; + scope: SessionContinuityScope | "both"; + trigger: SessionContinuityAuditTrigger; + writeMode: SessionContinuityWriteMode; +} + +interface PersistSessionContinuityResult { + rolloutPath: string; + written: string[]; + excludePath: string | null; + summary: Awaited>["summary"]; + diagnostics: Awaited>["diagnostics"]; + latestContinuityAuditEntry: SessionContinuityAuditEntry | null; + recentContinuityAuditEntries: SessionContinuityAuditEntry[]; + pendingContinuityRecovery: ContinuityRecoveryRecord | null; + continuityAuditPath: string; + continuityRecoveryPath: string; +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -65,6 +104,8 @@ function formatRecentGenerationLines(entries: SessionContinuityAuditEntry[]): st rolloutPath: entry.rolloutPath, sourceSessionId: entry.sourceSessionId, scope: entry.scope, + trigger: normalizeSessionContinuityAuditTrigger(entry.trigger), + writeMode: normalizeSessionContinuityWriteMode(entry.writeMode), preferredPath: entry.preferredPath, actualPath: entry.actualPath, fallbackReason: entry.fallbackReason ?? null, @@ -108,6 +149,8 @@ function formatPendingContinuityRecovery( `- Recovery file: ${recoveryPath}`, `- Failed stage: ${record.failedStage}`, `- Rollout: ${record.rolloutPath}`, + ...(record.trigger ? [`- Trigger: ${record.trigger}`] : []), + ...(record.writeMode ? [`- Write mode: ${record.writeMode}`] : []), `- Scope: ${record.scope}`, `- Generation: ${record.actualPath} | preferred ${record.preferredPath}`, `- Failure: ${record.failureMessage}` @@ -120,6 +163,131 @@ function formatPendingContinuityRecovery( return lines; } +async function selectRefreshRollout( + runtime: SessionRuntime, + scope: SessionContinuityScope | "both", + explicitRollout?: string +): Promise { + if (explicitRollout) { + return { + kind: "explicit-rollout", + rolloutPath: explicitRollout + }; + } + + const recoveryRecord = await runtime.sessionContinuityStore.readRecoveryRecord(); + if (recoveryRecord?.scope === scope) { + return { + kind: "pending-recovery-marker", + rolloutPath: recoveryRecord.rolloutPath + }; + } + + const latestAuditEntry = + await runtime.sessionContinuityStore.readLatestAuditEntryMatchingScope(scope); + if (latestAuditEntry) { + return { + kind: "latest-audit-entry", + rolloutPath: latestAuditEntry.rolloutPath + }; + } + + const latestPrimaryRollout = await findLatestProjectRollout(runtime.project); + if (latestPrimaryRollout) { + return { + kind: "latest-primary-rollout", + rolloutPath: latestPrimaryRollout + }; + } + + throw new Error("No relevant rollout found for this project."); +} + +async function persistSessionContinuity( + options: PersistSessionContinuityOptions +): Promise { + const parsedEvidence = await parseRolloutEvidence(options.rolloutPath); + if (!parsedEvidence) { + throw new Error(`Could not parse rollout evidence from ${options.rolloutPath}.`); + } + + const existing = + options.writeMode === "merge" + ? { + project: await options.runtime.sessionContinuityStore.readState("project"), + projectLocal: await options.runtime.sessionContinuityStore.readState("project-local") + } + : undefined; + const summarizer = new SessionContinuitySummarizer(options.runtime.loadedConfig.config); + const generation = await summarizer.summarizeWithDiagnostics(parsedEvidence, existing); + const written = + options.writeMode === "replace" + ? await options.runtime.sessionContinuityStore.replaceSummary( + generation.summary, + options.scope + ) + : await options.runtime.sessionContinuityStore.saveSummary( + generation.summary, + options.scope + ); + const auditEntry = buildSessionContinuityAuditEntry( + options.runtime.project, + options.runtime.loadedConfig.config, + generation.diagnostics, + written, + options.scope, + { + trigger: options.trigger, + writeMode: options.writeMode + } + ); + + try { + await options.runtime.sessionContinuityStore.appendAuditLog(auditEntry); + } catch (error) { + await writeContinuityRecoveryRecordBestEffort( + options.runtime, + generation.diagnostics, + options.scope, + written, + errorMessage(error), + options.trigger, + options.writeMode + ); + throw error; + } + + await clearContinuityRecoveryRecordBestEffort( + options.runtime, + generation.diagnostics, + options.scope + ); + + const recentContinuityAuditPreviewEntries = + await options.runtime.sessionContinuityStore.readRecentAuditEntries( + recentContinuityPreviewReadLimit + ); + + return { + rolloutPath: options.rolloutPath, + written, + excludePath: + options.scope === "project" + ? null + : options.runtime.sessionContinuityStore.getLocalIgnorePath(), + summary: generation.summary, + diagnostics: generation.diagnostics, + latestContinuityAuditEntry: recentContinuityAuditPreviewEntries[0] ?? null, + recentContinuityAuditEntries: recentContinuityAuditPreviewEntries.slice( + 0, + recentContinuityAuditLimit + ), + pendingContinuityRecovery: await options.runtime.sessionContinuityStore.readRecoveryRecord(), + continuityAuditPath: options.runtime.sessionContinuityStore.paths.auditFile, + continuityRecoveryPath: options.runtime.sessionContinuityStore.getRecoveryPath() + }; +} + export async function runSession( action: SessionAction, options: SessionOptions = {} @@ -128,76 +296,46 @@ export async function runSession( const runtime = await buildRuntimeContext(cwd); const scope = selectedScope(options.scope); - if (action === "save") { - const rolloutPath = - options.rollout ?? (await findLatestProjectRollout(runtime.project)); - if (!rolloutPath) { + if (action === "save" || action === "refresh") { + const rolloutSelection = + action === "refresh" + ? await selectRefreshRollout(runtime, scope, options.rollout) + : { + kind: options.rollout ? "explicit-rollout" : "latest-primary-rollout", + rolloutPath: options.rollout ?? (await findLatestProjectRollout(runtime.project)) ?? "" + }; + if (!rolloutSelection.rolloutPath) { throw new Error("No relevant rollout found for this project."); } - const { parseRolloutEvidence } = await import("../domain/rollout.js"); - const parsedEvidence = await parseRolloutEvidence(rolloutPath); - if (!parsedEvidence) { - throw new Error(`Could not parse rollout evidence from ${rolloutPath}.`); - } - - const existing = { - project: await runtime.sessionContinuityStore.readState("project"), - projectLocal: await runtime.sessionContinuityStore.readState("project-local") - }; - const summarizer = new SessionContinuitySummarizer(runtime.loadedConfig.config); - const generation = await summarizer.summarizeWithDiagnostics(parsedEvidence, existing); - const written = await runtime.sessionContinuityStore.saveSummary(generation.summary, scope); - const auditEntry = buildSessionContinuityAuditEntry( - runtime.project, - runtime.loadedConfig.config, - generation.diagnostics, - written, - scope - ); - try { - await runtime.sessionContinuityStore.appendAuditLog(auditEntry); - } catch (error) { - await writeContinuityRecoveryRecordBestEffort( - runtime, - generation.diagnostics, - scope, - written, - errorMessage(error) - ); - throw error; - } - await clearContinuityRecoveryRecordBestEffort( + const persisted = await persistSessionContinuity({ runtime, - generation.diagnostics, - scope - ); - const excludePath = - scope === "project" ? null : runtime.sessionContinuityStore.getLocalIgnorePath(); - const recentContinuityAuditPreviewEntries = - await runtime.sessionContinuityStore.readRecentAuditEntries( - recentContinuityPreviewReadLimit - ); - const recentContinuityAuditEntries = recentContinuityAuditPreviewEntries.slice( - 0, - recentContinuityAuditLimit - ); - const latestContinuityAuditEntry = recentContinuityAuditPreviewEntries[0] ?? null; - const pendingContinuityRecovery = await runtime.sessionContinuityStore.readRecoveryRecord(); + rolloutPath: rolloutSelection.rolloutPath, + scope, + trigger: action === "refresh" ? "manual-refresh" : "manual-save", + writeMode: action === "refresh" ? "replace" : "merge" + }); if (options.json) { return JSON.stringify( { - rolloutPath, - written, - excludePath, - summary: generation.summary, - diagnostics: generation.diagnostics, - latestContinuityAuditEntry, - recentContinuityAuditEntries, - continuityAuditPath: runtime.sessionContinuityStore.paths.auditFile, - pendingContinuityRecovery, - continuityRecoveryPath: runtime.sessionContinuityStore.getRecoveryPath() + ...(action === "refresh" + ? { + action: "refresh", + writeMode: "replace", + rolloutSelection + } + : {}), + rolloutPath: persisted.rolloutPath, + written: persisted.written, + excludePath: persisted.excludePath, + summary: persisted.summary, + diagnostics: persisted.diagnostics, + latestContinuityAuditEntry: persisted.latestContinuityAuditEntry, + recentContinuityAuditEntries: persisted.recentContinuityAuditEntries, + continuityAuditPath: persisted.continuityAuditPath, + pendingContinuityRecovery: persisted.pendingContinuityRecovery, + continuityRecoveryPath: persisted.continuityRecoveryPath }, null, 2 @@ -205,12 +343,17 @@ export async function runSession( } return [ - `Saved session continuity from ${rolloutPath}`, - formatSessionContinuityDiagnostics(generation.diagnostics), - ...(latestContinuityAuditEntry - ? formatSessionContinuityAuditDrillDown(latestContinuityAuditEntry) + action === "refresh" + ? `Refreshed session continuity from ${persisted.rolloutPath}` + : `Saved session continuity from ${persisted.rolloutPath}`, + ...(action === "refresh" + ? [`Selection: ${rolloutSelection.kind} | write mode: replace`] + : []), + formatSessionContinuityDiagnostics(persisted.diagnostics), + ...(persisted.latestContinuityAuditEntry + ? formatSessionContinuityAuditDrillDown(persisted.latestContinuityAuditEntry) : []), - ...(excludePath ? [`Local exclude updated: ${excludePath}`] : []) + ...(persisted.excludePath ? [`Local exclude updated: ${persisted.excludePath}`] : []) ].join("\n"); } @@ -293,6 +436,8 @@ export async function runSession( `Latest generation: ${latestContinuityDiagnostics ? formatSessionContinuityDiagnostics(latestContinuityDiagnostics) : "none recorded yet"}`, ...(latestContinuityAuditEntry ? [`Latest rollout: ${latestContinuityAuditEntry.rolloutPath}`] : []), `Continuity audit: ${runtime.sessionContinuityStore.paths.auditFile}`, + "Merged resume brief combines shared continuity with any project-local overrides.", + "Recent prior generations below are compact audit previews, not startup-injected history.", ...(latestContinuityAuditEntry ? formatSessionContinuityAuditDrillDown(latestContinuityAuditEntry) : []), @@ -424,6 +569,8 @@ export async function runSession( `Latest generation: ${latestContinuityDiagnostics ? formatSessionContinuityDiagnostics(latestContinuityDiagnostics) : "none recorded yet"}`, ...(latestContinuityAuditEntry ? [`Latest rollout: ${latestContinuityAuditEntry.rolloutPath}`] : []), `Continuity audit: ${runtime.sessionContinuityStore.paths.auditFile}`, + "Merged resume brief combines shared continuity with any project-local overrides.", + "Recent prior generations below are compact audit previews, not startup-injected history.", ...(latestContinuityAuditEntry ? formatSessionContinuityAuditDrillDown(latestContinuityAuditEntry) : []), @@ -444,11 +591,13 @@ export async function runSession( } async function writeContinuityRecoveryRecordBestEffort( - runtime: Awaited>, + runtime: SessionRuntime, diagnostics: Parameters[0]["diagnostics"], scope: SessionContinuityScope | "both", writtenPaths: string[], - failureMessage: string + failureMessage: string, + trigger?: SessionContinuityAuditTrigger, + writeMode?: SessionContinuityWriteMode ): Promise { try { await runtime.sessionContinuityStore.writeRecoveryRecord( @@ -456,6 +605,8 @@ async function writeContinuityRecoveryRecordBestEffort( projectId: runtime.project.projectId, worktreeId: runtime.project.worktreeId, diagnostics, + trigger, + writeMode, scope, writtenPaths, failedStage: "audit-write", @@ -468,7 +619,7 @@ async function writeContinuityRecoveryRecordBestEffort( } async function clearContinuityRecoveryRecordBestEffort( - runtime: Awaited>, + runtime: SessionRuntime, diagnostics: Parameters[0]["diagnostics"], scope: SessionContinuityScope | "both" ): Promise { diff --git a/src/lib/commands/wrapper.ts b/src/lib/commands/wrapper.ts index 0422aa9..7ba7a7d 100644 --- a/src/lib/commands/wrapper.ts +++ b/src/lib/commands/wrapper.ts @@ -7,7 +7,7 @@ import { buildContinuityRecoveryRecord, matchesContinuityRecoveryRecord } from "../domain/recovery-records.js"; -import { listRolloutFiles, parseRolloutEvidence } from "../domain/rollout.js"; +import { listRolloutFiles, parseRolloutEvidence, readRolloutMeta } from "../domain/rollout.js"; import { compileSessionContinuity } from "../domain/session-continuity.js"; import { readCodexBaseInstructions } from "../runtime/codex-config.js"; import { runCommand } from "../util/process.js"; @@ -23,6 +23,18 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +async function selectLatestPrimaryRollout(candidates: string[]): Promise { + const metas = await Promise.all(candidates.map((candidate) => readRolloutMeta(candidate))); + for (let index = candidates.length - 1; index >= 0; index -= 1) { + if (metas[index]?.isSubagent === true) { + continue; + } + return candidates[index] ?? null; + } + + return null; +} + async function syncRecentRollouts( cwd: string, before: string[], @@ -93,7 +105,7 @@ async function saveSessionContinuity( startedAtMs, endedAtMs ); - const rolloutPath = candidates.at(-1) ?? null; + const rolloutPath = await selectLatestPrimaryRollout(candidates); if (!rolloutPath) { return null; } @@ -115,7 +127,11 @@ async function saveSessionContinuity( runtime.loadedConfig.config, generation.diagnostics, written, - "both" + "both", + { + trigger: "wrapper-auto-save", + writeMode: "merge" + } ); try { await runtime.sessionContinuityStore.appendAuditLog(auditEntry); @@ -126,6 +142,8 @@ async function saveSessionContinuity( projectId: runtime.project.projectId, worktreeId: runtime.project.worktreeId, diagnostics: generation.diagnostics, + trigger: "wrapper-auto-save", + writeMode: "merge", scope: "both", writtenPaths: written, failedStage: "audit-write", diff --git a/src/lib/domain/recovery-records.ts b/src/lib/domain/recovery-records.ts index d380720..23b7f82 100644 --- a/src/lib/domain/recovery-records.ts +++ b/src/lib/domain/recovery-records.ts @@ -3,10 +3,12 @@ import type { ContinuityRecoveryRecord, ContinuityRecoveryFailedStage, MemoryScope, + SessionContinuityAuditTrigger, SessionContinuityDiagnostics, SessionContinuityEvidenceCounts, SessionContinuityFallbackReason, SessionContinuityScope, + SessionContinuityWriteMode, SyncRecoveryFailedStage, SyncRecoveryRecord } from "../types.js"; @@ -27,6 +29,19 @@ function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === "string"); } +function isContinuityTrigger(value: unknown): value is SessionContinuityAuditTrigger { + return ( + value === undefined || + value === "manual-save" || + value === "manual-refresh" || + value === "wrapper-auto-save" + ); +} + +function isWriteMode(value: unknown): value is SessionContinuityWriteMode { + return value === undefined || value === "merge" || value === "replace"; +} + function isEvidenceCounts(value: unknown): value is SessionContinuityEvidenceCounts { if (!value || typeof value !== "object") { return false; @@ -103,6 +118,8 @@ export function isContinuityRecoveryRecord( typeof record.worktreeId === "string" && typeof record.rolloutPath === "string" && typeof record.sourceSessionId === "string" && + isContinuityTrigger(record.trigger) && + isWriteMode(record.writeMode) && (record.scope === "project" || record.scope === "project-local" || record.scope === "both") && isStringArray(record.writtenPaths) && isExtractorPath(record.preferredPath) && @@ -179,6 +196,8 @@ interface BuildContinuityRecoveryRecordOptions { projectId: string; worktreeId: string; diagnostics: SessionContinuityDiagnostics; + trigger?: SessionContinuityAuditTrigger; + writeMode?: SessionContinuityWriteMode; scope: SessionContinuityScope | "both"; writtenPaths: string[]; failedStage: ContinuityRecoveryFailedStage; @@ -194,6 +213,8 @@ export function buildContinuityRecoveryRecord( worktreeId: options.worktreeId, rolloutPath: options.diagnostics.rolloutPath, sourceSessionId: options.diagnostics.sourceSessionId, + trigger: options.trigger, + writeMode: options.writeMode, scope: options.scope, writtenPaths: options.writtenPaths, preferredPath: options.diagnostics.preferredPath, diff --git a/src/lib/domain/rollout.ts b/src/lib/domain/rollout.ts index 0cbe879..a04dfce 100644 --- a/src/lib/domain/rollout.ts +++ b/src/lib/domain/rollout.ts @@ -8,6 +8,14 @@ interface JsonLine { payload?: Record; } +interface ParsedSessionMeta { + sessionId: string; + createdAt: string; + cwd: string; + isSubagent: boolean; + forkedFromSessionId?: string; +} + async function normalizeFsPath(input: string): Promise { try { return await fs.realpath(input); @@ -66,11 +74,51 @@ function sessionMetaValue( payload: Record, key: "id" | "cwd" | "timestamp" ): string { - const nested = payload.meta; - const nestedRecord = nested && typeof nested === "object" ? (nested as Record) : undefined; + const nestedRecord = sessionMetaRecord(payload); return String(payload[key] ?? nestedRecord?.[key] ?? ""); } +function sessionMetaRecord( + payload: Record +): Record | undefined { + const nested = payload.meta; + return nested && typeof nested === "object" ? (nested as Record) : undefined; +} + +function isSubagentSource(value: unknown): boolean { + return Boolean(value && typeof value === "object" && "subagent" in (value as Record)); +} + +function parseSessionMeta(payload: Record): ParsedSessionMeta | null { + const sessionId = sessionMetaValue(payload, "id"); + const createdAt = sessionMetaValue(payload, "timestamp"); + const cwd = sessionMetaValue(payload, "cwd"); + if (!sessionId || !createdAt || !cwd) { + return null; + } + + const nestedRecord = sessionMetaRecord(payload); + const forkedFromValue = + payload.forked_from_id ?? nestedRecord?.forked_from_id; + const sourceValue = payload.source ?? nestedRecord?.source; + const forkedFromSessionId = + typeof forkedFromValue === "string" && forkedFromValue.length > 0 + ? forkedFromValue + : undefined; + + return { + sessionId, + createdAt, + cwd, + isSubagent: Boolean(forkedFromSessionId) || isSubagentSource(sourceValue), + forkedFromSessionId + }; +} + +function isPrimaryRolloutMeta(meta: RolloutMeta): boolean { + return meta.isSubagent !== true; +} + export async function readRolloutMeta(filePath: string): Promise { const raw = await fs.readFile(filePath, "utf8"); const lines = raw @@ -90,20 +138,19 @@ export async function readRolloutMeta(filePath: string): Promise !beforeSet.has(filePath)); - const candidates = additions.length > 0 ? additions : after; - const metas = ( - await Promise.all( - candidates.map(async (filePath) => ({ - filePath, - meta: await readRolloutMeta(filePath) - })) - ) - ) - .filter( - (item): item is { filePath: string; meta: RolloutMeta } => - item.meta !== null && matchesProjectContext(item.meta, project) + const readMetas = async (files: string[]): Promise => + ( + await Promise.all( + files.map(async (filePath) => ({ + filePath, + meta: await readRolloutMeta(filePath) + })) + ) ) - .map((item) => item.meta); - - if (additions.length > 0) { - return metas + .filter( + (item): item is { filePath: string; meta: RolloutMeta } => + item.meta !== null && matchesProjectContext(item.meta, project) + ) + .map((item) => item.meta); + + const additionMetas = await readMetas(additions); + if (additionMetas.length > 0) { + return additionMetas .sort((left, right) => left.createdAtMs - right.createdAtMs) .map((meta) => meta.rolloutPath); } + const metas = await readMetas(after); + const windowStart = startedAtMs - 5_000; const windowEnd = endedAtMs + 5_000; const inWindow = metas @@ -177,7 +227,9 @@ export async function findLatestProjectRollout( ) .filter( (item): item is { filePath: string; meta: RolloutMeta } => - item.meta !== null && matchesProjectContext(item.meta, project) + item.meta !== null && + matchesProjectContext(item.meta, project) && + isPrimaryRolloutMeta(item.meta) ) .map((item) => item.meta) .sort((left, right) => right.createdAtMs - left.createdAtMs); @@ -199,6 +251,9 @@ export async function parseRolloutEvidence(filePath: string): Promise { - const written: string[] = []; - const targets = - scope === "both" ? (["project", "project-local"] satisfies SessionContinuityScope[]) : [scope]; - - for (const target of targets) { - if (target === "project") { - await this.ensureSharedLayout(); - } else { - await this.ensureLocalLayout(); - await this.ensureLocalIgnore(); - } - - const existing = await this.readState(target); - const base = - existing ?? createEmptySessionContinuityState(target, this.project.projectId, this.project.worktreeId); - const nextLayerSummary = - target === "project" ? summary.project : summary.projectLocal; - const nextState = applySessionContinuityLayerSummary( - base, - nextLayerSummary, - summary.sourceSessionId - ); - const filePath = - target === "project" ? this.paths.sharedFile : await this.resolveLocalWritePath(); - await writeTextFile(filePath, renderSessionContinuity(nextState)); - written.push(filePath); - } + return this.writeSummary(summary, scope, "merge"); + } - return written; + public async replaceSummary( + summary: SessionContinuitySummary, + scope: SessionContinuityScope | "both" + ): Promise { + return this.writeSummary(summary, scope, "replace"); } public async clear(scope: SessionContinuityScope | "both"): Promise { @@ -239,31 +220,65 @@ export class SessionContinuityStore { } public async readRecentAuditEntries(limit = 5): Promise { - if (!(await fileExists(this.paths.auditFile))) { - return []; - } - - const raw = await readTextFile(this.paths.auditFile); - return raw - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .flatMap((line) => { - try { - const parsed = JSON.parse(line) as unknown; - return isSessionContinuityAuditEntry(parsed) ? [parsed] : []; - } catch { - return []; - } - }) - .slice(-limit) - .reverse(); + const entries = await this.readAuditEntries(); + return entries.slice(-limit).reverse(); } public async readLatestAuditEntry(): Promise { return (await this.readRecentAuditEntries(1))[0] ?? null; } + public async readLatestAuditEntryMatchingScope( + scope: SessionContinuityScope | "both" + ): Promise { + const entries = await this.readAuditEntries(); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry?.scope === scope) { + return entry; + } + } + + return null; + } + + private async writeSummary( + summary: SessionContinuitySummary, + scope: SessionContinuityScope | "both", + writeMode: SessionContinuityWriteMode + ): Promise { + const written: string[] = []; + const targets = + scope === "both" ? (["project", "project-local"] satisfies SessionContinuityScope[]) : [scope]; + + for (const target of targets) { + if (target === "project") { + await this.ensureSharedLayout(); + } else { + await this.ensureLocalLayout(); + await this.ensureLocalIgnore(); + } + + const existing = await this.readState(target); + const base = + existing ?? createEmptySessionContinuityState(target, this.project.projectId, this.project.worktreeId); + const nextLayerSummary = + target === "project" ? summary.project : summary.projectLocal; + const nextState = + writeMode === "replace" + ? replaceSessionContinuityLayerSummary(base, nextLayerSummary, summary.sourceSessionId) + : applySessionContinuityLayerSummary(base, nextLayerSummary, summary.sourceSessionId); + const filePath = + target === "project" + ? this.paths.sharedFile + : await this.resolveLocalWritePath(writeMode); + await writeTextFile(filePath, renderSessionContinuity(nextState)); + written.push(filePath); + } + + return written; + } + private async resolveLocalReadPath(): Promise { if (this.config.sessionContinuityLocalPathStyle !== "claude") { return (await fileExists(this.paths.localFile)) ? this.paths.localFile : null; @@ -289,12 +304,38 @@ export class SessionContinuityStore { return entries[0]?.candidate ?? null; } - private async resolveLocalWritePath(): Promise { + private async readAuditEntries(): Promise { + if (!(await fileExists(this.paths.auditFile))) { + return []; + } + + const raw = await readTextFile(this.paths.auditFile); + return raw + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .flatMap((line) => { + try { + const parsed = JSON.parse(line) as unknown; + return isSessionContinuityAuditEntry(parsed) ? [parsed] : []; + } catch { + return []; + } + }); + } + + private async resolveLocalWritePath( + writeMode: SessionContinuityWriteMode = "merge" + ): Promise { if (this.config.sessionContinuityLocalPathStyle !== "claude") { return this.paths.localFile; } await this.ensureLocalLayout(); + if (writeMode === "replace") { + return (await this.resolveLocalReadPath()) ?? this.paths.localFile; + } + return this.paths.localFile; } } diff --git a/src/lib/domain/session-continuity.ts b/src/lib/domain/session-continuity.ts index 64d362f..65a646a 100644 --- a/src/lib/domain/session-continuity.ts +++ b/src/lib/domain/session-continuity.ts @@ -348,6 +348,26 @@ export function applySessionContinuityLayerSummary( }; } +export function replaceSessionContinuityLayerSummary( + base: SessionContinuityState, + summary: SessionContinuityLayerSummary, + sourceSessionId?: string +): SessionContinuityState { + const sanitized = sanitizeSessionContinuityLayerSummary(summary); + return { + ...base, + updatedAt: new Date().toISOString(), + status: "active", + sourceSessionId: sourceSessionId ?? base.sourceSessionId, + goal: sanitized.goal, + confirmedWorking: sanitized.confirmedWorking, + triedAndFailed: sanitized.triedAndFailed, + notYetTried: sanitized.notYetTried, + incompleteNext: sanitized.incompleteNext, + filesDecisionsEnvironment: sanitized.filesDecisionsEnvironment + }; +} + export function renderSessionContinuity(state: SessionContinuityState): string { const lines = [ "---", diff --git a/src/lib/extractor/heuristic-extractor.ts b/src/lib/extractor/heuristic-extractor.ts index f70dcd1..21c4475 100644 --- a/src/lib/extractor/heuristic-extractor.ts +++ b/src/lib/extractor/heuristic-extractor.ts @@ -125,19 +125,54 @@ function stripRememberPrefix(message: string): string { return message.replace(/^remember that\s+/iu, "").replace(/^记住/u, "").trim(); } +function isHighConfidenceExplicitCorrection(message: string): boolean { + return !/(?:\bmaybe\b|\bperhaps\b|\bif possible\b|\bwhen possible\b|\bfor now\b|\bprobably\b|\busually\b|\bsometimes\b|\btry\b|\bconsider\b|\bmight\b|\bcould\b|尽量|如果可以|可能|暂时)/iu.test( + message + ); +} + function extractExplicitCorrection(message: string): ExplicitCorrection | null { const trimmed = trimTrailingPunctuation(stripRememberPrefix(message)); + if (!isHighConfidenceExplicitCorrection(trimmed)) { + return null; + } + const patterns = [ - /^(?:actually\s+)?we use\s+(.+?),\s*not\s+(.+)$/iu, - /^(?:actually\s+)?use\s+(.+?),\s*not\s+(.+)$/iu, - /^not\s+(.+?),\s*(?:actually\s+)?use\s+(.+)$/iu, - /^(?:actually\s+)?use\s+(.+?)\s+instead of\s+(.+)$/iu, - /^我们用\s*(.+?)\s*[,,]\s*不用\s*(.+)$/u, - /^别用\s*(.+?)\s*[,,]\s*用\s*(.+)$/u, - /^实际上用\s*(.+?)\s*[,,]\s*不要用\s*(.+)$/u + { + pattern: /^(?:actually\s+)?we use\s+(.+?),\s*not\s+(.+)$/iu, + staleIndex: 2 + }, + { + pattern: /^(?:actually\s+)?use\s+(.+?),\s*not\s+(.+)$/iu, + staleIndex: 2 + }, + { + pattern: /^not\s+(.+?),\s*(?:actually\s+)?use\s+(.+)$/iu, + staleIndex: 1 + }, + { + pattern: /^(?:actually\s+)?use\s+(.+?)\s+instead of\s+(.+)$/iu, + staleIndex: 2 + }, + { + pattern: /^(?:actually\s+)?prefer\s+(.+?)\s+over\s+(.+)$/iu, + staleIndex: 2 + }, + { + pattern: /^我们用\s*(.+?)\s*[,,]\s*不用\s*(.+)$/u, + staleIndex: 2 + }, + { + pattern: /^(?:先\s*)?别用\s*(.+?)\s*[,,]\s*用\s*(.+)$/u, + staleIndex: 1 + }, + { + pattern: /^实际上用\s*(.+?)\s*[,,]\s*不要用\s*(.+)$/u, + staleIndex: 2 + } ] as const; - for (const pattern of patterns) { + for (const { pattern, staleIndex } of patterns) { const match = trimmed.match(pattern); if (!match?.[1] || !match?.[2]) { continue; @@ -150,9 +185,10 @@ function extractExplicitCorrection(message: string): ExplicitCorrection | null { return null; } - const staleText = pattern === patterns[2] || pattern === patterns[5] - ? match[1] - : match[2]; + const staleText = match[staleIndex]; + if (!staleText) { + continue; + } return { scope: inferScope(trimmed), diff --git a/src/lib/extractor/session-continuity-evidence.ts b/src/lib/extractor/session-continuity-evidence.ts index eacabe1..4e48eea 100644 --- a/src/lib/extractor/session-continuity-evidence.ts +++ b/src/lib/extractor/session-continuity-evidence.ts @@ -19,11 +19,57 @@ export const UNTRIED_PATTERNS = [ ]; export const NEXT_STEP_PATTERNS = [ - /(?:next step|follow up by|follow-up|remaining work|still need to|left to do|continue by|continue with|resume by|todo)\s*[:,-]?\s*(.+)/iu, + /(?:next step|follow up by|remaining work|still need to|left to do|continue by|continue with|resume by|todo)\s*[:,-]?\s*(.+)/iu, + /follow(?: |-)?up(?:\s+(?:by|with|on)|\s*[:,-])\s*(.+)/iu, /^\s*(?:we|you|i)?\s*need(?:s)?\s+to\s+(.+)/iu, /(?:下一步|接下来|还需要|继续|待完成|剩下的工作)\s*[::,-]?\s*(.+)/u ]; +const CONTINUITY_PROMPT_PATTERNS = [ + /\breviewer sub-agent\b/iu, + /\bwork read-only\b/iu, + /\bfiles to review\b/iu, + /\bprimary focus\b/iu, + /\breview dimensions\b/iu, + /\bforked workspace\b/iu, + /\bwrite scope\b/iu, + /\bdocs\/contract reviewer\b/iu, + /(?:子 ?agent|子线程|审查范围|分域|取证|只读工作|官方文档|平行审查|并行审查)/u +]; + +const PROGRESS_NARRATION_PATTERNS = [ + /^(?:i|we)\s+(?:will|am going to|plan to|want to|can now|need to do)\b/iu, + /^(?:我会|我们会|我先|我将|下面我会|现在我会|随后我会|最后我再|接下来我会|我会做)/u +]; + +function isPromptLikeContinuityMessage(text: string): boolean { + return CONTINUITY_PROMPT_PATTERNS.some((pattern) => pattern.test(text)); +} + +function isProgressNarration(text: string): boolean { + return PROGRESS_NARRATION_PATTERNS.some((pattern) => pattern.test(text)); +} + +function shouldRejectContinuityCapture(message: string, captured: string): boolean { + if (isPromptLikeContinuityMessage(message) || isPromptLikeContinuityMessage(captured)) { + return true; + } + + if (isProgressNarration(captured)) { + return true; + } + + if ( + /(?:按你要求跑完整校验命令|分域取证|审查计划|reviewer 线程|根据官方文档对代码进行审查|实现功能,而是)/u.test( + captured + ) + ) { + return true; + } + + return false; +} + export interface SessionContinuityEvidenceBuckets { recentSuccessfulCommands: string[]; recentFailedCommands: string[]; @@ -69,6 +115,7 @@ export function extractPatternMatches( const captured = normalizeMessage(match[1]); if (captured.length < 10) continue; if (/^(?:而是|但是|因为|所以|不过|然后|其实|就是|也就是说)/u.test(captured)) continue; + if (shouldRejectContinuityCapture(message, captured)) continue; matches.push(captured); break; } diff --git a/src/lib/types.ts b/src/lib/types.ts index cb4d3fd..debad4d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,6 +1,11 @@ export type MemoryScope = "global" | "project" | "project-local"; export type SessionContinuityScope = "project" | "project-local"; export type SessionContinuityLocalPathStyle = "codex" | "claude"; +export type SessionContinuityWriteMode = "merge" | "replace"; +export type SessionContinuityAuditTrigger = + | "manual-save" + | "manual-refresh" + | "wrapper-auto-save"; export interface MemoryEntry { id: string; @@ -103,6 +108,8 @@ export interface RolloutMeta { createdAtMs: number; cwd: string; rolloutPath: string; + isSubagent?: boolean; + forkedFromSessionId?: string; } export interface RolloutEvidence { @@ -113,6 +120,8 @@ export interface RolloutEvidence { agentMessages: string[]; toolCalls: RolloutToolCall[]; rolloutPath: string; + isSubagent?: boolean; + forkedFromSessionId?: string; } export interface SessionContinuityState { @@ -184,6 +193,8 @@ export interface SessionContinuityAuditEntry { projectId: string; worktreeId: string; configuredExtractorMode: SessionContinuityExtractorPath; + trigger?: SessionContinuityAuditTrigger; + writeMode?: SessionContinuityWriteMode; scope: SessionContinuityScope | "both"; rolloutPath: string; sourceSessionId: string; @@ -331,6 +342,8 @@ export interface ContinuityRecoveryRecord { worktreeId: string; rolloutPath: string; sourceSessionId: string; + trigger?: SessionContinuityAuditTrigger; + writeMode?: SessionContinuityWriteMode; scope: SessionContinuityScope | "both"; writtenPaths: string[]; preferredPath: SessionContinuityExtractorPath; diff --git a/test/extractor.test.ts b/test/extractor.test.ts index a57f27b..31991cc 100644 --- a/test/extractor.test.ts +++ b/test/extractor.test.ts @@ -90,6 +90,41 @@ describe("HeuristicExtractor", () => { ).toBe(true); }); + it("treats direct Chinese corrections with 先别用... as high-confidence replacements", async () => { + const extractor = new HeuristicExtractor(); + const existingEntries: MemoryEntry[] = [ + { + id: "use-npm", + scope: "project", + topic: "preferences", + summary: "Use npm in this repository.", + details: ["Use npm instead of pnpm in this repo."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]; + + const operations = await extractor.extract( + baseEvidence({ + userMessages: ["先别用 npm,用 pnpm。"] + }), + existingEntries + ); + + expect( + operations.some( + (operation) => operation.action === "delete" && operation.id === "use-npm" + ) + ).toBe(true); + expect( + operations.some( + (operation) => + operation.action === "upsert" && + operation.summary?.includes("先别用 npm,用 pnpm") + ) + ).toBe(true); + }); + it("does not save commands with no output (output undefined)", async () => { const extractor = new HeuristicExtractor(); const operations = await extractor.extract( @@ -236,6 +271,41 @@ describe("HeuristicExtractor", () => { ).toBe(true); }); + it("deletes stale preferences after a prefer-over correction rollout", async () => { + const extractor = new HeuristicExtractor(); + const evidence = await parseRolloutEvidence( + path.join(process.cwd(), "test/fixtures/rollouts/preferences-prefer-over-correction.jsonl") + ); + + expect(evidence).not.toBeNull(); + + const operations = await extractor.extract(evidence!, [ + { + id: "use-npm", + scope: "project", + topic: "preferences", + summary: "Use npm in this repository.", + details: ["Use npm instead of pnpm in this repository."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]); + + expect( + operations.some( + (operation) => operation.action === "delete" && operation.id === "use-npm" + ) + ).toBe(true); + expect( + operations.some( + (operation) => + operation.action === "upsert" && + operation.topic === "preferences" && + operation.summary?.includes("Prefer pnpm over npm") + ) + ).toBe(true); + }); + it("deletes stale workflow notes after an explicit correction rollout", async () => { const extractor = new HeuristicExtractor(); const evidence = await parseRolloutEvidence( @@ -271,6 +341,33 @@ describe("HeuristicExtractor", () => { ).toBe(true); }); + it("does not delete stale preferences for hedged prefer-over wording", async () => { + const extractor = new HeuristicExtractor(); + const evidence = await parseRolloutEvidence( + path.join( + process.cwd(), + "test/fixtures/rollouts/ambiguous-preferences-prefer-over-correction.jsonl" + ) + ); + + expect(evidence).not.toBeNull(); + + const operations = await extractor.extract(evidence!, [ + { + id: "use-npm", + scope: "project", + topic: "preferences", + summary: "Use npm in this repository.", + details: ["Use npm instead of pnpm in this repository."], + updatedAt: "2026-03-14T00:00:00.000Z", + sources: ["old"] + } + ]); + + expect(operations.some((operation) => operation.action === "delete")).toBe(false); + expect(operations.some((operation) => operation.action === "upsert")).toBe(false); + }); + it("keeps stale preferences when an explicit correction is ambiguous", async () => { const extractor = new HeuristicExtractor(); const evidence = await parseRolloutEvidence( diff --git a/test/fixtures/rollouts/ambiguous-preferences-prefer-over-correction.jsonl b/test/fixtures/rollouts/ambiguous-preferences-prefer-over-correction.jsonl new file mode 100644 index 0000000..2781303 --- /dev/null +++ b/test/fixtures/rollouts/ambiguous-preferences-prefer-over-correction.jsonl @@ -0,0 +1,2 @@ +{"type":"session_meta","payload":{"id":"fixture-ambiguous-preferences-prefer-over-correction","timestamp":"2026-03-19T00:00:00.000Z","cwd":"/tmp/project"}} +{"type":"event_msg","payload":{"type":"user_message","message":"Prefer pnpm over npm in this repository when possible."}} diff --git a/test/fixtures/rollouts/preferences-prefer-over-correction.jsonl b/test/fixtures/rollouts/preferences-prefer-over-correction.jsonl new file mode 100644 index 0000000..d39e855 --- /dev/null +++ b/test/fixtures/rollouts/preferences-prefer-over-correction.jsonl @@ -0,0 +1,2 @@ +{"type":"session_meta","payload":{"id":"fixture-preferences-prefer-over-correction","timestamp":"2026-03-19T00:00:00.000Z","cwd":"/tmp/project"}} +{"type":"event_msg","payload":{"type":"user_message","message":"Prefer pnpm over npm in this repository."}} diff --git a/test/helpers/cam-test-fixtures.ts b/test/helpers/cam-test-fixtures.ts new file mode 100644 index 0000000..c16d6ee --- /dev/null +++ b/test/helpers/cam-test-fixtures.ts @@ -0,0 +1,97 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { runCommandCapture } from "../../src/lib/util/process.js"; +import type { AppConfig } from "../../src/lib/types.js"; + +export function makeAppConfig(overrides: Partial = {}): AppConfig { + return { + autoMemoryEnabled: true, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex", + ...overrides + }; +} + +export async function initGitRepo(repoDir: string): Promise { + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: "Codex Auto Memory", + GIT_AUTHOR_EMAIL: "cam@example.com", + GIT_COMMITTER_NAME: "Codex Auto Memory", + GIT_COMMITTER_EMAIL: "cam@example.com" + }; + runCommandCapture("git", ["init", "-b", "main"], repoDir, gitEnv); + await fs.writeFile(path.join(repoDir, "README.md"), "seed\n", "utf8"); + runCommandCapture("git", ["add", "README.md"], repoDir, gitEnv); + runCommandCapture("git", ["commit", "-m", "init"], repoDir, gitEnv); +} + +export async function writeCamConfig( + repoDir: string, + projectConfig: AppConfig | Record, + localConfig: Record +): Promise { + await fs.writeFile( + path.join(repoDir, "codex-auto-memory.json"), + JSON.stringify(projectConfig, null, 2), + "utf8" + ); + await fs.writeFile( + path.join(repoDir, ".codex-auto-memory.local.json"), + JSON.stringify(localConfig, null, 2), + "utf8" + ); +} + +interface RolloutFixtureOptions { + sessionId?: string; + timestamp?: string; + callOutput?: string; +} + +export function makeRolloutFixture( + projectDir: string, + message: string, + options: RolloutFixtureOptions = {} +): string { + return [ + JSON.stringify({ + type: "session_meta", + payload: { + id: options.sessionId ?? "session-1", + timestamp: options.timestamp ?? "2026-03-15T00:00:00.000Z", + cwd: projectDir + } + }), + JSON.stringify({ + type: "event_msg", + payload: { + type: "user_message", + message + } + }), + JSON.stringify({ + type: "response_item", + payload: { + type: "function_call", + name: "exec_command", + call_id: "call-1", + arguments: "{\"cmd\":\"pnpm test\"}" + } + }), + JSON.stringify({ + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call-1", + output: options.callOutput ?? "Process exited with code 0" + } + }) + ].join("\n"); +} diff --git a/test/memory-command.test.ts b/test/memory-command.test.ts index 2e6ba0e..287adf5 100644 --- a/test/memory-command.test.ts +++ b/test/memory-command.test.ts @@ -7,6 +7,10 @@ import { configPaths } from "../src/lib/config/load-config.js"; import { detectProjectContext } from "../src/lib/domain/project-context.js"; import { MemoryStore } from "../src/lib/domain/memory-store.js"; import type { AppConfig, MemoryCommandOutput } from "../src/lib/types.js"; +import { + makeAppConfig, + writeCamConfig +} from "./helpers/cam-test-fixtures.js"; const tempDirs: string[] = []; const originalHome = process.env.HOME; @@ -22,6 +26,9 @@ afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); +const buildProjectConfig = makeAppConfig; +const writeProjectConfig = writeCamConfig; + describe("runMemory", () => { it("shows scope details and recent audit entries", async () => { const homeDir = await tempDir("cam-memory-home-"); @@ -29,29 +36,10 @@ describe("runMemory", () => { const memoryRoot = await tempDir("cam-memory-root-"); process.env.HOME = homeDir; - const projectConfig: AppConfig = { - autoMemoryEnabled: true, - extractorMode: "heuristic", - defaultScope: "project", - maxStartupLines: 200, - sessionContinuityAutoLoad: false, - sessionContinuityAutoSave: false, - sessionContinuityLocalPathStyle: "codex", - maxSessionContinuityLines: 60, - codexBinary: "codex" - }; - await fs.writeFile( - path.join(projectDir, "codex-auto-memory.json"), - JSON.stringify(projectConfig), - "utf8" - ); - await fs.writeFile( - path.join(projectDir, ".codex-auto-memory.local.json"), - JSON.stringify({ - autoMemoryDirectory: memoryRoot - }), - "utf8" - ); + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); const project = detectProjectContext(projectDir); const store = new MemoryStore(project, { @@ -143,6 +131,12 @@ describe("runMemory", () => { }); expect(output).toContain("Startup budget:"); + expect(output).toContain( + "Startup loaded files are the index files actually quoted into the current startup payload." + ); + expect(output).toContain( + "Topic files on demand stay as references until a later read needs them." + ); expect(output).toContain("Edit paths:"); expect(output).toContain("project: 1 entry"); expect(output).toContain("Topics: workflow"); @@ -167,29 +161,10 @@ describe("runMemory", () => { const memoryRoot = await tempDir("cam-memory-json-root-"); process.env.HOME = homeDir; - const projectConfig: AppConfig = { - autoMemoryEnabled: true, - extractorMode: "heuristic", - defaultScope: "project", - maxStartupLines: 200, - sessionContinuityAutoLoad: false, - sessionContinuityAutoSave: false, - sessionContinuityLocalPathStyle: "codex", - maxSessionContinuityLines: 60, - codexBinary: "codex" - }; - await fs.writeFile( - path.join(projectDir, "codex-auto-memory.json"), - JSON.stringify(projectConfig), - "utf8" - ); - await fs.writeFile( - path.join(projectDir, ".codex-auto-memory.local.json"), - JSON.stringify({ - autoMemoryDirectory: memoryRoot - }), - "utf8" - ); + const projectConfig = buildProjectConfig(); + await writeProjectConfig(projectDir, projectConfig, { + autoMemoryDirectory: memoryRoot + }); const project = detectProjectContext(projectDir); const store = new MemoryStore(project, { diff --git a/test/rollout.test.ts b/test/rollout.test.ts index 5a205b6..9b4c3b7 100644 --- a/test/rollout.test.ts +++ b/test/rollout.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { detectProjectContext } from "../src/lib/domain/project-context.js"; import { + findLatestProjectRollout, findRelevantRollouts, matchesProjectContext, parseRolloutEvidence, @@ -131,6 +132,105 @@ describe("rollout helpers", () => { expect(evidence?.toolCalls[1]?.output).toBe("test output"); }); + it("keeps the first valid session_meta as rollout identity even when later meta lines exist", async () => { + const projectDir = await tempDir("cam-rollout-subagent-project-"); + const rolloutPath = path.join(projectDir, "rollout-subagent.jsonl"); + await fs.writeFile( + rolloutPath, + [ + JSON.stringify({ + type: "session_meta", + payload: { + id: "session-subagent", + forked_from_id: "session-parent", + timestamp: "2026-03-14T00:00:02.000Z", + cwd: projectDir, + source: { + subagent: { + thread_spawn: { + parent_thread_id: "session-parent" + } + } + } + } + }), + JSON.stringify({ + type: "session_meta", + payload: { + id: "session-parent", + timestamp: "2026-03-14T00:00:01.000Z", + cwd: projectDir, + source: "cli" + } + }), + JSON.stringify({ + type: "event_msg", + payload: { + type: "user_message", + message: "Reviewer prompt that should stay attached to the subagent rollout." + } + }) + ].join("\n"), + "utf8" + ); + + const meta = await readRolloutMeta(rolloutPath); + const evidence = await parseRolloutEvidence(rolloutPath); + + expect(meta?.sessionId).toBe("session-subagent"); + expect(meta?.isSubagent).toBe(true); + expect(meta?.forkedFromSessionId).toBe("session-parent"); + expect(evidence?.sessionId).toBe("session-subagent"); + expect(evidence?.isSubagent).toBe(true); + expect(evidence?.forkedFromSessionId).toBe("session-parent"); + }); + + it("skips invalid session_meta entries and still detects nested subagent meta", async () => { + const projectDir = await tempDir("cam-rollout-nested-subagent-project-"); + const rolloutPath = path.join(projectDir, "rollout-nested-subagent.jsonl"); + await fs.writeFile( + rolloutPath, + [ + JSON.stringify({ + type: "session_meta", + payload: { + id: "broken-meta", + timestamp: "2026-03-14T00:00:00.000Z" + } + }), + JSON.stringify({ + type: "session_meta", + payload: { + meta: { + id: "session-nested-subagent", + forked_from_id: "session-parent", + timestamp: "2026-03-14T00:00:02.000Z", + cwd: projectDir, + source: { + subagent: { + thread_spawn: { + parent_thread_id: "session-parent" + } + } + } + } + } + }) + ].join("\n"), + "utf8" + ); + + const meta = await readRolloutMeta(rolloutPath); + const evidence = await parseRolloutEvidence(rolloutPath); + + expect(meta?.sessionId).toBe("session-nested-subagent"); + expect(meta?.isSubagent).toBe(true); + expect(meta?.forkedFromSessionId).toBe("session-parent"); + expect(evidence?.sessionId).toBe("session-nested-subagent"); + expect(evidence?.isSubagent).toBe(true); + expect(evidence?.forkedFromSessionId).toBe("session-parent"); + }); + it("does not match sibling directory", () => { const ctx = { cwd: "/foo/bar", projectRoot: "/foo/bar", projectId: "p", worktreeId: "w" }; expect(matchesProjectContext({ cwd: "/foo/bar-extra" }, ctx)).toBe(false); @@ -176,6 +276,7 @@ describe("rollout helpers", () => { const beforeFile = path.join(dayDir, "rollout-before.jsonl"); const addedFile = path.join(dayDir, "rollout-added.jsonl"); + const subagentFile = path.join(dayDir, "rollout-subagent.jsonl"); const staleFile = path.join(dayDir, "rollout-stale.jsonl"); await fs.writeFile( @@ -217,6 +318,26 @@ describe("rollout helpers", () => { }), "utf8" ); + await fs.writeFile( + subagentFile, + JSON.stringify({ + type: "session_meta", + payload: { + id: "subagent", + forked_from_id: "parent", + timestamp: "2026-03-14T00:00:04.000Z", + cwd: projectDir, + source: { + subagent: { + thread_spawn: { + parent_thread_id: "parent" + } + } + } + } + }), + "utf8" + ); const project = detectProjectContext(projectDir); const relevantFromAdditions = await findRelevantRollouts( @@ -225,11 +346,11 @@ describe("rollout helpers", () => { Date.parse("2026-03-14T00:00:00.000Z"), Date.parse("2026-03-14T00:00:05.000Z") ); - expect(relevantFromAdditions).toEqual([addedFile]); + expect(relevantFromAdditions).toEqual([addedFile, subagentFile]); const relevantFromWindow = await findRelevantRollouts( project, - [beforeFile, staleFile, addedFile], + [beforeFile, staleFile, addedFile, subagentFile], Date.parse("2026-03-14T00:09:58.000Z"), Date.parse("2026-03-14T00:10:02.000Z") ); @@ -239,4 +360,51 @@ describe("rollout helpers", () => { expect(meta?.sessionId).toBe("added"); expect(meta?.cwd).toBe(realFs.realpathSync.native(projectDir)); }); + + it("skips subagent rollouts when auto-selecting the latest project rollout", async () => { + const sessionsDir = await tempDir("cam-sessions-latest-"); + const dayDir = path.join(sessionsDir, "2026", "03", "14"); + const projectDir = await tempDir("cam-rollout-latest-project-"); + await fs.mkdir(dayDir, { recursive: true }); + process.env.CAM_CODEX_SESSIONS_DIR = sessionsDir; + + const primaryFile = path.join(dayDir, "rollout-primary.jsonl"); + const subagentFile = path.join(dayDir, "rollout-subagent.jsonl"); + await fs.writeFile( + primaryFile, + JSON.stringify({ + type: "session_meta", + payload: { + id: "primary", + timestamp: "2026-03-14T00:00:01.000Z", + cwd: projectDir, + source: "cli" + } + }), + "utf8" + ); + await fs.writeFile( + subagentFile, + JSON.stringify({ + type: "session_meta", + payload: { + id: "subagent", + forked_from_id: "primary", + timestamp: "2026-03-14T00:00:02.000Z", + cwd: projectDir, + source: { + subagent: { + thread_spawn: { + parent_thread_id: "primary" + } + } + } + } + }), + "utf8" + ); + + const latest = await findLatestProjectRollout(detectProjectContext(projectDir)); + expect(latest).toBe(primaryFile); + }); }); diff --git a/test/session-command.test.ts b/test/session-command.test.ts index c6cdc09..49fba85 100644 --- a/test/session-command.test.ts +++ b/test/session-command.test.ts @@ -8,7 +8,13 @@ import { detectProjectContext } from "../src/lib/domain/project-context.js"; import { SessionContinuityStore } from "../src/lib/domain/session-continuity-store.js"; import { SyncService } from "../src/lib/domain/sync-service.js"; import { runCommandCapture } from "../src/lib/util/process.js"; -import type { AppConfig, SessionContinuityAuditEntry } from "../src/lib/types.js"; +import type { SessionContinuityAuditEntry } from "../src/lib/types.js"; +import { + initGitRepo, + makeAppConfig, + makeRolloutFixture, + writeCamConfig +} from "./helpers/cam-test-fixtures.js"; const tempDirs: string[] = []; const originalSessionsDir = process.env.CAM_CODEX_SESSIONS_DIR; @@ -23,91 +29,24 @@ async function tempDir(prefix: string): Promise { return dir; } -async function initRepo(repoDir: string): Promise { - const gitEnv = { - ...process.env, - GIT_AUTHOR_NAME: "Codex Auto Memory", - GIT_AUTHOR_EMAIL: "cam@example.com", - GIT_COMMITTER_NAME: "Codex Auto Memory", - GIT_COMMITTER_EMAIL: "cam@example.com" - }; - runCommandCapture("git", ["init", "-b", "main"], repoDir, gitEnv); - await fs.writeFile(path.join(repoDir, "README.md"), "seed\n", "utf8"); - runCommandCapture("git", ["add", "README.md"], repoDir, gitEnv); - runCommandCapture("git", ["commit", "-m", "init"], repoDir, gitEnv); -} - -function configJson(overrides: Partial = {}): AppConfig { - return { - autoMemoryEnabled: true, - extractorMode: "heuristic", - defaultScope: "project", - maxStartupLines: 200, - sessionContinuityAutoLoad: false, - sessionContinuityAutoSave: false, - sessionContinuityLocalPathStyle: "codex", - maxSessionContinuityLines: 60, - codexBinary: "codex", - ...overrides - }; -} +const initRepo = initGitRepo; +const configJson = makeAppConfig; function runCli(repoDir: string, args: string[]) { return runCommandCapture(tsxBinaryPath, [sourceCliPath, ...args], repoDir); } -async function writeProjectConfig( - repoDir: string, - projectConfig: AppConfig, - localConfig: Record -): Promise { - await fs.writeFile( - path.join(repoDir, "codex-auto-memory.json"), - JSON.stringify(projectConfig, null, 2), - "utf8" - ); - await fs.writeFile( - path.join(repoDir, ".codex-auto-memory.local.json"), - JSON.stringify(localConfig, null, 2), - "utf8" - ); -} +const writeProjectConfig = writeCamConfig; +const rolloutFixture = makeRolloutFixture; -function rolloutFixture(projectDir: string, message: string): string { - return [ - JSON.stringify({ - type: "session_meta", - payload: { - id: "session-1", - timestamp: "2026-03-15T00:00:00.000Z", - cwd: projectDir - } - }), - JSON.stringify({ - type: "event_msg", - payload: { - type: "user_message", - message - } - }), - JSON.stringify({ - type: "response_item", - payload: { - type: "function_call", - name: "exec_command", - call_id: "call-1", - arguments: "{\"cmd\":\"pnpm test\"}" - } - }), - JSON.stringify({ - type: "response_item", - payload: { - type: "function_call_output", - call_id: "call-1", - output: "Process exited with code 0" - } - }) - ].join("\n"); +function makeEvidenceCounts(successfulCommands = 1) { + return { + successfulCommands, + failedCommands: 0, + fileWrites: 0, + nextSteps: 1, + untried: 0 + }; } async function writeWrapperMockCodex( @@ -303,6 +242,12 @@ describe("runSession", () => { const loadOutput = await runSession("load", { cwd: repoDir }); expect(loadOutput).toContain("Evidence: successful"); expect(loadOutput).toContain("Written paths:"); + expect(loadOutput).toContain( + "Merged resume brief combines shared continuity with any project-local overrides." + ); + expect(loadOutput).toContain( + "Recent prior generations below are compact audit previews, not startup-injected history." + ); expect(loadOutput).toContain("Recent prior generations:"); expect(loadOutput).toContain(rolloutPath); expect( @@ -332,6 +277,12 @@ describe("runSession", () => { const statusOutput = await runSession("status", { cwd: repoDir }); expect(statusOutput).toContain("Evidence: successful"); expect(statusOutput).toContain("Written paths:"); + expect(statusOutput).toContain( + "Merged resume brief combines shared continuity with any project-local overrides." + ); + expect(statusOutput).toContain( + "Recent prior generations below are compact audit previews, not startup-injected history." + ); expect(statusOutput).toContain("Recent prior generations:"); expect(statusOutput).toContain(rolloutPath); expect( @@ -382,6 +333,515 @@ describe("runSession", () => { expect(payload.recentContinuityAuditEntries[0]?.rolloutPath).toBe(rolloutPath); }, 30_000); + it("supports session refresh --json from the CLI command surface and replaces polluted continuity", async () => { + const repoDir = await tempDir("cam-session-refresh-cli-repo-"); + const memoryRoot = await tempDir("cam-session-refresh-cli-memory-"); + await initRepo(repoDir); + + await writeProjectConfig( + repoDir, + configJson(), + { autoMemoryDirectory: memoryRoot } + ); + + const store = new SessionContinuityStore(detectProjectContext(repoDir), { + ...configJson(), + autoMemoryDirectory: memoryRoot + }); + await store.saveSummary( + { + project: { + goal: "Stale shared goal", + confirmedWorking: ["Stale shared success"], + triedAndFailed: ["Stale shared failure"], + notYetTried: ["Stale shared idea"], + incompleteNext: [], + filesDecisionsEnvironment: ["Stale shared note"] + }, + projectLocal: { + goal: "", + confirmedWorking: [], + triedAndFailed: [], + notYetTried: [], + incompleteNext: ["Stale local next step"], + filesDecisionsEnvironment: ["Stale local file note"] + } + }, + "both" + ); + + const rolloutPath = path.join(repoDir, "refresh-rollout.jsonl"); + await fs.writeFile( + rolloutPath, + rolloutFixture(repoDir, "Refresh continuity through the CLI command surface."), + "utf8" + ); + + const result = runCli(repoDir, ["session", "refresh", "--json", "--rollout", rolloutPath]); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout) as { + action: string; + writeMode: string; + rolloutPath: string; + rolloutSelection: { kind: string; rolloutPath: string }; + latestContinuityAuditEntry: { + rolloutPath: string; + trigger?: string; + writeMode?: string; + } | null; + }; + expect(payload.action).toBe("refresh"); + expect(payload.writeMode).toBe("replace"); + expect(payload.rolloutPath).toBe(rolloutPath); + expect(payload.rolloutSelection).toEqual({ + kind: "explicit-rollout", + rolloutPath + }); + expect(payload.latestContinuityAuditEntry?.rolloutPath).toBe(rolloutPath); + expect(payload.latestContinuityAuditEntry?.trigger).toBe("manual-refresh"); + expect(payload.latestContinuityAuditEntry?.writeMode).toBe("replace"); + + const merged = await store.readMergedState(); + expect(merged?.goal).toContain("Refresh continuity through the CLI command surface."); + expect(merged?.goal).not.toContain("Stale shared goal"); + expect(merged?.confirmedWorking.join("\n")).not.toContain("Stale shared success"); + expect(merged?.incompleteNext.join("\n")).not.toContain("Stale local next step"); + expect(merged?.filesDecisionsEnvironment.join("\n")).not.toContain("Stale local file note"); + }, 30_000); + + it("refresh replaces only the selected scope", async () => { + const repoDir = await tempDir("cam-session-refresh-scope-repo-"); + const memoryRoot = await tempDir("cam-session-refresh-scope-memory-"); + await initRepo(repoDir); + + await writeProjectConfig( + repoDir, + configJson(), + { autoMemoryDirectory: memoryRoot } + ); + + const store = new SessionContinuityStore(detectProjectContext(repoDir), { + ...configJson(), + autoMemoryDirectory: memoryRoot + }); + await store.saveSummary( + { + project: { + goal: "Shared stale goal", + confirmedWorking: ["Shared stale success"], + triedAndFailed: [], + notYetTried: [], + incompleteNext: [], + filesDecisionsEnvironment: ["Shared stale note"] + }, + projectLocal: { + goal: "", + confirmedWorking: [], + triedAndFailed: [], + notYetTried: [], + incompleteNext: ["Local stale next"], + filesDecisionsEnvironment: ["Local stale file note"] + } + }, + "both" + ); + + const projectRolloutPath = path.join(repoDir, "project-refresh.jsonl"); + await fs.writeFile( + projectRolloutPath, + rolloutFixture(repoDir, "Refresh only the shared scope.", { + sessionId: "session-project-refresh" + }), + "utf8" + ); + await runSession("refresh", { + cwd: repoDir, + rollout: projectRolloutPath, + scope: "project" + }); + + const projectAfterRefresh = await store.readState("project"); + const localAfterProjectRefresh = await store.readState("project-local"); + expect(projectAfterRefresh?.goal).toContain("Refresh only the shared scope."); + expect(projectAfterRefresh?.goal).not.toContain("Shared stale goal"); + expect(localAfterProjectRefresh?.incompleteNext).toContain("Local stale next"); + + const localRolloutPath = path.join(repoDir, "local-refresh.jsonl"); + await fs.writeFile( + localRolloutPath, + rolloutFixture(repoDir, "Refresh only the local scope.", { + sessionId: "session-local-refresh" + }), + "utf8" + ); + await runSession("refresh", { + cwd: repoDir, + rollout: localRolloutPath, + scope: "project-local" + }); + + const projectAfterLocalRefresh = await store.readState("project"); + const localAfterRefresh = await store.readState("project-local"); + expect(projectAfterLocalRefresh?.goal).toContain("Refresh only the shared scope."); + expect(localAfterRefresh?.incompleteNext.join("\n")).not.toContain("Local stale next"); + expect(localAfterRefresh?.filesDecisionsEnvironment.join("\n")).not.toContain( + "Local stale file note" + ); + }, 30_000); + + it("refresh prefers a matching recovery marker over audit and latest primary rollout", async () => { + const repoDir = await tempDir("cam-session-refresh-recovery-priority-repo-"); + const memoryRoot = await tempDir("cam-session-refresh-recovery-priority-memory-"); + const sessionsDir = await tempDir("cam-session-refresh-recovery-priority-sessions-"); + const dayDir = path.join(sessionsDir, "2026", "03", "15"); + process.env.CAM_CODEX_SESSIONS_DIR = sessionsDir; + await fs.mkdir(dayDir, { recursive: true }); + await initRepo(repoDir); + + await writeProjectConfig( + repoDir, + configJson(), + { autoMemoryDirectory: memoryRoot } + ); + + const recoveryRolloutPath = path.join(repoDir, "recovery-rollout.jsonl"); + await fs.writeFile( + recoveryRolloutPath, + rolloutFixture(repoDir, "Use the recovery provenance for refresh.", { + sessionId: "session-recovery" + }), + "utf8" + ); + const auditRolloutPath = path.join(repoDir, "audit-rollout.jsonl"); + await fs.writeFile( + auditRolloutPath, + rolloutFixture(repoDir, "Use the audit provenance for refresh.", { + sessionId: "session-audit" + }), + "utf8" + ); + const primaryRolloutPath = path.join(dayDir, "rollout-primary.jsonl"); + await fs.writeFile( + primaryRolloutPath, + rolloutFixture(repoDir, "Use the latest primary rollout for refresh.", { + sessionId: "session-primary" + }), + "utf8" + ); + + const project = detectProjectContext(repoDir); + const store = new SessionContinuityStore(project, { + ...configJson(), + autoMemoryDirectory: memoryRoot + }); + await store.writeRecoveryRecord({ + recordedAt: "2026-03-18T00:00:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + rolloutPath: recoveryRolloutPath, + sourceSessionId: "session-recovery", + trigger: "manual-save", + writeMode: "merge", + scope: "both", + writtenPaths: [store.paths.sharedFile, store.paths.localFile], + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: makeEvidenceCounts(), + failedStage: "audit-write", + failureMessage: "stale recovery marker" + }); + await store.appendAuditLog({ + generatedAt: "2026-03-18T00:01:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + configuredExtractorMode: "heuristic", + trigger: "manual-save", + writeMode: "merge", + scope: "both", + rolloutPath: auditRolloutPath, + sourceSessionId: "session-audit", + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: makeEvidenceCounts(), + writtenPaths: ["/tmp/continuity-audit.md"] + }); + + const payload = JSON.parse( + await runSession("refresh", { cwd: repoDir, scope: "both", json: true }) + ) as { + rolloutPath: string; + rolloutSelection: { kind: string; rolloutPath: string }; + latestContinuityAuditEntry: { trigger?: string; writeMode?: string } | null; + }; + expect(payload.rolloutSelection).toEqual({ + kind: "pending-recovery-marker", + rolloutPath: recoveryRolloutPath + }); + expect(payload.rolloutPath).toBe(recoveryRolloutPath); + expect(payload.latestContinuityAuditEntry?.trigger).toBe("manual-refresh"); + expect(payload.latestContinuityAuditEntry?.writeMode).toBe("replace"); + + const merged = await store.readMergedState(); + expect(merged?.goal).toContain("Use the recovery provenance for refresh."); + expect(await store.readRecoveryRecord()).toBeNull(); + }, 30_000); + + it("refresh falls back to the latest matching audit entry when recovery scope does not match", async () => { + const repoDir = await tempDir("cam-session-refresh-audit-priority-repo-"); + const memoryRoot = await tempDir("cam-session-refresh-audit-priority-memory-"); + const sessionsDir = await tempDir("cam-session-refresh-audit-priority-sessions-"); + const dayDir = path.join(sessionsDir, "2026", "03", "15"); + process.env.CAM_CODEX_SESSIONS_DIR = sessionsDir; + await fs.mkdir(dayDir, { recursive: true }); + await initRepo(repoDir); + + await writeProjectConfig( + repoDir, + configJson(), + { autoMemoryDirectory: memoryRoot } + ); + + const auditRolloutPath = path.join(repoDir, "matching-audit-rollout.jsonl"); + await fs.writeFile( + auditRolloutPath, + rolloutFixture(repoDir, "Use the latest matching audit entry.", { + sessionId: "session-matching-audit" + }), + "utf8" + ); + const primaryRolloutPath = path.join(dayDir, "rollout-primary.jsonl"); + await fs.writeFile( + primaryRolloutPath, + rolloutFixture(repoDir, "Fallback primary rollout should not be used here.", { + sessionId: "session-primary-fallback" + }), + "utf8" + ); + + const project = detectProjectContext(repoDir); + const store = new SessionContinuityStore(project, { + ...configJson(), + autoMemoryDirectory: memoryRoot + }); + await store.writeRecoveryRecord({ + recordedAt: "2026-03-18T00:00:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + rolloutPath: path.join(repoDir, "project-only-recovery.jsonl"), + sourceSessionId: "session-project-only", + scope: "project", + writtenPaths: [store.paths.sharedFile], + preferredPath: "heuristic", + actualPath: "heuristic", + evidenceCounts: makeEvidenceCounts(), + failedStage: "audit-write", + failureMessage: "project-only marker" + }); + await store.appendAuditLog({ + generatedAt: "2026-03-18T00:01:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + configuredExtractorMode: "heuristic", + trigger: "manual-save", + writeMode: "merge", + scope: "both", + rolloutPath: auditRolloutPath, + sourceSessionId: "session-matching-audit", + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: makeEvidenceCounts(), + writtenPaths: ["/tmp/continuity-audit.md"] + }); + + const payload = JSON.parse( + await runSession("refresh", { cwd: repoDir, scope: "both", json: true }) + ) as { + rolloutSelection: { kind: string; rolloutPath: string }; + }; + expect(payload.rolloutSelection).toEqual({ + kind: "latest-audit-entry", + rolloutPath: auditRolloutPath + }); + + const merged = await store.readMergedState(); + expect(merged?.goal).toContain("Use the latest matching audit entry."); + expect(await store.readRecoveryRecord()).toMatchObject({ + scope: "project" + }); + }, 30_000); + + it("does not fall back to a lower-priority source when the selected refresh provenance cannot be read", async () => { + const repoDir = await tempDir("cam-session-refresh-missing-provenance-repo-"); + const memoryRoot = await tempDir("cam-session-refresh-missing-provenance-memory-"); + const sessionsDir = await tempDir("cam-session-refresh-missing-provenance-sessions-"); + const dayDir = path.join(sessionsDir, "2026", "03", "15"); + process.env.CAM_CODEX_SESSIONS_DIR = sessionsDir; + await fs.mkdir(dayDir, { recursive: true }); + await initRepo(repoDir); + + await writeProjectConfig( + repoDir, + configJson(), + { autoMemoryDirectory: memoryRoot } + ); + + const project = detectProjectContext(repoDir); + const store = new SessionContinuityStore(project, { + ...configJson(), + autoMemoryDirectory: memoryRoot + }); + await store.appendAuditLog({ + generatedAt: "2026-03-18T00:01:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + configuredExtractorMode: "heuristic", + trigger: "manual-save", + writeMode: "merge", + scope: "both", + rolloutPath: path.join(repoDir, "missing-audit-rollout.jsonl"), + sourceSessionId: "session-missing-audit", + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: makeEvidenceCounts(), + writtenPaths: ["/tmp/continuity-missing.md"] + }); + await fs.writeFile( + path.join(dayDir, "rollout-primary.jsonl"), + rolloutFixture(repoDir, "Fallback primary rollout should stay unused."), + "utf8" + ); + + await expect( + runSession("refresh", { cwd: repoDir, scope: "both" }) + ).rejects.toThrow(/ENOENT|no such file/i); + }, 30_000); + + it("auto-selects the latest primary rollout instead of a newer subagent rollout", async () => { + const repoDir = await tempDir("cam-session-latest-primary-repo-"); + const memoryRoot = await tempDir("cam-session-latest-primary-memory-"); + const sessionsDir = await tempDir("cam-session-latest-primary-sessions-"); + const dayDir = path.join(sessionsDir, "2026", "03", "15"); + process.env.CAM_CODEX_SESSIONS_DIR = sessionsDir; + await fs.mkdir(dayDir, { recursive: true }); + await initRepo(repoDir); + + await writeProjectConfig( + repoDir, + configJson(), + { autoMemoryDirectory: memoryRoot } + ); + + const primaryRolloutPath = path.join(dayDir, "rollout-primary.jsonl"); + await fs.writeFile( + primaryRolloutPath, + [ + JSON.stringify({ + type: "session_meta", + payload: { + id: "session-main", + timestamp: "2026-03-15T00:00:01.000Z", + cwd: repoDir, + source: "cli" + } + }), + JSON.stringify({ + type: "event_msg", + payload: { + type: "user_message", + message: "Continue the real primary continuity path." + } + }), + JSON.stringify({ + type: "response_item", + payload: { + type: "function_call", + name: "exec_command", + call_id: "call-1", + arguments: "{\"cmd\":\"pnpm test\"}" + } + }), + JSON.stringify({ + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call-1", + output: "Process exited with code 0" + } + }) + ].join("\n"), + "utf8" + ); + + const subagentRolloutPath = path.join(dayDir, "rollout-subagent.jsonl"); + await fs.writeFile( + subagentRolloutPath, + [ + JSON.stringify({ + type: "session_meta", + payload: { + id: "session-subagent", + forked_from_id: "session-main", + timestamp: "2026-03-15T00:00:02.000Z", + cwd: repoDir, + source: { + subagent: { + thread_spawn: { + parent_thread_id: "session-main" + } + } + } + } + }), + JSON.stringify({ + type: "session_meta", + payload: { + id: "session-main", + timestamp: "2026-03-15T00:00:01.000Z", + cwd: repoDir, + source: "cli" + } + }), + JSON.stringify({ + type: "event_msg", + payload: { + type: "user_message", + message: "You are reviewer sub-agent 4. Work read-only. Focus on docs and contract surfaces only." + } + }) + ].join("\n"), + "utf8" + ); + + const payload = JSON.parse( + await runSession("save", { + cwd: repoDir, + scope: "both", + json: true + }) + ) as { + rolloutPath: string; + diagnostics: { sourceSessionId: string }; + summary: { + project: { goal: string }; + projectLocal: { incompleteNext: string[] }; + }; + latestContinuityAuditEntry: { rolloutPath: string } | null; + }; + + expect(payload.rolloutPath).toBe(primaryRolloutPath); + expect(payload.diagnostics.sourceSessionId).toBe("session-main"); + expect(payload.latestContinuityAuditEntry?.rolloutPath).toBe(primaryRolloutPath); + expect(payload.summary.project.goal).toContain("real primary continuity"); + expect(payload.summary.projectLocal.incompleteNext.join("\n")).not.toContain( + "reviewer sub-agent" + ); + }, 30_000); + it("rejects invalid scope values", async () => { const repoDir = await tempDir("cam-session-invalid-scope-repo-"); const memoryRoot = await tempDir("cam-session-invalid-scope-memory-"); @@ -746,6 +1206,82 @@ describe("runSession", () => { expect(loadOutput.match(/\/tmp\/rollout-latest\.jsonl/g) ?? []).toHaveLength(1); }); + it("does not coalesce save and refresh audit entries with the same rollout provenance", async () => { + const repoDir = await tempDir("cam-session-refresh-history-repo-"); + const memoryRoot = await tempDir("cam-session-refresh-history-memory-"); + await initRepo(repoDir); + + await writeProjectConfig( + repoDir, + configJson(), + { autoMemoryDirectory: memoryRoot } + ); + + const project = detectProjectContext(repoDir); + const store = new SessionContinuityStore(project, { + ...configJson(), + autoMemoryDirectory: memoryRoot + }); + await store.ensureAuditLayout(); + await fs.writeFile( + store.paths.auditFile, + [ + JSON.stringify({ + generatedAt: "2026-03-15T00:00:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + configuredExtractorMode: "heuristic", + trigger: "manual-save", + writeMode: "merge", + scope: "both", + rolloutPath: "/tmp/rollout-same.jsonl", + sourceSessionId: "session-same", + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: makeEvidenceCounts(), + writtenPaths: ["/tmp/continuity-save.md"] + } satisfies SessionContinuityAuditEntry), + JSON.stringify({ + generatedAt: "2026-03-15T00:01:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + configuredExtractorMode: "heuristic", + trigger: "manual-refresh", + writeMode: "replace", + scope: "both", + rolloutPath: "/tmp/rollout-same.jsonl", + sourceSessionId: "session-same", + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: makeEvidenceCounts(), + writtenPaths: ["/tmp/continuity-refresh.md"] + } satisfies SessionContinuityAuditEntry), + JSON.stringify({ + generatedAt: "2026-03-15T00:02:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + configuredExtractorMode: "heuristic", + trigger: "manual-save", + writeMode: "merge", + scope: "both", + rolloutPath: "/tmp/rollout-latest.jsonl", + sourceSessionId: "session-latest", + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: makeEvidenceCounts(2), + writtenPaths: ["/tmp/continuity-latest.md"] + } satisfies SessionContinuityAuditEntry) + ].join("\n"), + "utf8" + ); + + const loadOutput = await runSession("load", { cwd: repoDir }); + expect(loadOutput.match(/\/tmp\/rollout-same\.jsonl/g) ?? []).toHaveLength(2); + }); + it("skips invalid-shaped continuity audit entries", async () => { const repoDir = await tempDir("cam-session-invalid-shape-repo-"); const memoryRoot = await tempDir("cam-session-invalid-shape-memory-"); @@ -808,6 +1344,88 @@ describe("runSession", () => { expect(statusOutput).not.toContain("/tmp/rollout-invalid.jsonl"); }, 30_000); + it("keeps reading legacy audit and recovery records that do not include trigger or writeMode", async () => { + const repoDir = await tempDir("cam-session-legacy-audit-repo-"); + const memoryRoot = await tempDir("cam-session-legacy-audit-memory-"); + await initRepo(repoDir); + + await writeProjectConfig( + repoDir, + configJson(), + { autoMemoryDirectory: memoryRoot } + ); + + const project = detectProjectContext(repoDir); + const store = new SessionContinuityStore(project, { + ...configJson(), + autoMemoryDirectory: memoryRoot + }); + await store.ensureAuditLayout(); + await fs.writeFile( + store.paths.auditFile, + `${JSON.stringify({ + generatedAt: "2026-03-17T00:00:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + configuredExtractorMode: "heuristic", + scope: "both", + rolloutPath: "/tmp/rollout-legacy.jsonl", + sourceSessionId: "session-legacy", + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: makeEvidenceCounts(), + writtenPaths: ["/tmp/legacy-continuity.md"] + })}\n`, + "utf8" + ); + await fs.writeFile( + store.getRecoveryPath(), + JSON.stringify({ + recordedAt: "2026-03-17T00:01:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + rolloutPath: "/tmp/rollout-legacy-recovery.jsonl", + sourceSessionId: "session-legacy-recovery", + scope: "both", + writtenPaths: ["/tmp/legacy-recovery.md"], + preferredPath: "heuristic", + actualPath: "heuristic", + evidenceCounts: makeEvidenceCounts(), + failedStage: "audit-write", + failureMessage: "legacy recovery marker" + }), + "utf8" + ); + + const loadJson = JSON.parse( + await runSession("load", { cwd: repoDir, json: true }) + ) as { + latestContinuityAuditEntry: { + rolloutPath: string; + trigger?: string; + writeMode?: string; + } | null; + pendingContinuityRecovery: { + rolloutPath: string; + trigger?: string; + writeMode?: string; + } | null; + }; + expect(loadJson.latestContinuityAuditEntry?.rolloutPath).toBe("/tmp/rollout-legacy.jsonl"); + expect(loadJson.latestContinuityAuditEntry?.trigger).toBeUndefined(); + expect(loadJson.latestContinuityAuditEntry?.writeMode).toBeUndefined(); + expect(loadJson.pendingContinuityRecovery?.rolloutPath).toBe( + "/tmp/rollout-legacy-recovery.jsonl" + ); + expect(loadJson.pendingContinuityRecovery?.trigger).toBeUndefined(); + expect(loadJson.pendingContinuityRecovery?.writeMode).toBeUndefined(); + + const statusOutput = await runSession("status", { cwd: repoDir }); + expect(statusOutput).toContain("/tmp/rollout-legacy.jsonl"); + expect(statusOutput).toContain("/tmp/rollout-legacy-recovery.jsonl"); + }, 30_000); + it("writes and surfaces a continuity recovery marker when audit persistence fails", async () => { const repoDir = await tempDir("cam-session-recovery-repo-"); const memoryRoot = await tempDir("cam-session-recovery-memory-"); @@ -1197,6 +1815,79 @@ describe("runWrappedCodex with session continuity", () => { expect(merged?.goal).toContain("Continue with save-only continuity handling"); }, 30_000); + it("prefers the primary rollout over a newer subagent rollout during wrapper auto-save", async () => { + const repoDir = await tempDir("cam-wrapper-primary-rollout-repo-"); + const memoryRoot = await tempDir("cam-wrapper-primary-rollout-memory-"); + const sessionsDir = await tempDir("cam-wrapper-primary-rollout-sessions-"); + await initRepo(repoDir); + process.env.CAM_CODEX_SESSIONS_DIR = sessionsDir; + + const mockCodexPath = path.join(repoDir, "mock-codex"); + const todayDir = path.join(sessionsDir, "2026", "03", "15"); + await fs.mkdir(todayDir, { recursive: true }); + await fs.writeFile( + mockCodexPath, + `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const cwd = process.cwd(); +const sessionsDir = process.env.CAM_CODEX_SESSIONS_DIR; +const rolloutDir = path.join(sessionsDir, "2026", "03", "15"); +fs.mkdirSync(rolloutDir, { recursive: true }); +const primaryPath = path.join(rolloutDir, "rollout-2026-03-15T00-00-00-000Z-session.jsonl"); +const subagentPath = path.join(rolloutDir, "rollout-2026-03-15T00-00-01-000Z-subagent.jsonl"); +fs.writeFileSync(primaryPath, [ + JSON.stringify({ type: "session_meta", payload: { id: "session-wrapper-primary", timestamp: "2026-03-15T00:00:00.000Z", cwd, source: "cli" } }), + JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: "Continue the primary wrapper continuity path." } }), + JSON.stringify({ type: "response_item", payload: { type: "function_call", name: "exec_command", call_id: "call-1", arguments: "{\\"cmd\\":\\"pnpm test\\"}" } }), + JSON.stringify({ type: "response_item", payload: { type: "function_call_output", call_id: "call-1", output: "Process exited with code 0" } }) +].join("\\n")); +fs.writeFileSync(subagentPath, [ + JSON.stringify({ type: "session_meta", payload: { id: "session-wrapper-subagent", forked_from_id: "session-wrapper-primary", timestamp: "2026-03-15T00:00:01.000Z", cwd, source: { subagent: { thread_spawn: { parent_thread_id: "session-wrapper-primary" } } } } }), + JSON.stringify({ type: "session_meta", payload: { id: "session-wrapper-primary", timestamp: "2026-03-15T00:00:00.000Z", cwd, source: "cli" } }), + JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: "You are reviewer sub-agent 4. Work read-only. Focus on docs and contract surfaces only." } }) +].join("\\n")); +`, + "utf8" + ); + await fs.chmod(mockCodexPath, 0o755); + + await writeProjectConfig( + repoDir, + configJson({ + codexBinary: mockCodexPath, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: true + }), + { + autoMemoryDirectory: memoryRoot, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: true + } + ); + + const continuityStore = new SessionContinuityStore(detectProjectContext(repoDir), { + ...configJson({ + codexBinary: mockCodexPath, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: true + }), + autoMemoryDirectory: memoryRoot + }); + + const exitCode = await runWrappedCodex(repoDir, "exec", ["continue"]); + expect(exitCode).toBe(0); + + const latestAudit = await continuityStore.readLatestAuditEntry(); + const merged = await continuityStore.readMergedState(); + + expect(latestAudit?.rolloutPath).toContain("rollout-2026-03-15T00-00-00-000Z-session.jsonl"); + expect(latestAudit?.sourceSessionId).toBe("session-wrapper-primary"); + expect(merged?.goal).toContain("primary wrapper continuity path"); + expect(merged?.goal).not.toContain("reviewer sub-agent"); + expect(merged?.incompleteNext.join("\n")).not.toContain("reviewer sub-agent"); + }, 30_000); + it("injects continuity on startup and auto-saves it after the run", async () => { const repoDir = await tempDir("cam-wrapper-session-repo-"); const memoryRoot = await tempDir("cam-wrapper-session-memory-"); diff --git a/test/session-continuity.test.ts b/test/session-continuity.test.ts index 5b9d6eb..28c8cf5 100644 --- a/test/session-continuity.test.ts +++ b/test/session-continuity.test.ts @@ -18,7 +18,11 @@ import { collectSessionContinuityEvidenceBuckets } from "../src/lib/extractor/se import { buildSessionContinuityPrompt } from "../src/lib/extractor/session-continuity-prompt.js"; import { SessionContinuitySummarizer } from "../src/lib/extractor/session-continuity-summarizer.js"; import { runCommandCapture } from "../src/lib/util/process.js"; -import type { AppConfig, RolloutEvidence } from "../src/lib/types.js"; +import type { RolloutEvidence } from "../src/lib/types.js"; +import { + initGitRepo, + makeAppConfig +} from "./helpers/cam-test-fixtures.js"; const tempDirs: string[] = []; @@ -28,20 +32,6 @@ async function tempDir(prefix: string): Promise { return dir; } -async function initRepo(repoDir: string): Promise { - const gitEnv = { - ...process.env, - GIT_AUTHOR_NAME: "Codex Auto Memory", - GIT_AUTHOR_EMAIL: "cam@example.com", - GIT_COMMITTER_NAME: "Codex Auto Memory", - GIT_COMMITTER_EMAIL: "cam@example.com" - }; - runCommandCapture("git", ["init", "-b", "main"], repoDir, gitEnv); - await fs.writeFile(path.join(repoDir, "README.md"), "seed\n", "utf8"); - runCommandCapture("git", ["add", "README.md"], repoDir, gitEnv); - runCommandCapture("git", ["commit", "-m", "init"], repoDir, gitEnv); -} - async function writeMockCodexBinary(tempRoot: string, body: string): Promise { const mockBinary = path.join(tempRoot, "mock-codex"); await fs.writeFile( @@ -59,20 +49,13 @@ ${body} return mockBinary; } -function baseConfig(memoryRoot: string, overrides: Partial = {}): AppConfig { - return { - autoMemoryEnabled: true, +const initRepo = initGitRepo; + +function baseConfig(memoryRoot: string, overrides: Parameters[0] = {}) { + return makeAppConfig({ autoMemoryDirectory: memoryRoot, - extractorMode: "heuristic", - defaultScope: "project", - maxStartupLines: 200, - sessionContinuityAutoLoad: false, - sessionContinuityAutoSave: false, - sessionContinuityLocalPathStyle: "codex", - maxSessionContinuityLines: 60, - codexBinary: "codex", ...overrides - }; + }); } afterEach(async () => { @@ -508,8 +491,10 @@ describe("session continuity domain", () => { '继续,但是这个问题需要更多研究才能解决', '下一步而是要确认这个方案的可行性', '还需要因为依赖关系比较复杂', + '接下来我会做两件并行的只读工作:一是按你要求跑完整校验命令并记录结果,二是把审查范围拆给 4 个 reviewer 子 agent 分域取证。' ], [ + 'You are reviewer sub-agent 4 for a high-accountability code review. Work read-only. Focus on docs and contract surfaces only.', '接下来 x', // too short ] ) @@ -531,7 +516,39 @@ describe("session continuity domain", () => { expect(negativeBuckets.explicitUntried).toHaveLength(0); // Positive: genuine items should be captured - expect(positiveBuckets.explicitNextSteps.length).toBeGreaterThan(0); + expect(positiveBuckets.explicitNextSteps).toHaveLength(2); + expect(positiveBuckets.explicitNextSteps).toEqual( + expect.arrayContaining([ + '修复 session-continuity-evidence.ts 中的正则匹配问题', + '需要更新测试文件以覆盖新的守卫逻辑' + ]) + ); + expect(positiveBuckets.explicitNextSteps.join("\n")).not.toContain("reviewer sub-agent"); + }); + + it("keeps actionable focus-on and follow-up phrasing as next steps", () => { + const evidence: RolloutEvidence = { + sessionId: "session-follow-up-focus", + createdAt: "2026-03-15T00:00:00.000Z", + cwd: "/tmp/project", + userMessages: [ + "Next step: focus on src/auth/login.ts and update the auth cookie path.", + "Follow-up: rerun pnpm test after the cookie change." + ], + agentMessages: [], + toolCalls: [], + rolloutPath: "/tmp/rollout.jsonl" + }; + + const buckets = collectSessionContinuityEvidenceBuckets(evidence); + + expect(buckets.explicitNextSteps).toHaveLength(2); + expect(buckets.explicitNextSteps.join("\n")).toContain( + "focus on src/auth/login.ts and update the auth cookie path" + ); + expect(buckets.explicitNextSteps.join("\n")).toContain( + "rerun pnpm test after the cookie change" + ); }); it("prompt includes evidence buckets for commands, file writes, and next steps", () => { @@ -1030,6 +1047,74 @@ describe("SessionContinuityStore", () => { expect((await store.readState("project-local"))?.incompleteNext).toContain("Add middleware."); }); + it("can replace existing continuity without merging stale state forward", async () => { + const repoDir = await tempDir("cam-continuity-replace-repo-"); + const memoryRoot = await tempDir("cam-continuity-replace-memory-"); + await initRepo(repoDir); + + const store = new SessionContinuityStore( + detectProjectContext(repoDir), + baseConfig(memoryRoot) + ); + await store.saveSummary( + { + project: { + goal: "Stale shared goal", + confirmedWorking: ["Stale shared success"], + triedAndFailed: [], + notYetTried: ["Stale shared idea"], + incompleteNext: [], + filesDecisionsEnvironment: ["Stale shared note"] + }, + projectLocal: { + goal: "", + confirmedWorking: [], + triedAndFailed: [], + notYetTried: [], + incompleteNext: ["Stale local next"], + filesDecisionsEnvironment: ["Stale local note"] + }, + sourceSessionId: "session-stale" + }, + "both" + ); + + const written = await store.replaceSummary( + { + project: { + goal: "Fresh shared goal", + confirmedWorking: ["Fresh shared success"], + triedAndFailed: [], + notYetTried: [], + incompleteNext: [], + filesDecisionsEnvironment: [] + }, + projectLocal: { + goal: "", + confirmedWorking: [], + triedAndFailed: [], + notYetTried: [], + incompleteNext: ["Fresh local next"], + filesDecisionsEnvironment: [] + }, + sourceSessionId: "session-fresh" + }, + "both" + ); + + expect(written).toContain(store.paths.sharedFile); + expect(written).toContain(store.paths.localFile); + expect((await store.readState("project"))?.goal).toBe("Fresh shared goal"); + expect((await store.readState("project"))?.confirmedWorking).toEqual([ + "Fresh shared success" + ]); + expect((await store.readState("project"))?.notYetTried).toEqual([]); + expect((await store.readState("project-local"))?.incompleteNext).toEqual([ + "Fresh local next" + ]); + expect((await store.readState("project-local"))?.filesDecisionsEnvironment).toEqual([]); + }); + it("supports claude-style local files, reads the newest mtime file, and clears all active session tmp files", async () => { const repoDir = await tempDir("cam-continuity-claude-repo-"); const memoryRoot = await tempDir("cam-continuity-claude-memory-"); @@ -1079,4 +1164,56 @@ describe("SessionContinuityStore", () => { expect(cleared).toContain(newerFile); expect((await fs.readdir(store.paths.localDir)).filter((name) => name.endsWith("-session.tmp"))).toHaveLength(0); }); + + it("replaces the current active claude-style local file without deleting historical tmp files", async () => { + const repoDir = await tempDir("cam-continuity-claude-replace-repo-"); + const memoryRoot = await tempDir("cam-continuity-claude-replace-memory-"); + await initRepo(repoDir); + + const store = new SessionContinuityStore( + detectProjectContext(repoDir), + baseConfig(memoryRoot, { sessionContinuityLocalPathStyle: "claude" }) + ); + await store.ensureLocalLayout(); + const olderFile = path.join(store.paths.localDir, "2026-03-01-old-session.tmp"); + const activeFile = path.join(store.paths.localDir, "2026-03-02-active-session.tmp"); + await fs.writeFile(olderFile, "older\n", "utf8"); + await fs.writeFile(activeFile, "active\n", "utf8"); + const olderTimestamp = new Date("2026-03-01T00:00:00.000Z"); + const activeTimestamp = new Date("2026-03-03T00:00:00.000Z"); + await fs.utimes(olderFile, olderTimestamp, olderTimestamp); + await fs.utimes(activeFile, activeTimestamp, activeTimestamp); + + const written = await store.replaceSummary( + { + project: { + goal: "", + confirmedWorking: [], + triedAndFailed: [], + notYetTried: [], + incompleteNext: [], + filesDecisionsEnvironment: [] + }, + projectLocal: { + goal: "", + confirmedWorking: [], + triedAndFailed: [], + notYetTried: [], + incompleteNext: ["Replace the active claude session file."], + filesDecisionsEnvironment: [] + }, + sourceSessionId: "session-claude-replace" + }, + "project-local" + ); + + expect(written).toEqual([activeFile]); + const localLocation = await store.getLocation("project-local"); + expect(localLocation.path).toBe(activeFile); + expect(await fs.readFile(activeFile, "utf8")).toContain( + "Replace the active claude session file." + ); + expect(await fs.readFile(olderFile, "utf8")).toBe("older\n"); + expect((await fs.readdir(store.paths.localDir)).filter((name) => name.endsWith("-session.tmp"))).toHaveLength(2); + }); });