diff --git a/CHANGELOG.md b/CHANGELOG.md index 1979e502..874952aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ All notable changes to this project will be documented in this file. See [standa - Multi-project management: `role` and `project` together resolve resource namespaces, and project-private learnings are isolated ([#426](https://github.com/Tencent/teamai-cli/pull/426), for [#375](https://github.com/Tencent/teamai-cli/issues/375)). - Data partitions auto-migrate a legacy `.teamai`, resume interrupted migrations, smoke-check the clone, and keep a git-ignored backup ([#439](https://github.com/Tencent/teamai-cli/pull/439), for [#374](https://github.com/Tencent/teamai-cli/issues/374)). - Teams add their own course-correction words via `sharing.intervention.correctionKeywords` in `teamai.yaml`. The built-in list still covers only Chinese, English and Japanese, so corrections typed in other languages count only once the team configures them. The `UserPromptSubmit` hook now stores a `correction` flag on each dashboard prompt event (for [#564](https://github.com/Tencent/teamai-cli/issues/564)). +- `teamai session migrate` splits CodeBuddy into two platforms: `codebuddy` (CLI, `~/.codebuddy/projects/...`) and `codebuddy-ide` (the IDE sidebar history). IDE conversations were readable by neither before, so migrating out of CodeBuddy only ever saw CLI sessions; they can now be listed, migrated, and rolled back directly. +- `teamai session migrate` no longer writes to CodeBuddy IDE when the target is `codebuddy`. The two stores are independent and only the CLI one is listed, so the implicit double-write produced sessions that showed up in the sidebar but could neither be listed nor deleted. +- User-level session repos: `session list --all`, `session pull --all`, and `session search --all` read across every archived project (plus `_unattributed`), not just the current directory's git identity. `search --all` previously had a dead loop and never actually searched other projects. +- `session push --all` archives every workspace of one platform (with `--source`, confirmation above 5 sessions, `-y` skips), so a personal team repo can collect sessions from any directory in one run. +- Session archive keys derive from the session's own native working directory instead of the directory where the command ran. `claude-code` / `codebuddy` / `cursor` recover the native cwd from the first JSONL record; sessions whose workspace is unknowable (codebuddy-ide md5 placeholders) archive under `_unattributed` with an explicit warning. +- Re-pushing the same session updates the existing entry (dedup key: origin sessionId + author) instead of accumulating `xxx_1` duplicates. ### 🐛 Bug Fixes @@ -19,6 +25,29 @@ All notable changes to this project will be documented in this file. See [standa - Course-correction matching normalizes prompts and keywords to Unicode NFC, so composed and decomposed accents match. Stored prompt summaries and the 60-second correction window are unchanged. Fixes [#573](https://github.com/Tencent/teamai-cli/issues/573). - Course-correction detection matches keywords in space-separated scripts as whole words, so Spanish "segundo" no longer counts as `undo` (for [#564](https://github.com/Tencent/teamai-cli/issues/564)). - `teamai doctor` no longer assumes TGit before initialization and now exits with code 1 when any diagnostic check fails. +- CodeBuddy IDE history roots are detected in both on-disk layouts. The `default` instance keeps `history` one level higher (`/CodeBuddyIDE/history`), and failing to recognize it skipped every conversation under that instance. +- CodeBuddy IDE 32-hex conversation ids are reused as-is instead of being hashed a second time. Write and delete computed different ids, so `rollback` reported success while leaving the conversation in place. +- CodeBuddy CLI project directories are encoded with CodeBuddy's own rule — only path separators become `-`, spaces are kept. The previous catch-all encoding turned `.../teamai cli` into `...-teamai-cli` and such workspaces could never be listed or read. +- CodeBuddy session titles no longer leak injected prompt text. Both the CLI and the IDE pick the first real user message (skipping ``-style wrappers) instead of displaying raw prompt XML in `session migrate` listings. +- `claude-code`, `workbuddy`, and `cursor` titles get the same injected-text cleaning as CodeBuddy; their first "user message" is often a ``-style wrapper too, which used to become the archived session name. +- Migrating **into** `claude-code` now writes a `summary` record, so `claude --resume` shows the session's real title instead of the bare session id (e.g. `824ff784`). `readSession` also honors that record, so write→read roundtrips keep the title. +- Session archive dedup key includes the platform: the same session pushed as `codebuddy` and re-archived after migrating to `claude-code` are two artifacts, not an update. Existing repos where a cross-platform overwrite already happened keep the surviving copy. +- Git operations in `session push` / `migrate --push` / `pull` no longer crash with a stack trace when the team repo is not a git repository, lacks a remote, is missing, or hits a concurrent `index.lock` — each prints a one-line English error with the repo root and exits 1. +- A corrupted `sessions/**/_index.json` now prints a warning with the rebuild command instead of silently emptying the dedup key (which used to produce `xxx_1` duplicates on the next push). +- Interactive prompts treat EOF like an answer of "n": `session push --all` and platform selection print `Cancelled.` and exit 0 instead of hanging into a silent success. `--limit` values that are not positive numbers fall back to the documented default instead of negative-slice trimming. A closed output pipe (`| head`) exits cleanly instead of dumping an EPIPE stack. +- `session push` remote failures report git's actual `fatal:` line instead of the contentless `Command failed: git push origin` first line. +- Codex sessions keep their per-message timestamps: `readSession` reads `response_item.timestamp` and `writeSession` stamps records with the message's own time instead of the migration moment (roundtrips through claude-code no longer collapse the timeline). Codex session lookup matches whole ids — a 4-character prefix can no longer resolve to a different session file. +- Cursor duplicate-write idempotency restored (a malformed UUID regex made `writeSession` mint a new id every time, piling up copies), and cursor/workbuddy titles skip tool-output snippets and injected `ai-title` wrappers. +- `workbuddy` / `claude-code` / `cursor` `extractMeta` no longer stop scanning at the first injected text block, so a real question after a `` wrapper becomes the title. +- Project directory names encode the symlink-resolved cwd for `claude-code`, `codebuddy`, `cursor`, and `workbuddy`. On macOS, writing with `--target-cwd /tmp/x` used to land in a directory nothing could list back, because listing runs with the resolved `/private/tmp/x`. +- `session push` no longer crashes with a stack trace when the remote push fails (no upstream, read-only HTTP mode, network) — the local save and commit already succeeded, so it prints a warning and keeps them. +- CodeBuddy IDE workspace hashes resolve symlinks before hashing. On macOS, writing with `/tmp/foo` and listing from `/private/tmp/foo` used to produce two different workspaces, so migrated sessions appeared to vanish. +- `migrate --push` re-reads exactly the migrated target session ids instead of "the N most recent" of the target directory, which could push unrelated pre-existing sessions while dropping the migrated ones. +- Pushed session meta no longer lies: `fidelityScore` records the real migration preview score instead of a hardcoded 1.0, and `createdAt` keeps the session's own creation time instead of the push time (the latter also skewed `session search` time-decay ranking). +- `session push` no longer reports "✓ Pushed N session(s)" when nothing was committed (empty pushes print "No changes to push"). +- `session resume` failures explain that the session may be archived under another project identity and suggest `session search --all` / `--cwd`, instead of a bare uncaught error. +- 14 Chinese user-facing strings in `session-flow` (console output and thrown errors) are now English, per the project's English-output rule. +- `codebuddy-ide` no longer silently relabels a session's cwd with the directory passed to `readSession` when the global fallback finds the conversation in a different workspace; the cwd is reported as an `md5:` placeholder so archive keys stay honest. - MCP `requires` is resolved from `PATH` (including Windows `PATHEXT`), so `teamai mcp inject` no longer skips servers such as `uvx` on Windows ([#540](https://github.com/Tencent/teamai-cli/pull/540), for [#539](https://github.com/Tencent/teamai-cli/issues/539)). - The GitHub and CNB providers resolve their CLI to a launchable absolute path and start it through cross-spawn, so on Windows they no longer answer "installed" while every call fails silently ([#520](https://github.com/Tencent/teamai-cli/pull/520)). - `enabledAgents` now also gates CLI builtin deploy, CLAUDE.md-class injects, and last-pull skip-sync targets, so an already-installed tool outside the whitelist is not written to ([#510](https://github.com/Tencent/teamai-cli/issues/510)). diff --git a/README.md b/README.md index 242c1569..2034bdea 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,7 @@ Insight into how the team actually uses its AI tools, and a starting point for t |------------|---------|---------------| | **Usage** | `teamai digest` | Weekly team digest — 7-day success, prompt, active-time, estimated cost, cache, and correction trends, plus lifetime totals. | | **Sessions** | `teamai session save` | Privacy-scrubbed per-session summaries (tool sequence, prompt turns, interventions) that feed the digest's Session Highlights. | +| **Session Sync** | `teamai session migrate` | Move full transcripts between AI tools; archive, search, and restore team sessions (`push` / `pull` / `list` / `resume` / `search`). | | **Dashboard** | `teamai dashboard` | Web dashboard showing live sessions and local 7-day trends compared with the prior 7 days. | | **KB Health** | `teamai dashboard` → KB Health | Built-in dashboard page reporting knowledge-base usage & health — coverage by type, top recalled entries, silent entries, recall trend, author contributions, and a maintenance console. | @@ -265,6 +266,7 @@ Insight into how the team actually uses its AI tools, and a starting point for t | `teamai source` | Manage skill subscription sources (other teams or your org's shared repos) | | `teamai remove ` | Remove a resource and open MR | | `teamai session save` | Record a privacy-scrubbed session summary to a monthly log (`--push` feeds `digest`) | +| `teamai session migrate/push/pull/list/resume/search/rollback` | Migrate full sessions across AI tools; archive to and restore from the team repo | | `teamai digest` | Generate weekly team usage digest | | `teamai doctor` | Diagnose configuration issues | | `teamai uninstall` | Remove all teamai resources and hooks | diff --git a/README.zh-CN.md b/README.zh-CN.md index 8657dd28..3a0ad128 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -234,6 +234,7 @@ teamai recall maintenance --update-quality # 为过时 skills / docs 生 |------|------|----------| | **用量(Usage)** | `teamai digest` | 团队周报——近 7 天成功率、对话、活跃时长、估算成本、缓存与纠偏趋势,以及历史累计数据。 | | **会话(Sessions)** | `teamai session save` | 脱敏的单会话摘要(工具序列、对话轮次、干预次数),喂给周报的 Session Highlights。 | +| **会话同步(Session Sync)** | `teamai session migrate` | 在 AI 工具间迁移完整会话;团队会话的归档、搜索与恢复(`push` / `pull` / `list` / `resume` / `search`)。 | | **看板(Dashboard)** | `teamai dashboard` | Web 看板,展示实时会话,以及本机近 7 天相对前 7 天的趋势。 | | **知识库健康(KB Health)** | `teamai dashboard` → KB Health | 内置于看板的报告页面,展示知识库使用情况与健康状态——各类型覆盖率、高频召回条目、沉默条目、召回趋势、作者贡献及维护控制台。 | @@ -265,6 +266,7 @@ teamai recall maintenance --update-quality # 为过时 skills / docs 生 | `teamai source` | 管理 skill 订阅源(其他团队或本团队公共仓库) | | `teamai remove ` | 删除资源并创建 MR | | `teamai session save` | 将脱敏后的 session 摘要记录到月度日志(`--push` 可喂给 `digest`) | +| `teamai session migrate/push/pull/list/resume/search/rollback` | 跨 AI 工具迁移完整会话;归档到团队仓库并从中恢复 | | `teamai digest` | 生成团队周报 | | `teamai doctor` | 诊断配置问题 | | `teamai uninstall` | 移除所有 teamai 资源和 hooks | diff --git a/docs/designs/session-user-repo-sync.md b/docs/designs/session-user-repo-sync.md new file mode 100644 index 00000000..a38cda5b --- /dev/null +++ b/docs/designs/session-user-repo-sync.md @@ -0,0 +1,139 @@ +# Design: Session User-Repo Sync — cross-project consumption and correctness fixes + +> Status: Approved · Branch: `feat/session-user-repo` · Phasing: M1 → M2 → M3 (one PR each) + +## Problem + +`teamai session` commands store sessions in a team repo under `sessions/repos///`. +Project-level repos (repo cloned inside the project) form a closed loop: push reads only the +project cwd, and list/pull/resume filter by the project's `git remote` identity. User-level repos +(the clone lives anywhere, e.g. under `~/.teamai/`) can already **receive** sessions from any +directory — but nothing can **consume** them back across projects, and several correctness bugs +undermine the whole flow. + +| # | Problem | Evidence | +|---|---------|----------| +| P1 | `session search --all` never searches other projects: the loop body is dead code, and `listRepos()` returns encoded dir names with no decoder — although each repo's `_index.json` stores the canonical identity | `session-cmd.ts:494-517`, `sync.ts:108-110`, `sync.ts:451-457` | +| P2 | `list` / `pull` / `resume` have no cross-project view; `_unattributed` sessions are invisible from any git directory | `session-cmd.ts:410-474`, `sync.ts:234-239` | +| P3 | `migrate --push` archives under the cwd where the command ran, not the session's native project | `session-cmd.ts:301-324` | +| P4 | `migrate --push` re-reads "the N most recent" target sessions, which can push unrelated pre-existing sessions instead of the migrated ones | `session-cmd.ts:306-307` | +| P5 | `push` is single-cwd only; no cross-directory batch | `session-cmd.ts:344-390` | +| P6 | 14 Chinese user-facing strings violate the English-output rule (3 console + 11 throw) | `session-cmd.ts:279,562-563,573`; `codex.ts:240`, `sync.ts:372/375/408/417`, `claude-code.ts:379`, `codebuddy-ide.ts:172/307`, `codebuddy.ts:263`, `workbuddy.ts:264`, `cursor.ts:229` | +| P7 | Meta lies: `fidelityScore` hardcoded 1.0; `createdAt` records push time, breaking search time-decay ordering | `session-cmd.ts:323`, `sync.ts:168` vs `search.ts:104-111` | +| P8 | Repeated pushes of one session create `xxx` and `xxx_1` duplicates; no dedup key | `sync.ts:329-330` | +| P9 | `resume` in the wrong directory throws an uncaught Chinese error with no hint the session lives under another identity | `session-cmd.ts:455-474`, `sync.ts:408` | +| P10 | `gitCommit` returns HEAD even when nothing was committed → "✓ Pushed N" on empty pushes | `sync.ts:546-549` | + +Additional aggravator for P3: when `codebuddy-ide` `readSession` falls back to a global search and +finds a conversation from another workspace, it silently rewrites `session.cwd` to the passed-in +project path (`codebuddy-ide.ts:163-170,223`) — the archive key is wrong *deterministically*, +not just incidentally. + +## Solution + +### Key invariant + +**A session's archive key (repoIdentity) is derived from the session's own native cwd whenever +that cwd is knowable, never from the directory where the CLI happened to run.** When the native +cwd is unknowable (codebuddy-ide without an explicit path), warn and fall back to `_unattributed`. + +Native-cwd knowability per adapter: + +| Adapter | Source | Effort | +|---------|--------|--------| +| codex | `session_meta.payload.cwd` — real absolute path | none | +| workbuddy | `meta.json` cwd | none | +| claude-code | each JSONL record carries `cwd`; adapter must read the first record | small | +| codebuddy CLI | each record carries `cwd` (written by `writeSession`) | small | +| cursor | same pattern as claude-code | small | +| codebuddy-ide | workspace dir = md5(cwd), irreversible | impossible — caller must pass real cwd, else `_unattributed` + warning | + +### Data model (no schema changes) + +`SessionSyncMeta.origin` gains reliable values: `repoIdentity` from native cwd (fallback: +caller-provided, then `_unattributed`), `createdAt` from `session.createdAt` (not push time), +`fidelityScore` from the migration preview score. Dedup key = `origin.sessionId` + author: +re-pushing updates the existing entry instead of generating `_1` suffixes. + +### Entry points + +- `session list --all` / `session pull --all` — iterate every repo dir via `_index.json` canonical + identities plus `_unattributed`. +- `session search --all` — replace the dead loop with the same cross-repo iteration. +- `session push --all` — `--source` stays required; enumerates every workspace directory of that + one platform (bounded blast radius, consistent with `status --all`). Confirmation list when + more than 5 sessions would be pushed; `-y` skips. +- `migrate --push` — re-read exactly the `result.targetSessionId`s recorded during the migration + loop; archive under the session's native identity. +- `resume` failure — append a hint: the session may be archived under another project; try + `session search --all` or `--cwd `. + +Deliberately **not** done: new top-level commands (Occam's razor — every change extends an +existing command's options); branch/MR flow for session push (current behavior pushes the +current branch; aligning with `teamai push`'s branch+MR flow is a separate discussion). + +## Affected surface + +**New** (this PR series): +- `docs/designs/session-user-repo-sync.md` — this document +- `src/__tests__/session-sync.test.ts` — SyncManager: index, encoding, archive layout, dedup, cross-repo listing +- `src/__tests__/session-cmd.test.ts` — command layer: search --all, push --all, archive key, English output + +**Modified**: +- `src/session-flow/sync.ts` — native-identity validation, dedup, `listAllRepoIdentities()`, cross-repo `listSessionsAcrossRepos()`, empty-commit detection +- `src/session-flow/session-cmd.ts` — `--all` options, precise re-read, resume hint, English strings +- `src/session-flow/adapters/claude-code.ts`, `codebuddy.ts`, `cursor.ts` — read native cwd from first record; English errors +- `src/session-flow/adapters/codebuddy-ide.ts`, `codex.ts`, `workbuddy.ts` — no silent cwd rewrite; English errors +- `docs/usage-guide.md` / `docs/usage-guide.zh-CN.md` — new `### Session Sync & Migration` section between Session Save and Hooks (+ both TOCs) +- `README.md` / `README.zh-CN.md` — Sessions row and command cheat-sheet +- `CHANGELOG.md` + +## Phasing + +| Phase | Scope | PR | +|-------|-------|-----| +| M1 correctness | P3 P4 P6 P7 P9 P10 (small fixes, no API change) | 1 | +| M2 user-repo capability | P1 P2 P5 P8 + SyncManager cross-repo API + archive-key rework | 1 | +| M3 tests & docs | both test files, six doc touchpoints, real-CLI E2E report | 1 | + +M1 and M2 are independent in code but share the same branch; M3 lands last and validates both. + +## Out of scope + +- Branch/MR flow for session push (separate discussion) +- Streaming/lazy loading for search (known limitation: full sessions are read into memory; recorded, not fixed) +- SessionSave/`teamai session save` (different system — digest summaries) + +## Known limitations (QA sweep, recorded — not fixed by design) + +Verified by `src/__tests__/fidelity-sweep.test.ts` (roundtrip matrix, kept as the fidelity +regression suite): + +- **fidelityScore is a proxy metric.** It only measures IR-block-level degradations. Content + deformation (dropped empty messages, timestamp collapse, sessionId regeneration, title + drift, message splitting in codex) is invisible to it. Do not treat 100% as "byte-perfect". +- **codex message splitting**: `[thinking, text, tool_call]` assistant turns are written as + separate codex response_items and read back as more messages than went in. +- **Empty-content messages** are dropped by several adapters' writers/readers (semantic + choice per adapter; unifying would change existing behavior). +- **sessionId is platform-native.** v4 (claude-code), v7 (codex), 32-hex (codebuddy-ide) + each regenerate on write; a cross-platform chain therefore accumulates one archive per + platform. The session id you resume with is always the target platform's. +- **claude-code flattenDag** drops sidechain branches and can promote orphan nodes early; + fork branches interleave into the main timeline. +- **cursor has no stored title** — the title is derived from the first real user text; + sessions whose only real content is tool output may title from that snippet. +- **codex has no on-disk title mechanism** — roundtrip titles degrade to `Session `. + +## End-to-end test plan (real CLI, per AGENTS.md — type-check/unit tests don't count) + +1. `node dist/index.js session platforms` — all 6 platforms listed, `codebuddy` and `codebuddy-ide` both `✓ installed` +2. In a non-git temp dir: `session push --source codebuddy --repo-root ` — session lands under `sessions/_unattributed/`, output is English +3. In the user repo: `session list --all` — the unattributed session is visible; `session list` (no flag) — not visible (current-project filter intact) +4. `session search --all ` — matches sessions from at least two different repoIdentity dirs +5. From project A: `session migrate -s codebuddy-ide -t claude-code --push` — archive lands under B's identity (check meta.json), fidelityScore equals preview score, no `_1` duplicate on re-push +6. Re-run step 5 push — same entry updated, no duplicate file +7. `session resume` in a wrong project dir — error is English and mentions `search --all` / `--cwd` +8. Empty repo push (`session push --source codebuddy` with no local sessions) — prints "No sessions found", never "✓ Pushed" +9. `npx vitest run src/__tests__/session-sync.test.ts src/__tests__/session-cmd.test.ts` — all green +10. `npx vitest run` — no new failures vs. the pre-change baseline diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 97b17814..731e853b 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1311,6 +1311,32 @@ teamai session save --push --include-prompt # also include the (redacted) first > Privacy: the team-pushed payload is **counts + tool names only** by default. The first-ask prompt line is opt-in via `--include-prompt`, and even then it is run through the same secret redaction (`ghp_…` → ``) used elsewhere. Local logs keep the redacted first-ask line since they never leave your machine. +### Session Sync & Migration + +Beyond summaries, `teamai session` can move full conversation transcripts between AI tools and archive them in the team repo. Where `session save` records a privacy-scrubbed summary, these commands carry every message. + +Supported platforms: `claude-code` (plus `claude-internal` / `tclaude`), `codex` (plus `codex-internal` / `tcodex`), `codebuddy` (CLI), `codebuddy-ide` (the IDE sidebar), `cursor`, and `workbuddy`. `teamai session platforms` shows which are installed locally. + +```bash +teamai session platforms # supported vs installed +teamai session migrate -s codebuddy-ide -t claude-code # one session across tools +teamai session migrate --all -s codebuddy -t claude-code # the 5 most recent +teamai session rollback --platform claude-code # undo a migration +teamai session push --source codebuddy # archive this directory's sessions +teamai session push --source codebuddy --all # every workspace of that platform +teamai session pull # pull + re-index team sessions +teamai session list # this project's team sessions +teamai session list --all # every archived project +teamai session search [--all] # full-text search of archived content +teamai session resume --platform claude-code # restore into a local tool +``` + +All of these accept `--dry-run` and `-v`. `migrate --push` migrates and archives in one step; `resume` prints the new session id — continue it with your tool's own resume flag. + +**Archive layout.** Sessions are archived under the git identity of their working directory: `sessions/repos///` in the team repo. Sessions from non-git directories land under `_unattributed`. The archive key comes from the session's own workspace — not from where you run the command — so migrating from another directory still archives under the right project. CodeBuddy IDE sessions whose workspace cannot be resolved fall back to `_unattributed` with a warning. + +**Project-level vs user-level repos.** `list` / `pull` / `resume` filter by the current directory's git remote, so a project-level team repo shows exactly that project's sessions. Pass `--repo-root ` — for example a personal repo — and use `--all` to read across every archived project. + ### Hooks Hooks automatically injected by `teamai init`: diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 2d8258cf..af238093 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1275,6 +1275,32 @@ teamai session save --push --include-prompt # 额外带上(脱敏后的)首 > 隐私:推送到团队的内容默认**只含计数 + 工具名**。首个 prompt 行需通过 `--include-prompt` 显式开启,且即便开启也会经过与别处一致的密钥脱敏(`ghp_…` → ``)。本地日志因为不出本机,会保留脱敏后的首个 prompt 行。 +### Session 同步与迁移(Session Sync & Migration) + +除了摘要之外,`teamai session` 还能在不同 AI 工具之间迁移完整会话,并把会话归档到团队仓库。`session save` 记录的是脱敏摘要,而这一组命令搬运的是会话的全部消息。 + +支持的平台:`claude-code`(含 `claude-internal` / `tclaude` 变体)、`codex`(含 `codex-internal` / `tcodex`)、`codebuddy`(CLI)、`codebuddy-ide`(IDE 侧边栏)、`cursor`、`workbuddy`。运行 `teamai session platforms` 查看本机已安装哪些。 + +```bash +teamai session platforms # 支持 vs 已安装 +teamai session migrate -s codebuddy-ide -t claude-code # 跨工具迁移单条会话 +teamai session migrate --all -s codebuddy -t claude-code # 最近 5 条 +teamai session rollback --platform claude-code # 撤销一次迁移 +teamai session push --source codebuddy # 归档当前目录的会话 +teamai session push --source codebuddy --all # 该平台的全部工作区 +teamai session pull # 拉取并重建团队会话索引 +teamai session list # 当前项目的团队会话 +teamai session list --all # 全部归档项目 +teamai session search [--all] # 全文搜索归档内容 +teamai session resume --platform claude-code # 恢复到本地工具 +``` + +以上命令均支持 `--dry-run` 与 `-v`。`migrate --push` 一步完成迁移 + 归档;`resume` 会打印新的会话 id,用工具自身的 resume 参数继续。 + +**归档布局。** 会话按其工作目录的 git 标识归档到团队仓库的 `sessions/repos///`;非 git 目录的会话落入 `_unattributed`。归档键取自会话自身的工作区——而不是执行命令时所在的目录——从别的目录迁入也会归到正确的项目名下。CodeBuddy IDE 中无法还原工作区的会话会带警告归入 `_unattributed`。 + +**项目级 vs 用户级仓库。** `list` / `pull` / `resume` 按当前目录的 git remote 过滤,项目级团队仓库因此只显示本项目的会话。传入 `--repo-root <任意 clone>`(例如个人仓库)并配合 `--all`,即可跨全部归档项目读取。 + ### Hooks `teamai init` 自动注入的 Hooks: diff --git a/src/__tests__/codebuddy-ide-adapter.test.ts b/src/__tests__/codebuddy-ide-adapter.test.ts new file mode 100644 index 00000000..871b445c --- /dev/null +++ b/src/__tests__/codebuddy-ide-adapter.test.ts @@ -0,0 +1,328 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import * as crypto from 'node:crypto'; + +/** + * IDE 存储路径是 `os.homedir()` 派生的,必须整体换掉 home 才能用 fixture。 + * `vi.spyOn(os, 'homedir')` 在这里不生效——测试文件是 default import, + * 被测模块是 namespace import,两者在 ESM 下不是同一个对象,spy 打在测试侧。 + * 因此改用模块级 mock,同时覆盖命名导出与 default。 + */ +const mocks = vi.hoisted(() => ({ home: '' })); + +vi.mock('node:os', async (importOriginal) => { + const actual = (await importOriginal()) as unknown as Record & { default?: object }; + const patched: Record = { ...actual, homedir: () => mocks.home }; + patched.default = { ...(actual.default ?? {}), homedir: () => mocks.home }; + return patched; +}); + +import { + listIdeHistoryRoots, + readIdeConversation, +} from '../session-flow/ide-history.js'; +import { CodeBuddyIdeAdapter } from '../session-flow/adapters/codebuddy-ide.js'; + +/** + * CodeBuddy IDE 适配器测试。 + * + * IDE 与 CLI 是两套独立存储,IDE 侧有几个容易踩空的点,这里逐条钉住: + * - history 根有两种布局(default 实例少一层 instId) + * - 工作区目录名是 md5(cwd),过滤时不能把工作区目录当 history 根传下去 + * - 会话 id 是 32 位 hex,读写删必须映射成同一个 id + */ + +let tmpHome: string; + +function md5(s: string): string { + return crypto.createHash('md5').update(s).digest('hex'); +} + +function userDataBase(home: string): string { + return path.join(home, 'Library', 'Application Support', 'CodeBuddyExtension', 'Data'); +} + +/** 常规实例:/CodeBuddyIDE//history */ +function historyRootA(home: string): string { + return path.join(userDataBase(home), 'ext-a', 'CodeBuddyIDE', 'ext-a', 'history'); +} + +/** default 实例:/CodeBuddyIDE/history(少一层 instId) */ +function historyRootB(home: string): string { + return path.join(userDataBase(home), 'ext-b', 'CodeBuddyIDE', 'history'); +} + +function writeMsg(msgDir: string, id: string, role: string, content: unknown[], createdAt: string): void { + fs.mkdirSync(msgDir, { recursive: true }); + fs.writeFileSync( + path.join(msgDir, `${id}.json`), + JSON.stringify( + { + role, + message: JSON.stringify({ role, content }), + id, + extra: JSON.stringify({ modelName: 'custom-local:test-model' }), + createdAt, + }, + null, + 2, + ), + ); +} + +interface FixtureConv { + id: string; + name?: string; + messages: Array<{ id: string; role: string; content: unknown[] }>; +} + +/** 在某个 history 根下建一个工作区及其会话。 */ +function buildWorkspace( + historyRoot: string, + cwd: string, + convs: FixtureConv[], +): string { + const wsDir = path.join(historyRoot, md5(cwd)); + fs.mkdirSync(wsDir, { recursive: true }); + + const index = { + conversations: convs.map((c) => ({ + id: c.id, + type: 'craft', + name: c.name ?? '', + createdAt: '2026-09-01T10:00:00.000Z', + lastMessageAt: '2026-09-01T11:00:00.000Z', + })), + current: convs[0]?.id, + }; + fs.writeFileSync(path.join(wsDir, 'index.json'), JSON.stringify(index, null, 2)); + + for (const c of convs) { + const convDir = path.join(wsDir, c.id); + const msgDir = path.join(convDir, 'messages'); + fs.mkdirSync(msgDir, { recursive: true }); + // 顺序故意与文件名字典序相反,验证顺序来自 index.json 而非文件名 + fs.writeFileSync( + path.join(convDir, 'index.json'), + JSON.stringify({ messages: c.messages.map((m) => ({ id: m.id, role: m.role, isComplete: true })), requests: [] }, null, 2), + ); + c.messages.forEach((m, i) => { + writeMsg(msgDir, m.id, m.role, m.content, new Date(Date.UTC(2026, 8, 1, 10, i)).toISOString()); + }); + } + + return wsDir; +} + +beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-cbide-')); + mocks.home = tmpHome; +}); + +afterEach(() => { + mocks.home = ''; + fs.rmSync(tmpHome, { recursive: true, force: true }); +}); + +describe('listIdeHistoryRoots', () => { + it('covers both layouts, including the default instance without an instId level', () => { + fs.mkdirSync(historyRootA(tmpHome), { recursive: true }); + fs.mkdirSync(historyRootB(tmpHome), { recursive: true }); + + const roots = listIdeHistoryRoots(); + expect(roots).toContain(historyRootA(tmpHome)); + expect(roots).toContain(historyRootB(tmpHome)); + }); + + it('returns empty when CodeBuddy IDE has never been launched', () => { + expect(listIdeHistoryRoots()).toEqual([]); + }); +}); + +describe('readIdeConversation', () => { + it('follows the message order from index.json, not filename order', () => { + const cwd = '/tmp/project-a'; + const conv: FixtureConv = { + id: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', + name: 'order test', + messages: [ + { id: 'zzz', role: 'user', content: [{ type: 'text', text: 'first' }] }, + { id: 'aaa', role: 'assistant', content: [{ type: 'text', text: 'second' }] }, + ], + }; + const wsDir = buildWorkspace(historyRootA(tmpHome), cwd, [conv]); + const convDir = path.join(wsDir, conv.id); + + const messages = readIdeConversation(convDir); + expect(messages.map((m) => m.content[0]?.text)).toEqual(['first', 'second']); + }); + + it('stops at the limit so listing does not read whole conversations', () => { + const cwd = '/tmp/project-a'; + const conv: FixtureConv = { + id: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2', + name: 'limit test', + messages: Array.from({ length: 10 }, (_, i) => ({ + id: `m${i}`, + role: 'user', + content: [{ type: 'text', text: `msg-${i}` }], + })), + }; + const wsDir = buildWorkspace(historyRootA(tmpHome), cwd, [conv]); + const convDir = path.join(wsDir, conv.id); + + expect(readIdeConversation(convDir, 3)).toHaveLength(3); + }); +}); + +describe('CodeBuddyIdeAdapter', () => { + const cwdA = '/tmp/ws-a'; + const cwdB = '/tmp/ws-b'; + + function seed(): void { + buildWorkspace(historyRootA(tmpHome), cwdA, [ + { + id: '33333333333333333333333333333333', + name: '项目 A 的会话', + messages: [ + { id: 'u1', role: 'user', content: [{ type: 'text', text: '你好' }] }, + { + id: 'a1', + role: 'assistant', + content: [ + { type: 'reasoning', text: '想一下' }, + { type: 'text', text: '收到' }, + { type: 'tool-call', toolCallId: 'c1', toolName: 'read_file', args: { p: '/x' } }, + ], + }, + { + id: 't1', + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'read_file', + result: { status: 'success', success: true, result: { type: 'text_result', content: 'ok' } }, + }, + ], + }, + ], + }, + ]); + buildWorkspace(historyRootB(tmpHome), cwdB, [ + { + id: '22222222222222222222222222222222', + name: '', + messages: [ + { + id: 'u2', + role: 'user', + content: [{ type: 'text', text: 'ignore me' }], + }, + ], + }, + ]); + } + + it('filters by workspace hash instead of treating the workspace dir as a history root', async () => { + seed(); + const adapter = new CodeBuddyIdeAdapter(); + + const scoped = await adapter.listConversations(cwdA); + expect(scoped).toHaveLength(1); + expect(scoped[0].title).toBe('项目 A 的会话'); + expect(scoped[0].cwd).toBe(cwdA); + expect(scoped[0].messageCount).toBe(3); + + const all = await adapter.listConversations(); + expect(all).toHaveLength(2); + }); + + it('marks the workspace as unknown when no cwd is supplied', async () => { + seed(); + const adapter = new CodeBuddyIdeAdapter(); + const [meta] = await adapter.listConversations(); + expect(meta.cwd).toMatch(/^md5:[0-9a-f]{32}$/); + }); + + it('falls back to a session id title when the first message is injected context', async () => { + seed(); + const adapter = new CodeBuddyIdeAdapter(); + const session = await adapter.readSession('22222222222222222222222222222222'); + expect(session.title).toBe('Session 22222222'); + }); + + it('converts IDE blocks into IR, folding tool results into a user message', async () => { + seed(); + const adapter = new CodeBuddyIdeAdapter(); + const session = await adapter.readSession('33333333333333333333333333333333', cwdA); + + expect(session.platform).toBe('codebuddy-ide'); + expect(session.cwd).toBe(cwdA); + expect(session.messages).toHaveLength(3); + + const assistant = session.messages[1]; + expect(assistant.role).toBe('assistant'); + expect(assistant.content.map((b) => b.type)).toEqual(['thinking', 'text', 'tool_call']); + expect(assistant.content[1]).toMatchObject({ type: 'text', text: '收到' }); + expect(assistant.content[2]).toMatchObject({ type: 'tool_call', toolName: 'read_file', callId: 'c1' }); + + const toolMsg = session.messages[2]; + expect(toolMsg.role).toBe('user'); + expect(toolMsg.content[0]).toMatchObject({ type: 'tool_result', callId: 'c1', content: 'ok', isError: false }); + expect(session.metadata?.model).toBe('test-model'); + }); + + it('reuses the 32-hex conversation id across write, read, and delete', async () => { + seed(); + const adapter = new CodeBuddyIdeAdapter(); + const targetCwd = path.join(tmpHome, 'ws-target'); + fs.mkdirSync(targetCwd, { recursive: true }); + + const source = await adapter.readSession('33333333333333333333333333333333', cwdA); + const writtenId = await adapter.writeSession({ ...source, title: 'round trip' }, targetCwd); + expect(writtenId).toBe('33333333333333333333333333333333'); + + const back = await adapter.readSession(writtenId, targetCwd); + expect(back.title).toBe('round trip'); + expect(back.messages).toHaveLength(source.messages.length); + + expect(await adapter.deleteSession(writtenId, targetCwd)).toBe(true); + + // 写入时两个 IDE 实例都写了,删除要两个都清掉 + const targetWsDirs = listIdeHistoryRoots().map((r) => path.join(r, md5(targetCwd))); + expect(targetWsDirs.length).toBeGreaterThan(0); + for (const wsDir of targetWsDirs) { + expect(fs.existsSync(path.join(wsDir, writtenId))).toBe(false); + } + + // 同名会话在别的工作区另有副本,按 cwd 限定删除不能连带误删 + expect(fs.existsSync(path.join(historyRootA(tmpHome), md5(cwdA), writtenId))).toBe(true); + }); + + it('refuses to write when the target cwd is not an absolute path', async () => { + seed(); + const adapter = new CodeBuddyIdeAdapter(); + const source = await adapter.readSession('33333333333333333333333333333333', cwdA); + + await expect(adapter.writeSession({ ...source, cwd: 'md5:abc' })).rejects.toThrow(/absolute working directory/); + }); + + it('does not relabel the workspace when the global fallback finds the session elsewhere', async () => { + seed(); + const adapter = new CodeBuddyIdeAdapter(); + + // 会话在 cwdA 工作区,但用另一个 projectPath 读取 → 全局兜底命中。 + // cwd 必须标 md5 占位(真实工作区不可逆),绝不能冒充传入路径, + // 否则下游按 session.cwd 派生的归档键会跟着错。 + const session = await adapter.readSession('33333333333333333333333333333333', '/tmp/other-project'); + expect(session.cwd).toBe(`md5:${md5(cwdA)}`); + + // 对照:projectPath 与会话所在工作区一致时,cwd 就是该路径 + const direct = await adapter.readSession('33333333333333333333333333333333', cwdA); + expect(direct.cwd).toBe(cwdA); + }); +}); diff --git a/src/__tests__/fidelity-sweep.test.ts b/src/__tests__/fidelity-sweep.test.ts new file mode 100644 index 00000000..5ecc41b4 --- /dev/null +++ b/src/__tests__/fidelity-sweep.test.ts @@ -0,0 +1,570 @@ +/** + * fidelity-sweep.test.ts — 跨平台迁移数据保真度扫描(QA 专用,不进 CI)。 + * + * 构造丰富内容 fixture(多轮对话/中文/代码块/反引号/超长文本>10KB/thinking/ + * tool-call 配对/空 content/emoji/嵌套 JSON/Markdown),对 5 条平台组合做 + * 三腿往返(A 写→读 → B 写→读 → A' 写→读),逐条对比消息条数/role/文本/ + * thinking/tool 配对/时间戳/title/sessionId 稳定性,并核查 fidelityScore 诚实度、 + * flattenDag 多分支行为、500+ 消息压力耗时与内存。 + * + * 输出 JSON 报告到 /tmp/session-fidelity-report.json(含 diffs 明细)。 + */ +import { describe, it, vi, afterAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const mocks = vi.hoisted(() => ({ home: '' })); + +vi.mock('node:os', async (importOriginal) => { + const actual = (await importOriginal()) as unknown as Record & { default?: object }; + const patched: Record = { ...actual, homedir: () => mocks.home }; + patched.default = { ...(actual.default ?? {}), homedir: () => mocks.home }; + return patched; +}); + +import { ClaudeCodeAdapter } from '../session-flow/adapters/claude-code.js'; +import { CodeBuddyAdapter } from '../session-flow/adapters/codebuddy.js'; +import { CodeBuddyIdeAdapter } from '../session-flow/adapters/codebuddy-ide.js'; +import { CodexAdapter } from '../session-flow/adapters/codex.js'; +import { CursorAdapter } from '../session-flow/adapters/cursor.js'; +import { WorkBuddyAdapter } from '../session-flow/adapters/workbuddy.js'; +import * as crypto from 'node:crypto'; +import { degradeThinkingBlocks, fidelityFromSession } from '../session-flow/migrate.js'; +import type { Session, Message } from '../session-flow/ir.js'; +import type { AgentAdapter } from '../session-flow/adapters/base.js'; +import { encodeCwdClaude } from '../session-flow/fs.js'; + +// --------------------------------------------------------------------------- +// 报告容器 +// --------------------------------------------------------------------------- + +const REPORT: Record = { routes: [], extras: {}, stress: {}, fidelityAudit: [] }; +const REPORT_PATH = '/tmp/session-fidelity-report.json'; + +afterAll(() => { + fs.writeFileSync(REPORT_PATH, JSON.stringify(REPORT, null, 2), 'utf-8'); +}); + +// --------------------------------------------------------------------------- +// Fixture +// --------------------------------------------------------------------------- + +const LONG_10KB = + '超长内容块测试。This line contains 中文, English, emoji 🚀🔥, backticks ```, quotes "double" \'single\', ' + + 'tabs\tand\nnewlines, JSON {"nested":{"deep":[1,2,{"x":"y"}]}}, markdown **bold** and `inline code`.\n' + .repeat(120); // ~12KB + +function buildFixture(): Session { + const t = (i: number) => new Date(Date.UTC(2026, 8, 1, 10, 0, i)).toISOString(); + const messages: Message[] = [ + { + role: 'user', + timestamp: t(0), + messageId: 'm0', + content: [ + { + type: 'text', + text: [ + '第一轮提问:请帮我检查下面的代码块(含反引号/中文/emoji 🎉):', + '', + '```python', + 'def foo(s: str) -> str:', + ' return f"前缀-{s}" # 注释 "引号" \'单引号\'', + '```', + '', + '特殊字符:\\\\ \t \\u00e9 ¥ € "triple"""\'\'\' & ${template}', + ].join('\n'), + }, + { type: 'text', text: '' }, // 空 text 块 + ], + }, + { + role: 'assistant', + timestamp: t(1), + messageId: 'm1', + metadata: { model: 'test-model-x' }, + content: [ + { type: 'thinking', text: '思考:需要先读取配置文件,路径含中文与空格。🤔' }, + { type: 'text', text: '# 分析\n\n- 要点一\n- 要点二 **加粗**\n\n```js\nconst a = 1; // `内嵌反引号`\n```' }, + { + type: 'tool_call', + toolName: 'read_file', + callId: 'call-1', + arguments: { path: '/x/中文 文件.md', options: { depth: 2, flag: true, tags: ['a', 'b'] } }, + }, + ], + }, + { + role: 'user', + timestamp: t(2), + messageId: 'm2', + content: [{ type: 'tool_result', callId: 'call-1', content: LONG_10KB, isError: false }], + }, + { + role: 'assistant', + timestamp: t(3), + messageId: 'm3', + content: [ + { type: 'tool_call', toolName: 'bash', callId: 'call-2', arguments: { cmd: 'echo "done" && ls -la' } }, + ], + }, + { + role: 'user', + timestamp: t(4), + messageId: 'm4', + content: [{ type: 'tool_result', callId: 'call-2', content: 'boom: exit 1 ❌', isError: true }], + }, + { role: 'user', timestamp: t(5), messageId: 'm5', content: [{ type: 'text', text: '第二轮提问:总结一下。' }] }, + { + role: 'assistant', + timestamp: t(6), + messageId: 'm6', + content: [ + { type: 'thinking', text: '思考:用户要总结,要点有三。' }, + { type: 'text', text: '总结 ✅:\n\n1. 配置读取正常\n2. 命令失败已上报\n\n嵌套 JSON:' + JSON.stringify({ a: { b: { c: ['深', '层'] } }, emoji: '🐉' }) }, + ], + }, + { role: 'user', timestamp: t(7), messageId: 'm7', content: [] }, // 空 content 消息 + { + role: 'assistant', + timestamp: t(8), + messageId: 'm8', + content: [{ type: 'text', text: LONG_10KB + '\n\n结尾标记 END-OF-LONG ✅' }], + }, + ]; + return { + sessionId: 'a1b2c3d4-1111-4222-8333-444455556666', + title: '保真度扫描 Fixture 🚀 往返测试', + cwd: '/fixture/proj-a', + platform: 'fixture', + createdAt: t(0), + updatedAt: t(9), + messages, + metadata: { model: 'test-model-x' }, + }; +} + +// --------------------------------------------------------------------------- +// 对比工具 +// --------------------------------------------------------------------------- + +interface RouteDiff { leg: string; diffs: string[] } + +function textOf(msg: Message): string { + return msg.content.filter((b) => b.type === 'text').map((b) => (b as { text: string }).text).join('\n'); +} +function thinkingOf(msg: Message): string { + return msg.content.filter((b) => b.type === 'thinking').map((b) => (b as { text: string }).text).join('\n'); +} +function callsOf(msg: Message): Array<{ toolName: string; callId: string; arguments: unknown }> { + return msg.content + .filter((b) => b.type === 'tool_call') + .map((b) => { + const c = b as { toolName: string; callId: string; arguments: unknown }; + return { toolName: c.toolName, callId: c.callId, arguments: c.arguments }; + }); +} +function resultsOf(msg: Message): Array<{ callId: string; content: string; isError: boolean }> { + return msg.content + .filter((b) => b.type === 'tool_result') + .map((b) => { + const c = b as { callId: string; content: string; isError: boolean }; + return { callId: c.callId, content: c.content, isError: c.isError }; + }); +} + +function trunc(s: string, n = 160): string { + const flat = s.replace(/\n/g, '\\n'); + return flat.length > n ? flat.slice(0, n) + `…(len=${flat.length})` : flat; +} + +/** + * 逐条对比 before → after。expectDegrade=true 时不把 thinking/tool_result 的 + * 平台级降级算作 diff(但 callId 丢失/内容变形仍算)。 + */ +function compareSessions(before: Session, after: Session, leg: string, expectDegrade: boolean): RouteDiff { + const diffs: string[] = []; + const bm = before.messages; + const am = after.messages; + + if (bm.length !== am.length) { + diffs.push(`MESSAGE_COUNT: before=${bm.length} after=${am.length} (delta=${am.length - bm.length})`); + } + + const n = Math.min(bm.length, am.length); + for (let i = 0; i < n; i++) { + const b = bm[i]; + const a = am[i]; + const tag = `msg[${i}](${b.role})`; + if (b.role !== a.role) diffs.push(`${tag}.ROLE: ${b.role} → ${a.role}`); + + // 文本逐字对比(cursor 会把 tool_result/thinking 降级为 text,单独处理) + const bt = textOf(b); + const at = textOf(a); + if (bt !== at) { + if (expectDegrade) { + // 降级后文本 = 原文本 + 包裹前缀,检查原文本是否完整包含于新文本 + const wrapped = bt !== '' && at.includes(bt); + const degradedOk = a.content.some( + (blk) => blk.type === 'text' && (blk as { text: string }).text.includes('[tool_result'), + ); + if (!wrapped || !degradedOk) { + diffs.push(`${tag}.TEXT_DEGRADED_MISMATCH: before=${trunc(bt)} after=${trunc(at)}`); + } + } else { + diffs.push(`${tag}.TEXT: before=${trunc(bt)} after=${trunc(at)}`); + } + } + + // thinking(仅双方都支持时) + if (!expectDegrade) { + const bth = thinkingOf(b); + const ath = thinkingOf(a); + if (bth !== ath) { + diffs.push(`${tag}.THINKING: before=${trunc(bth)} after=${trunc(ath)}`); + } + } + + // tool_call + const bc = callsOf(b); + const ac = callsOf(a); + if (JSON.stringify(bc) !== JSON.stringify(ac)) { + diffs.push(`${tag}.TOOL_CALL: before=${JSON.stringify(bc).slice(0, 300)} after=${JSON.stringify(ac).slice(0, 300)}`); + } + + // tool_result + const br = resultsOf(b); + const ar = resultsOf(a); + if (JSON.stringify(br) !== JSON.stringify(ar)) { + diffs.push(`${tag}.TOOL_RESULT: before=${JSON.stringify(br).slice(0, 300)} after=${JSON.stringify(ar).slice(0, 300)}`); + } + + // 时间戳:允许精度损失,不允许错位/反转 + const btMs = b.timestamp ? Date.parse(b.timestamp) : NaN; + const atMs = a.timestamp ? Date.parse(a.timestamp) : NaN; + if (!isNaN(btMs) && isNaN(atMs)) diffs.push(`${tag}.TS_LOST: before=${b.timestamp} after=undefined`); + if (!isNaN(btMs) && !isNaN(atMs)) { + const drift = atMs - btMs; + if (drift < -2000) diffs.push(`${tag}.TS_BACKWARD: drift=${drift}ms (before=${b.timestamp} after=${a.timestamp})`); + else if (drift > 60000) diffs.push(`${tag}.TS_SHIFTED: drift=${drift}ms (before=${b.timestamp} after=${a.timestamp})`); + } + } + + // after 内部时间戳单调性 + for (let i = 1; i < am.length; i++) { + const p = am[i - 1].timestamp ? Date.parse(am[i - 1].timestamp!) : NaN; + const q = am[i].timestamp ? Date.parse(am[i].timestamp!) : NaN; + if (!isNaN(p) && !isNaN(q) && q < p - 2000) { + diffs.push(`TS_NON_MONOTONIC: msg[${i - 1}]→msg[${i}] delta=${q - p}ms`); + break; + } + } + + if (before.title !== after.title) { + diffs.push(`TITLE: before="${trunc(before.title, 80)}" after="${trunc(after.title, 80)}"`); + } + return { leg, diffs }; +} + +// --------------------------------------------------------------------------- +// 平台沙箱 +// --------------------------------------------------------------------------- + +let tmpHome = ''; + +function newWorkspace(name: string): string { + const dir = path.join(tmpHome, name); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function ideHistoryRoot(): string { + const root = path.join( + tmpHome, + 'Library', + 'Application Support', + 'CodeBuddyExtension', + 'Data', + 'ext-x', + 'CodeBuddyIDE', + 'ext-x', + 'history', + ); + fs.mkdirSync(root, { recursive: true }); + return root; +} + +function makeAdapter(platform: string): AgentAdapter { + switch (platform) { + case 'claude-code': + return new ClaudeCodeAdapter('claude-code'); + case 'codebuddy': + return new CodeBuddyAdapter(); + case 'codebuddy-ide': + return new CodeBuddyIdeAdapter(); + case 'codex': + return new CodexAdapter('codex'); + case 'cursor': + return new CursorAdapter(); + case 'workbuddy': + return new WorkBuddyAdapter(); + default: + throw new Error(`unknown platform ${platform}`); + } +} + +// --------------------------------------------------------------------------- +// 路由扫描 +// --------------------------------------------------------------------------- + +async function sweepRoute(aName: string, bName: string): Promise { + const A = makeAdapter(aName) as never as AgentAdapter & { readSession: Function; writeSession: Function }; + const B = makeAdapter(bName) as never as AgentAdapter & { readSession: Function; writeSession: Function }; + const cwdA = newWorkspace(`proj-${aName.replace(/[^a-z0-9]/gi, '-')}`); + const cwdB = newWorkspace(`proj-${bName.replace(/[^a-z0-9]/gi, '-')}`); + const cwdA2 = newWorkspace(`proj2-${aName.replace(/[^a-z0-9]/gi, '-')}`); + + const fixture = buildFixture(); + const sidA = await (A as any).writeSession(fixture, cwdA); + const s1 = await (A as any).readSession(sidA, cwdA); + + const enhanced = degradeThinkingBlocks(s1, bName); + const sidB = await (B as any).writeSession(enhanced, cwdB); + const s2 = await (B as any).readSession(sidB, cwdB); + + const sidA2 = await (A as any).writeSession(s2, cwdA2); + const s3 = await (A as any).readSession(sidA2, cwdA2); + + const expectDegrade = bName === 'cursor'; + const leg1 = compareSessions(s1, s2, `${aName}→${bName}`, expectDegrade); + const leg2 = compareSessions(s2, s3, `${bName}→${aName}`, aName === 'cursor'); + const full = compareSessions(s1, s3, `${aName}→${bName}→${aName}`, false); + + const fid = fidelityFromSession(s1, bName); + + (REPORT.routes as unknown[]).push({ + route: `${aName} → ${bName} → ${aName}`, + sessionIds: { leg0: sidA, leg1: sidB, leg2: sidA2, stableLeg0to1: sidA === sidB, stableLeg1to2: sidB === sidA2 }, + s1: { messages: s1.messages.length, title: s1.title, cwd: s1.cwd }, + s2: { messages: s2.messages.length, title: s2.title }, + s3: { messages: s3.messages.length, title: s3.title }, + fidelityScoreAtSource: { score: Number(fid.score.toFixed(4)), degraded: fid.degradedBlocks, warnings: fid.warnings, platformSpecificLosses: fid.platformSpecificLosses }, + leg1, + leg2, + full, + }); +} + +describe('fidelity sweep (QA, writes report to /tmp/session-fidelity-report.json)', () => { + it('runs all 5 roundtrip routes', async () => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-fidelity-')); + mocks.home = tmpHome; + ideHistoryRoot(); + try { + await sweepRoute('codebuddy-ide', 'claude-code'); + await sweepRoute('codebuddy', 'claude-code'); + await sweepRoute('claude-code', 'cursor'); + await sweepRoute('workbuddy', 'claude-code'); + await sweepRoute('codex', 'claude-code'); + } finally { + mocks.home = ''; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }, 120_000); + + // ------------------------------------------------------------------------- + + it('flattenDag: multi-branch + sidechain behavior', async () => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-fidelity-dag-')); + mocks.home = tmpHome; + try { + const cwd = newWorkspace('dag-proj'); + const ts = '2026-09-01T10:00:00.000Z'; + const rec = (uuid: string, parentUuid: string | null, text: string, extra: Record = {}) => ({ + parentUuid, + isSidechain: false, + type: 'user', + message: { role: 'user', content: [{ type: 'text', text }] }, + uuid, + timestamp: ts, + cwd, + sessionId: 'dag00000-0000-4000-8000-000000000000', + version: '2.1.221', + userType: 'external', + entrypoint: 'cli', + ...extra, + }); + const records = [ + rec('u1', null, 'Q1'), + { ...rec('a1', 'u1', 'A1'), type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'A1' }] } }, + rec('u2', 'a1', 'Q2-main'), // 主线分叉 + { ...rec('a1b', 'a1', 'A1-branch2'), type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'A1-branch2' }] } }, // 文件序在 u2 之后 + rec('side1', 'a1', 'SIDECHAIN-Q', { isSidechain: true }), // 侧链 + { ...rec('a2', 'u2', 'A2'), type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'A2' }] } }, + // 孤儿节点:parentUuid 指向不存在记录 + rec('orphan', 'ghost-uuid', 'ORPHAN-Q'), + ]; + const adapter = new ClaudeCodeAdapter('claude-code'); + // 直接写入编码目录(必须复用适配器编码逻辑:macOS /var → /private/var realpath) + const dir = path.join(tmpHome, '.claude', 'projects', encodeCwdClaude(cwd)); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'dag00000-0000-4000-8000-000000000000.jsonl'), + records.map((r) => JSON.stringify(r)).join('\n') + '\n', + ); + + const session = (await (adapter as any).readSession('dag00000-0000-4000-8000-000000000000', cwd)) as Session; + const outline = session.messages.map((m) => `${m.role}:${textOf(m)}`); + (REPORT.extras as Record).flattenDag = { + expectedFileOrder: ['user:Q1', 'assistant:A1', 'user:Q2-main', 'assistant:A1-branch2', 'user:SIDECHAIN-Q', 'assistant:A2', 'user:ORPHAN-Q'], + actualReadOrder: outline, + sidechainDropped: !outline.some((o) => o.includes('SIDECHAIN-Q')), + branchInterleaved: outline.indexOf('assistant:A1-branch2') > -1 && outline.indexOf('assistant:A1-branch2') < outline.indexOf('assistant:A2'), + orphanKept: outline.some((o) => o.includes('ORPHAN-Q')), + }; + } finally { + mocks.home = ''; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + // ------------------------------------------------------------------------- + + it('fidelityScore honesty audit', async () => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-fidelity-f-')); + mocks.home = tmpHome; + ideHistoryRoot(); + try { + const audit = REPORT.fidelityAudit as Array>; + const mk = (name: string, adapter: any, read: () => Promise, target = 'claude-code') => + read().then((s) => { + const fid = fidelityFromSession(s, target); + audit.push({ + case: name, + score: Number(fid.score.toFixed(4)), + degradedBlocks: fid.degradedBlocks, + lostBlocks: fid.lostBlocks, + warnings: fid.warnings, + platformSpecificLosses: fid.platformSpecificLosses, + messageCount: s.messages.length, + }); + return s; + }); + + // F1: IDE 侧图片块(IR 无对应类型)被静默丢弃 → score 仍 1.0 + const ide = new CodeBuddyIdeAdapter(); + const cwd = newWorkspace('f1-proj'); + const root = ideHistoryRoot(); + const convId = 'f1111111111111111111111111111111'; + const wsDir = path.join(root, crypto.createHash('md5').update(fs.realpathSync(cwd)).digest('hex')); + const msgDir = path.join(wsDir, convId, 'messages'); + fs.mkdirSync(msgDir, { recursive: true }); + const msgs = [ + { id: 'u1', role: 'user', content: [{ type: 'text', text: '看这张图' }] }, + { id: 'a1', role: 'assistant', content: [{ type: 'image', data: 'BASE64-PICTURE-DATA' }, { type: 'text', text: '图已收到' }] }, + ]; + fs.writeFileSync(path.join(wsDir, convId, 'index.json'), JSON.stringify({ messages: msgs.map((m) => ({ id: m.id })), requests: [] })); + msgs.forEach((m) => + fs.writeFileSync( + path.join(msgDir, `${m.id}.json`), + JSON.stringify({ role: m.role, message: JSON.stringify({ role: m.role, content: m.content }), id: m.id, extra: '{}', createdAt: '2026-09-01T10:00:00.000Z' }), + ), + ); + fs.writeFileSync(path.join(wsDir, 'index.json'), JSON.stringify({ conversations: [{ id: convId, type: 'craft', name: 'F1', createdAt: '2026-09-01T10:00:00.000Z', lastMessageAt: '2026-09-01T10:00:00.000Z' }] })); + await mk('F1 codebuddy-ide image block silently dropped', ide, () => ide.readSession(convId, cwd)); + + // F2: 空 content 消息被多平台写入端静默丢弃(score 按 block 计,看不见消息级丢失) + const fixtureEmpty = buildFixture(); + const cursor = new CursorAdapter(); + const claude = new ClaudeCodeAdapter('claude-code'); + const fidEmpty = fidelityFromSession(fixtureEmpty, 'cursor'); + audit.push({ case: 'F2 fixture(empty msg) fidelity→cursor', score: Number(fidEmpty.score.toFixed(4)), note: '空消息 0 block,不扣分;但 cursor.writeSession 会静默丢弃该消息' }); + + // F3: claude sidechain 在 readSession 拍平时已丢,fidelity 输入侧就看不到 + audit.push({ case: 'F3 claude sidechain', note: 'fidelity 在 readSession 之后计算,sidechain/孤儿分支的块不进入 IR,永远不计入损失' }); + + // F4: cursor 作为目标时 tool_result 降级是否被正确计分 + const fidCursor = fidelityFromSession(buildFixture(), 'cursor'); + audit.push({ case: 'F4 fixture fidelity→cursor', score: Number(fidCursor.score.toFixed(4)), degradedBlocks: fidCursor.degradedBlocks, platformSpecificLosses: fidCursor.platformSpecificLosses }); + void cursor; void claude; + } finally { + mocks.home = ''; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + // ------------------------------------------------------------------------- + + it('stress: 600-message session migrate timing + memory', async () => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-fidelity-stress-')); + mocks.home = tmpHome; + try { + const cwd = newWorkspace('stress-proj'); + const messages: Message[] = []; + for (let i = 0; i < 300; i++) { + messages.push({ + role: 'user', + timestamp: new Date(Date.UTC(2026, 8, 1, 8, 0, i * 2)).toISOString(), + messageId: `su${i}`, + content: [ + { type: 'text', text: `压力测试第 ${i} 轮提问 🚀:${'内容填充'.repeat(20)}` }, + { type: 'tool_result', callId: `sc-${i}`, content: `result-${i}-${'x'.repeat(500)}`, isError: i % 7 === 0 }, + ], + }); + messages.push({ + role: 'assistant', + timestamp: new Date(Date.UTC(2026, 8, 1, 8, 0, i * 2 + 1)).toISOString(), + messageId: `sa${i}`, + content: [ + { type: 'thinking', text: `思考 ${i}` }, + { type: 'text', text: `回答 ${i}:${'分析'.repeat(30)}` }, + { type: 'tool_call', toolName: 'bash', callId: `sc-${i}`, arguments: { cmd: `echo ${i}` } }, + ], + }); + } + const session: Session = { + sessionId: 'b1b2c3d4-1111-4222-8333-444455556666', + title: '压力测试 600 消息', + cwd, + platform: 'fixture', + createdAt: messages[0].timestamp!, + updatedAt: messages[messages.length - 1].timestamp!, + messages, + }; + + const mem0 = process.memoryUsage(); + const claude = new ClaudeCodeAdapter('claude-code'); + let t0 = Date.now(); + void claude.writeSession(session, cwd); + const claudeWriteMs = Date.now() - t0; + t0 = Date.now(); + const back = (await claude.readSession(session.sessionId, cwd)) as Session; + const claudeReadMs = Date.now() - t0; + + const codex = new CodexAdapter('codex'); + t0 = Date.now(); + // 注意:codex.writeSession 对非 UUIDv7 的 id 会重新生成 —— 这里本就是一个发现 + const stressSid = (await codex.writeSession(session, cwd)) as string; + const codexWriteMs = Date.now() - t0; + t0 = Date.now(); + await codex.readSession(stressSid); + const codexReadMs = Date.now() - t0; + + const mem1 = process.memoryUsage(); + REPORT.stress = { + messageCount: session.messages.length, + claudeWriteMs, + claudeReadMs, + codexWriteMs, + codexReadMs, + claudeReadBackMessages: back.messages.length, + heapDeltaMB: Number(((mem1.heapUsed - mem0.heapUsed) / 1048576).toFixed(1)), + rssMB: Number((mem1.rss / 1048576).toFixed(1)), + }; + } finally { + mocks.home = ''; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }, 180_000); +}); diff --git a/src/__tests__/session-cmd.test.ts b/src/__tests__/session-cmd.test.ts new file mode 100644 index 00000000..81e01904 --- /dev/null +++ b/src/__tests__/session-cmd.test.ts @@ -0,0 +1,552 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Command } from 'commander'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * session-cmd 子命令测试(M2 消费端)。 + * + * 覆盖点: + * - list --all 的 SOURCE 列;非 --all 保持单项目格式 + * - pull --all 对每个 identity 幂等重建索引 + * - search --all 含 _unattributed 且当前 repo 不被二次加载;非 --all 只搜当前 repo + * - push --all 忽略 --limit、>5 条确认(可拒绝/可 -y 跳过)、每条打印来源目录 + * - 归档键不变式:运行目录与会话 cwd 是两个项目时,归档到会话 cwd 的 identity + * - codebuddy-ide 的 md5: 占位 → _unattributed + 英文警告 + * - migrate --push 归档键来自会话原生 cwd + * - 全部新输出为英文 + */ + +const mocks = vi.hoisted(() => ({ + /** cwd → git remote url(getRepoIdentity 的 mock 数据) */ + remotes: {} as Record, + /** `git status --porcelain -- sessions/` 的返回值;空串 = 无变更可提交 */ + porcelain: 'M sessions/changed\n', + gitCalls: [] as Array<{ args: string[]; cwd?: string }>, + adaptersByPlatform: {} as Record, + /** migrate.js mock 的 preview/migrate 返回值 */ + previewResult: null as unknown, + migrateResult: null as unknown, + /** readline mock:ask() 等待输入时捕获的 'line' 回调 */ + lineCb: null as ((line: string) => void) | null, + lineArmed: null as (() => void) | null, + /** 真实适配器回归用例的假 HOME(适配器存储路径由 os.homedir() 派生) */ + home: '', +})); + +// 适配器存储路径全部由 os.homedir() 派生;像 codebuddy-ide-adapter.test.ts 一样 +// 整体替换 home 才能用临时目录做 fixture(spy 在 ESM namespace import 下不生效)。 +vi.mock('node:os', async (importOriginal) => { + const actual = (await importOriginal()) as Record & { default?: object }; + const patched: Record = { ...actual, homedir: () => mocks.home }; + patched.default = { ...(actual.default ?? {}), homedir: () => mocks.home }; + return patched; +}); + +vi.mock('node:child_process', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + execFileSync: (cmd: string, args: string[], opts?: { cwd?: string }) => { + if (cmd !== 'git') throw new Error(`unexpected command: ${cmd}`); + mocks.gitCalls.push({ args, cwd: opts?.cwd }); + if (args[0] === 'config') return 'tester\n'; + if (args[0] === 'remote') { + const url = opts?.cwd ? mocks.remotes[opts.cwd] : undefined; + if (!url) throw new Error('not a git repository'); + return `${url}\n`; + } + if (args[0] === 'status') return mocks.porcelain; + if (args[0] === 'rev-parse') return 'abc123def456\n'; + return ''; // add / commit / push / pull + }, + }; +}); + +// ask() 走 readline;mock 掉 createInterface,把 'line' 回调暴露给测试, +// 确认类提示("Push all of the above?")由测试主动喂答案。 +vi.mock('node:readline', async (importOriginal) => { + const actual = (await importOriginal()) as Record & { default?: unknown }; + const realDefault = (actual.default ?? {}) as Record; + return { + ...actual, + default: { + ...realDefault, + createInterface: () => ({ + on: (event: string, cb: (line: string) => void) => { + if (event === 'line') { + mocks.lineCb = cb; + if (mocks.lineArmed) mocks.lineArmed(); + } + }, + close: () => {}, + }), + }, + }; +}); + +vi.mock('../session-flow/adapters/index.js', () => ({ + getAdapter: (platform: string) => { + const adapter = mocks.adaptersByPlatform[platform]; + if (!adapter) throw new Error(`Unsupported platform: ${platform}`); + return adapter; + }, + listAvailablePlatforms: () => Object.keys(mocks.adaptersByPlatform), + listInstalledPlatforms: () => [], +})); + +vi.mock('../session-flow/migrate.js', () => ({ + MigrationEngine: class { + constructor( + public readonly source: string, + public readonly target: string, + ) {} + async preview(): Promise { + return mocks.previewResult; + } + async migrate(): Promise { + return mocks.migrateResult; + } + }, +})); + +import { registerSessionFlowCommands } from '../session-flow/session-cmd.js'; +import { SyncManager, defaultSyncMeta } from '../session-flow/sync.js'; +import type { Session } from '../session-flow/ir.js'; +import { ClaudeCodeAdapter } from '../session-flow/adapters/claude-code.js'; +import { CodeBuddyAdapter } from '../session-flow/adapters/codebuddy.js'; +import { CursorAdapter } from '../session-flow/adapters/cursor.js'; + +let repoRoot: string; +/** console.log + process.stdout.write 的合并捕获(ask 的提示走 stdout.write) */ +let out: string[] = []; +let warned: string[] = []; + +beforeEach(() => { + repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-cmd-')); + mocks.home = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-cmd-home-')); + out = []; + warned = []; + mocks.remotes = {}; + mocks.porcelain = 'M sessions/changed\n'; + mocks.gitCalls.length = 0; + mocks.adaptersByPlatform = {}; + mocks.previewResult = null; + mocks.migrateResult = null; + mocks.lineArmed = null; + vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { + out.push(a.map(String).join(' ')); + }); + vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { + warned.push(a.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { + out.push(String(chunk)); + return true; + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + fs.rmSync(mocks.home, { recursive: true, force: true }); + mocks.home = ''; +}); + +async function runSession(...argv: string[]): Promise { + const program = new Command(); + const sessionCmd = program.command('session').description('session commands'); + registerSessionFlowCommands(sessionCmd); + // from:'node'(默认)会消耗 argv[0]=executable、argv[1]=script path, + // 之后才是 program 的子命令路径:session ... + await program.parseAsync(['node', 'teamai', 'session', ...argv]); +} + +function mkSession(o: Partial = {}): Session { + return { + sessionId: 's-1', + title: 'fix payment', + cwd: '/proj/beta', + platform: 'fakeplat', + createdAt: '2026-01-02T03:04:05.000Z', + updatedAt: '2026-01-02T03:05:05.000Z', + messages: [ + { role: 'user', content: [{ type: 'text', text: 'implement payment retry' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'done' }] }, + ], + metadata: {}, + ...o, + }; +} + +/** 注册一个假适配器(listConversations/readSession 都是 spy),返回 adapter 供断言。 */ +function fakeAdapter(sessions: Session[], platform = 'fakeplat') { + const byId = new Map(sessions.map((s) => [s.sessionId, s])); + const adapter = { + platform, + listConversations: vi.fn(async () => + sessions.map((s) => ({ + sessionId: s.sessionId, + title: s.title, + cwd: s.cwd, + platform, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + messageCount: s.messages.length, + filePath: '/tmp/x', + sizeBytes: 100, + })), + ), + readSession: vi.fn(async (id: string) => { + const s = byId.get(id); + if (!s) throw new Error(`session not found: ${id}`); + return s; + }), + }; + mocks.adaptersByPlatform[platform] = adapter; + return adapter; +} + +/** 用真实 SyncManager 落盘一个已归档会话(种子数据)。 */ +function seed(identity: string | null, author: string, sessionId: string, title: string, text: string): void { + const mgr = new SyncManager(repoRoot); + const session = mkSession({ + sessionId, + title, + platform: 'claude-code', + cwd: '/proj/seed', + messages: [{ role: 'user', content: [{ type: 'text', text }] }], + }); + const meta = defaultSyncMeta( + { platform: 'claude-code', author, cwd: '/proj/seed', sessionId, repoIdentity: identity }, + '2026-01-02T00:00:00.000Z', + ); + mgr.saveSession(session, meta); +} + +const CJK = /[\u4e00-\u9fff]/; + +// ───────────────────────────────────────────────────────────── + +describe('session list --all', () => { + it('prints a SOURCE column showing repo identity and _unattributed', async () => { + seed('github.com/org/alpha', 'alice', 'a-1', 'payment alpha', 'alpha payment retry'); + seed(null, 'bob', 'b-1', 'payment plain', 'plain payment notes'); + + await runSession('list', '--all', '--repo-root', repoRoot, '--cwd', '/tmp/nowhere'); + + const text = out.join('\n'); + expect(text).toContain('SOURCE'); + expect(text).toContain('github.com/org/alpha'); + expect(text).toContain('_unattributed'); + expect(text).toContain('2 session(s)'); + expect(text).not.toMatch(CJK); + }); + + it('keeps the single-project format without --all', async () => { + seed('github.com/org/alpha', 'alice', 'a-1', 'payment', 'payment retry'); + mocks.remotes['/run/alpha'] = 'https://github.com/org/alpha.git'; + + await runSession('list', '--repo-root', repoRoot, '--cwd', '/run/alpha'); + + const text = out.join('\n'); + expect(text).toContain('Sessions for github.com/org/alpha:'); + expect(text).not.toContain('SOURCE'); + }); +}); + +describe('session pull --all', () => { + it('rebuilds the index of every repo including _unattributed', async () => { + seed('github.com/org/alpha', 'alice', 'a-1', 'payment', 'payment alpha'); + seed(null, 'bob', 'b-1', 'plain', 'plain notes'); + + // 手动清空 alpha 的索引条目,验证 pull --all 会按磁盘内容幂等重建 + const idxPath = path.join(repoRoot, 'sessions', 'repos', 'github.com_org_alpha', '_index.json'); + fs.writeFileSync( + idxPath, + JSON.stringify({ version: 1, repoIdentity: 'github.com/org/alpha', updatedAt: '2026-01-01T00:00:00.000Z', sessions: [] }), + ); + + await runSession('pull', '--all', '--repo-root', repoRoot); + + const text = out.join('\n'); + expect(text).toContain('Pulled and indexed 2 session(s) across 2 repo(s)'); + expect(mocks.gitCalls.some((c) => c.args[0] === 'pull')).toBe(true); + + const restored = JSON.parse(fs.readFileSync(idxPath, 'utf-8')); + expect(restored.sessions).toHaveLength(1); + expect(restored.sessions[0].sessionId).toBe('a-1'); + expect(text).not.toMatch(CJK); + }); +}); + +describe('session search --all', () => { + it('searches every repo including _unattributed without double-loading the current one', async () => { + seed('github.com/org/alpha', 'alice', 'a-1', 'payment alpha', 'alpha payment retry'); + seed('gitlab.company.com/g/beta', 'bob', 'b-1', 'payment beta', 'beta payment retry'); + seed(null, 'carol', 'c-1', 'payment plain', 'plain payment notes'); + mocks.remotes['/run/alpha'] = 'https://github.com/org/alpha.git'; + + const loadSpy = vi.spyOn(SyncManager.prototype, 'loadSession'); + await runSession('search', 'payment', '--all', '--repo-root', repoRoot, '--cwd', '/run/alpha'); + + const text = out.join('\n'); + expect(text).toContain('3 result(s) found'); + expect(text).toContain('payment-alpha'); + expect(text).toContain('payment-beta'); + expect(text).toContain('payment-plain'); + // 恰好 3 次:当前 repo 不会因为同时也在 --all 清单里被加载两遍 + expect(loadSpy).toHaveBeenCalledTimes(3); + expect(text).not.toMatch(CJK); + }); + + it('scopes search to the current repo without --all', async () => { + seed('github.com/org/alpha', 'alice', 'a-1', 'payment alpha', 'alpha payment retry'); + seed('gitlab.company.com/g/beta', 'bob', 'b-1', 'payment beta', 'beta payment retry'); + mocks.remotes['/run/alpha'] = 'https://github.com/org/alpha.git'; + + const loadSpy = vi.spyOn(SyncManager.prototype, 'loadSession'); + await runSession('search', 'payment', '--repo-root', repoRoot, '--cwd', '/run/alpha'); + + expect(out.join('\n')).toContain('1 result(s) found'); + expect(loadSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('session push --all', () => { + function manySessions(n: number): Session[] { + return Array.from({ length: n }, (_, i) => + mkSession({ sessionId: `s-${i}`, title: `task ${i}`, updatedAt: `2026-01-0${(i % 8) + 1}T00:00:00.000Z` }), + ); + } + + it('ignores --limit and pushes every workspace session', async () => { + const adapter = fakeAdapter(manySessions(8)); + mocks.remotes['/proj/beta'] = 'https://gitlab.com/team/beta.git'; + + await runSession( + 'push', '--source', 'fakeplat', '--all', '-y', + '--repo-root', repoRoot, '--cwd', '/run/dir', '--limit', '2', + ); + + expect(adapter.readSession).toHaveBeenCalledTimes(8); + // --all 枚举全部工作区:listConversations 以无参形式调用 + expect(adapter.listConversations).toHaveBeenCalledWith(); + + const dir = path.join(repoRoot, 'sessions', 'repos', 'gitlab.com_team_beta', 'tester'); + expect(fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'))).toHaveLength(8); + + const text = out.join('\n'); + expect(text).toContain('Pushed 8 session(s) from fakeplat'); + expect(text).toContain('Source: /proj/beta'); + expect(text).not.toMatch(CJK); + }); + + it('asks for confirmation before pushing more than five sessions', async () => { + const adapter = fakeAdapter(manySessions(6)); + mocks.remotes['/proj/beta'] = 'https://gitlab.com/team/beta.git'; + + const armed = new Promise((resolve) => { + mocks.lineArmed = resolve; + }); + const parsed = runSession( + 'push', '--source', 'fakeplat', '--all', + '--repo-root', repoRoot, '--cwd', '/run/dir', + ); + await armed; + mocks.lineCb!('n'); + await parsed; + + const text = out.join('\n'); + expect(text).toContain('About to push 6 session(s) from fakeplat:'); + expect(text).toContain('Push all of the above?'); + expect(text).toContain('Cancelled.'); + // 拒绝后不读取、不落盘任何会话 + expect(adapter.readSession).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(repoRoot, 'sessions'))).toBe(false); + }); + + it('skips the confirmation prompt with -y', async () => { + fakeAdapter(manySessions(6)); + mocks.remotes['/proj/beta'] = 'https://gitlab.com/team/beta.git'; + + await runSession( + 'push', '--source', 'fakeplat', '--all', '-y', + '--repo-root', repoRoot, '--cwd', '/run/dir', + ); + + const text = out.join('\n'); + expect(text).not.toContain('Push all of the above?'); + expect(text).toContain('Pushed 6 session(s) from fakeplat'); + }); + + it('reports no changes when nothing new was committed', async () => { + fakeAdapter([mkSession({ sessionId: 'once-1' })]); + mocks.remotes['/proj/beta'] = 'https://gitlab.com/team/beta.git'; + mocks.porcelain = ''; // git status --porcelain 无暂存变更 → commit 为 null + + await runSession('push', '--source', 'fakeplat', '--repo-root', repoRoot, '--cwd', '/run/dir'); + + const text = out.join('\n'); + expect(text).toContain('No changes to push'); + // 会话文件本身已写入(commit 检测发生在 saveSession 之后) + const dir = path.join(repoRoot, 'sessions', 'repos', 'gitlab.com_team_beta', 'tester'); + expect(fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'))).toHaveLength(1); + expect(mocks.gitCalls.some((c) => c.args[0] === 'push')).toBe(false); + }); +}); + +describe('session push archive key (native cwd, not the run directory)', () => { + it('archives under the session native cwd identity instead of the run directory', async () => { + // 运行目录属于 org/other,会话原生 cwd 属于 team/beta: + // 归档键必须跟会话 cwd 走(Key invariant,设计文档 P3) + fakeAdapter([mkSession({ sessionId: 'native-1', title: 'native cwd', cwd: '/proj/beta' })]); + mocks.remotes['/proj/beta'] = 'https://gitlab.com/team/beta.git'; + mocks.remotes['/run/other'] = 'https://github.com/org/other.git'; + + await runSession('push', '--source', 'fakeplat', '--repo-root', repoRoot, '--cwd', '/run/other'); + + const betaDir = path.join(repoRoot, 'sessions', 'repos', 'gitlab.com_team_beta', 'tester'); + expect(fs.readdirSync(betaDir).filter((f) => f.endsWith('.jsonl'))).toHaveLength(1); + expect(fs.existsSync(path.join(repoRoot, 'sessions', 'repos', 'github.com_org_other'))).toBe(false); + + // 归档键写进 meta,与目录一致 + const metaPath = fs.readdirSync(betaDir).find((f) => f.endsWith('.meta.json'))!; + const meta = JSON.parse(fs.readFileSync(path.join(betaDir, metaPath), 'utf-8')); + expect(meta.origin.repoIdentity).toBe('gitlab.com/team/beta'); + }); + + it('archives codebuddy-ide md5 placeholders under _unattributed with an English warning', async () => { + fakeAdapter( + [mkSession({ sessionId: 'ide-1', title: 'ide scratch', cwd: 'md5:0123456789abcdef0123456789abcdef', platform: 'codebuddy-ide' })], + 'codebuddy-ide', + ); + + await runSession('push', '--source', 'codebuddy-ide', '--repo-root', repoRoot, '--cwd', '/run/other'); + + expect(warned.join('\n')).toContain( + 'native cwd unknowable for codebuddy-ide session, archived under _unattributed', + ); + const dir = path.join(repoRoot, 'sessions', '_unattributed', 'tester'); + expect(fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'))).toHaveLength(1); + const metaPath = fs.readdirSync(dir).find((f) => f.endsWith('.meta.json'))!; + const meta = JSON.parse(fs.readFileSync(path.join(dir, metaPath), 'utf-8')); + expect(meta.origin.repoIdentity).toBeNull(); + }); +}); + +describe('session migrate --push archive key', () => { + it('archives under the target session native cwd identity', async () => { + mocks.previewResult = { + sourcePlatform: 'fakeplat', + targetPlatform: 'fakeplat2', + sessionTitle: 'migrate me', + sessionId: 'src-1', + cwd: '/run/dir', + messageCount: 2, + fidelity: { score: 1, mode: 1, preservedBlocks: 2, totalBlocks: 2, degradedBlocks: 0, degradations: [], warnings: [] }, + }; + mocks.migrateResult = { + success: true, + targetSessionId: 'tgt-1', + targetFilePath: '/tmp/tgt.jsonl', + preview: { fidelity: { score: 0.9 } }, + }; + + fakeAdapter([mkSession({ sessionId: 'src-1', title: 'migrate me', cwd: '/run/dir' })], 'fakeplat'); + const targetSession = mkSession({ sessionId: 'tgt-1', title: 'migrated', cwd: '/proj/target', platform: 'fakeplat2' }); + mocks.adaptersByPlatform['fakeplat2'] = { + platform: 'fakeplat2', + listConversations: vi.fn(async () => []), + readSession: vi.fn(async () => targetSession), + }; + mocks.remotes['/proj/target'] = 'https://github.com/org/target.git'; + + await runSession( + 'migrate', 'src-1', '-s', 'fakeplat', '-t', 'fakeplat2', + '--push', '--repo-root', repoRoot, '--cwd', '/run/dir', + ); + + const dir = path.join(repoRoot, 'sessions', 'repos', 'github.com_org_target', 'tester'); + const metaFiles = fs.readdirSync(dir).filter((f) => f.endsWith('.meta.json')); + expect(metaFiles).toHaveLength(1); + + const meta = JSON.parse(fs.readFileSync(path.join(dir, metaFiles[0]), 'utf-8')); + // 归档键来自目标会话的原生 cwd,而非 migrate 的运行目录 /run/dir + expect(meta.origin.repoIdentity).toBe('github.com/org/target'); + expect(meta.migration.sourcePlatform).toBe('fakeplat'); + expect(meta.migration.fidelityScore).toBe(0.9); + + const text = out.join('\n'); + expect(text).toContain('Pushed 1 session(s) to team repo'); + expect(text).not.toMatch(CJK); + }); +}); + +describe('adapters: native cwd recovery from JSONL records', () => { + // 带空格的路径:目录名编码有损(空格与 / 无法区分),记录里的 cwd 才是真相。 + // 归档键(repoIdentity)依赖 readSession 返回记录 cwd 而非解码目录名。 + const NATIVE = '/Users/x/my project'; + const SID = '11111111-2222-3333-4444-555555555555'; + + it('claude-code readSession prefers the record cwd over the lossy directory name', async () => { + const projDir = path.join(mocks.home, '.claude', 'projects', '-Users-x-my-project'); + fs.mkdirSync(projDir, { recursive: true }); + fs.writeFileSync( + path.join(projDir, `${SID}.jsonl`), + JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'hi' }] }, + cwd: NATIVE, + timestamp: '2026-01-02T00:00:00.000Z', + uuid: 'u1', + parentUuid: null, + isSidechain: false, + }) + '\n', + ); + + const session = await new ClaudeCodeAdapter().readSession(SID); + expect(session.cwd).toBe(NATIVE); + }); + + it('codebuddy readSession prefers the record cwd over the identity-decoded directory name', async () => { + const projDir = path.join(mocks.home, '.codebuddy', 'projects', 'Users-x-my-project'); + fs.mkdirSync(projDir, { recursive: true }); + fs.writeFileSync( + path.join(projDir, `${SID}.jsonl`), + JSON.stringify({ + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hi' }], + cwd: NATIVE, + timestamp: 1767000000000, + id: 'm1', + parentId: null, + sessionId: SID, + }) + '\n', + ); + + const session = await new CodeBuddyAdapter().readSession(SID); + expect(session.cwd).toBe(NATIVE); + }); + + it('cursor readSession prefers the record cwd over the lossy directory name', async () => { + const transcriptDir = path.join( + mocks.home, '.cursor', 'projects', 'Users-x-my-project', 'agent-transcripts', SID, + ); + fs.mkdirSync(transcriptDir, { recursive: true }); + fs.writeFileSync( + path.join(transcriptDir, `${SID}.jsonl`), + JSON.stringify({ + role: 'user', + message: { content: [{ type: 'text', text: 'hi' }] }, + cwd: NATIVE, + }) + '\n', + ); + + const session = await new CursorAdapter().readSession(SID); + expect(session.cwd).toBe(NATIVE); + }); +}); diff --git a/src/__tests__/session-sync.test.ts b/src/__tests__/session-sync.test.ts new file mode 100644 index 00000000..7840051d --- /dev/null +++ b/src/__tests__/session-sync.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { SyncManager, defaultSyncMeta, generateSessionName } from '../session-flow/sync.js'; +import type { Session } from '../session-flow/ir.js'; + +/** + * SyncManager 跨 repo 能力测试(M2)。 + * + * 覆盖点: + * - listAllRepoIdentities:canonical 反查(目录名编码有损,只能从 _index.json 读回)、 + * 损坏/无索引目录跳过、_unattributed 的纳入条件 + * - listSessionsAcrossRepos:跨 repo 合并、author 过滤、旧索引条目的 repoIdentity 回填 + * - saveSession 去重(P8):sessionId+author 命中 → 原地更新,无 `_1` 副本; + * 旧索引无 sessionId → 退化为名冲突路径 + * - rebuildIndex:幂等重建且保留 origin.sessionId + */ + +let repoRoot: string; + +beforeEach(() => { + repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-sync-')); +}); + +afterEach(() => { + fs.rmSync(repoRoot, { recursive: true, force: true }); +}); + +function mkSession(o: Partial = {}): Session { + return { + sessionId: 's-1', + title: 'fix payment', + cwd: '/proj/alpha', + platform: 'claude-code', + createdAt: '2026-01-02T03:04:05.000Z', + updatedAt: '2026-01-02T03:05:05.000Z', + messages: [ + { role: 'user', content: [{ type: 'text', text: 'implement payment retry' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'done' }] }, + ], + metadata: {}, + ...o, + }; +} + +function mkMeta(o: { sessionId?: string; author?: string; repoIdentity?: string | null } = {}) { + return defaultSyncMeta( + { + platform: 'claude-code', + author: o.author ?? 'alice', + cwd: '/proj/alpha', + sessionId: o.sessionId ?? 's-1', + // 显式传 null(_unattributed)不能被默认值吞掉 + repoIdentity: o.repoIdentity === undefined ? 'github.com/org/alpha' : o.repoIdentity, + }, + '2026-01-02T03:04:05.000Z', + ); +} + +/** 与 SyncManager.repoDir 相同的编码规则(/ → _,保留字母数字和 . -)。 */ +function repoDirOf(identity: string): string { + return path.join(repoRoot, 'sessions', 'repos', identity.replace(/[^a-zA-Z0-9.-]/g, '_')); +} + +function writeRepoIndex(identity: string, index: unknown): void { + fs.mkdirSync(repoDirOf(identity), { recursive: true }); + fs.writeFileSync(path.join(repoDirOf(identity), '_index.json'), JSON.stringify(index)); +} + +function readRepoIndex(identity: string): { sessions: Array> } { + return JSON.parse(fs.readFileSync(path.join(repoDirOf(identity), '_index.json'), 'utf-8')); +} + +const unattrDir = () => path.join(repoRoot, 'sessions', '_unattributed'); + +describe('listAllRepoIdentities', () => { + it('reverse-maps canonical identities from each repo _index.json', () => { + // 目录名是编码后的(github.com_org_alpha),canonical 原文只能从索引反查 + writeRepoIndex('github.com/org/alpha', { version: 1, repoIdentity: 'github.com/org/alpha', updatedAt: '2026-01-01T00:00:00.000Z', sessions: [] }); + writeRepoIndex('gitlab.company.com/g/beta', { version: 1, repoIdentity: 'gitlab.company.com/g/beta', updatedAt: '2026-01-01T00:00:00.000Z', sessions: [] }); + + const mgr = new SyncManager(repoRoot); + expect(mgr.listAllRepoIdentities().sort()).toEqual( + expect.arrayContaining(['github.com/org/alpha', 'gitlab.company.com/g/beta']), + ); + }); + + it('skips repos with corrupted or missing _index.json', () => { + writeRepoIndex('github.com/org/alpha', { version: 1, repoIdentity: 'github.com/org/alpha', updatedAt: '2026-01-01T00:00:00.000Z', sessions: [] }); + // 损坏索引 + const corrupted = repoDirOf('gitlab.company.com/g/beta'); + fs.mkdirSync(corrupted, { recursive: true }); + fs.writeFileSync(path.join(corrupted, '_index.json'), '{ not valid json'); + // 无索引 + fs.mkdirSync(repoDirOf('example.com/no-index'), { recursive: true }); + + const mgr = new SyncManager(repoRoot); + expect(mgr.listAllRepoIdentities()).toEqual(['github.com/org/alpha']); + }); + + it('includes _unattributed when its _index.json exists', () => { + writeRepoIndex('github.com/org/alpha', { version: 1, repoIdentity: 'github.com/org/alpha', updatedAt: '2026-01-01T00:00:00.000Z', sessions: [] }); + fs.mkdirSync(unattrDir(), { recursive: true }); + fs.writeFileSync( + path.join(unattrDir(), '_index.json'), + JSON.stringify({ version: 1, repoIdentity: null, updatedAt: '2026-01-01T00:00:00.000Z', sessions: [] }), + ); + + expect(new SyncManager(repoRoot).listAllRepoIdentities()).toEqual( + expect.arrayContaining(['github.com/org/alpha', null]), + ); + }); + + it('includes _unattributed when it has session content but no index', () => { + fs.mkdirSync(path.join(unattrDir(), 'bob'), { recursive: true }); + + expect(new SyncManager(repoRoot).listAllRepoIdentities()).toEqual([null]); + }); + + it('excludes an empty _unattributed directory', () => { + writeRepoIndex('github.com/org/alpha', { version: 1, repoIdentity: 'github.com/org/alpha', updatedAt: '2026-01-01T00:00:00.000Z', sessions: [] }); + fs.mkdirSync(unattrDir(), { recursive: true }); + + expect(new SyncManager(repoRoot).listAllRepoIdentities()).toEqual(['github.com/org/alpha']); + }); + + it('returns an empty list for an empty team repo', () => { + expect(new SyncManager(repoRoot).listAllRepoIdentities()).toEqual([]); + }); +}); + +describe('listSessionsAcrossRepos', () => { + it('merges sessions from every repo and _unattributed', () => { + const mgr = new SyncManager(repoRoot); + mgr.saveSession(mkSession({ sessionId: 'a-1', title: 'alpha task' }), mkMeta({ sessionId: 'a-1', repoIdentity: 'github.com/org/alpha' })); + mgr.saveSession(mkSession({ sessionId: 'b-1', title: 'beta task' }), mkMeta({ sessionId: 'b-1', author: 'bob', repoIdentity: 'gitlab.company.com/g/beta' })); + mgr.saveSession(mkSession({ sessionId: 'c-1', title: 'plain task' }), mkMeta({ sessionId: 'c-1', author: 'carol', repoIdentity: null })); + + const all = mgr.listSessionsAcrossRepos(); + expect(all.map((s) => s.sessionId).sort()).toEqual(['a-1', 'b-1', 'c-1']); + // 每个条目都带 repoIdentity,标识来源 repo(null → _unattributed) + expect(all.find((s) => s.sessionId === 'a-1')?.repoIdentity).toBe('github.com/org/alpha'); + expect(all.find((s) => s.sessionId === 'b-1')?.repoIdentity).toBe('gitlab.company.com/g/beta'); + expect(all.find((s) => s.sessionId === 'c-1')?.repoIdentity).toBeNull(); + }); + + it('filters by author across repos', () => { + const mgr = new SyncManager(repoRoot); + mgr.saveSession(mkSession({ sessionId: 'a-1', title: 'alpha alice' }), mkMeta({ sessionId: 'a-1', author: 'alice', repoIdentity: 'github.com/org/alpha' })); + mgr.saveSession(mkSession({ sessionId: 'b-1', title: 'beta bob' }), mkMeta({ sessionId: 'b-1', author: 'bob', repoIdentity: 'gitlab.company.com/g/beta' })); + mgr.saveSession(mkSession({ sessionId: 'c-1', title: 'plain alice' }), mkMeta({ sessionId: 'c-1', author: 'alice', repoIdentity: null })); + + const byAlice = mgr.listSessionsAcrossRepos('alice'); + expect(byAlice.map((s) => s.sessionId).sort()).toEqual(['a-1', 'c-1']); + }); + + it('backfills repoIdentity for legacy index entries that lack it', () => { + // 旧格式索引条目没有 repoIdentity 字段 → 用所在 repo 的 identity 回填, + // 展示层(list --all 的 SOURCE 列)依赖它 + writeRepoIndex('github.com/org/alpha', { + version: 1, + repoIdentity: 'github.com/org/alpha', + updatedAt: '2026-01-01T00:00:00.000Z', + sessions: [ + { sessionName: 'old_x', author: 'alice', platform: 'claude-code', title: 'x', cwd: '/p', messageCount: 1, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', status: 'active' }, + ], + }); + fs.mkdirSync(unattrDir(), { recursive: true }); + fs.writeFileSync( + path.join(unattrDir(), '_index.json'), + JSON.stringify({ version: 1, repoIdentity: null, updatedAt: '2026-01-01T00:00:00.000Z', sessions: [ + { sessionName: 'old_y', author: 'bob', platform: 'codex', title: 'y', cwd: '/q', messageCount: 1, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', status: 'active' }, + ] }), + ); + + const all = new SyncManager(repoRoot).listSessionsAcrossRepos(); + expect(all.find((s) => s.sessionName === 'old_x')?.repoIdentity).toBe('github.com/org/alpha'); + expect(all.find((s) => s.sessionName === 'old_y')?.repoIdentity).toBeNull(); + }); +}); + +describe('saveSession dedup (origin sessionId + author)', () => { + it('updates the existing entry in place instead of creating a _1 duplicate', () => { + const mgr = new SyncManager(repoRoot); + mgr.saveSession(mkSession({ title: 'fix payment', messages: mkSession().messages }), mkMeta()); + + // 同一 sessionId + author 再推(内容有更新)→ 复用原 sessionName 覆盖写 + const second = mkSession({ + title: 'fix payment v2', + messages: [ + { role: 'user', content: [{ type: 'text', text: 'implement payment retry' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'halfway' }] }, + { role: 'user', content: [{ type: 'text', text: 'continue' }] }, + ], + }); + mgr.saveSession(second, mkMeta()); + + const authorDir = path.join(repoDirOf('github.com/org/alpha'), 'alice'); + const jsonls = fs.readdirSync(authorDir).filter((f) => f.endsWith('.jsonl')); + expect(jsonls).toEqual(['claude-code_fix-payment_20260102.jsonl']); // 无 _1 副本 + + const idx = readRepoIndex('github.com/org/alpha'); + expect(idx.sessions).toHaveLength(1); + expect(idx.sessions[0]).toMatchObject({ + title: 'fix payment v2', + sessionId: 's-1', + messageCount: 3, + }); + }); + + it('treats the same sessionId under a different author as a separate session', () => { + const mgr = new SyncManager(repoRoot); + mgr.saveSession(mkSession({ sessionId: 'shared-1', title: 'shared' }), mkMeta({ sessionId: 'shared-1', author: 'alice' })); + mgr.saveSession(mkSession({ sessionId: 'shared-1', title: 'shared' }), mkMeta({ sessionId: 'shared-1', author: 'bob' })); + + const repoDir = repoDirOf('github.com/org/alpha'); + expect(fs.existsSync(path.join(repoDir, 'alice', 'claude-code_shared_20260102.jsonl'))).toBe(true); + expect(fs.existsSync(path.join(repoDir, 'bob', 'claude-code_shared_20260102.jsonl'))).toBe(true); + + const idx = readRepoIndex('github.com/org/alpha'); + expect(idx.sessions).toHaveLength(2); + // 两个条目都带 sessionId,去重键按 author 区分 + expect(idx.sessions.every((s) => s.sessionId === 'shared-1')).toBe(true); + expect(idx.sessions.map((s) => s.author).sort()).toEqual(['alice', 'bob']); + }); + + it('falls back to name-conflict suffixes when the legacy index has no sessionId', () => { + // 旧索引条目没有 sessionId → 按 sessionId 查重查不到 → 走旧的 + // resolveNameConflict 路径,生成 _1 副本(兼容旧行为) + const base = generateSessionName('claude-code', 'fix payment', '2026-01-02T03:04:05.000Z'); + const authorDir = path.join(repoDirOf('github.com/org/alpha'), 'alice'); + fs.mkdirSync(authorDir, { recursive: true }); + fs.writeFileSync(path.join(authorDir, `${base}.jsonl`), '{"role":"user"}\n'); + writeRepoIndex('github.com/org/alpha', { + version: 1, + repoIdentity: 'github.com/org/alpha', + updatedAt: '2026-01-01T00:00:00.000Z', + sessions: [ + { sessionName: base, author: 'alice', platform: 'claude-code', title: 'fix payment', cwd: '/p', messageCount: 1, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', status: 'active' }, + ], + }); + + const mgr = new SyncManager(repoRoot); + mgr.saveSession(mkSession({ sessionId: 'new-1' }), mkMeta({ sessionId: 'new-1' })); + + expect(fs.existsSync(path.join(authorDir, `${base}_1.jsonl`))).toBe(true); + expect(readRepoIndex('github.com/org/alpha').sessions).toHaveLength(2); + }); +}); + +describe('rebuildIndex', () => { + it('preserves origin sessionId in rebuilt entries', () => { + const mgr = new SyncManager(repoRoot); + mgr.saveSession(mkSession(), mkMeta()); + + // 索引损坏后重建 + fs.writeFileSync(path.join(repoDirOf('github.com/org/alpha'), '_index.json'), '{ corrupted'); + + const count = mgr.rebuildIndex('github.com/org/alpha'); + expect(count).toBe(1); + + const idx = readRepoIndex('github.com/org/alpha'); + expect(idx.sessions[0]).toMatchObject({ sessionName: 'claude-code_fix-payment_20260102', sessionId: 's-1' }); + }); + + it('is idempotent across repeated rebuilds', () => { + const mgr = new SyncManager(repoRoot); + mgr.saveSession(mkSession(), mkMeta()); + + expect(mgr.rebuildIndex('github.com/org/alpha')).toBe(1); + expect(mgr.rebuildIndex('github.com/org/alpha')).toBe(1); + expect(readRepoIndex('github.com/org/alpha').sessions).toHaveLength(1); + }); +}); diff --git a/src/__tests__/session-title.test.ts b/src/__tests__/session-title.test.ts new file mode 100644 index 00000000..69573609 --- /dev/null +++ b/src/__tests__/session-title.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { cleanTitleText, fallbackTitle, isInjectedText } from '../session-flow/title.js'; + +describe('session title cleaning', () => { + it('detects platform-injected message heads', () => { + expect(isInjectedText('Caveat: ...')).toBe(true); + expect(isInjectedText('long injected context')).toBe(true); + expect(isInjectedText('/teamai')).toBe(true); + expect(isInjectedText('怎么把会话迁移到 claude?')).toBe(false); + }); + + it('strips injected wrapper tags and keeps human text', () => { + expect(cleanTitleText('x 怎么迁移会话')).toBe('怎么迁移会话'); + }); + + it('truncates long titles', () => { + expect(cleanTitleText('a'.repeat(200))).toHaveLength(60); + }); + + it('returns empty for text that cannot be fully stripped', () => { + // 半截标签剥不干净(残留尖括号),宁可返回空让调用方退回 Session + expect(cleanTitleText('text { + // 整条是不是注入由 isInjectedText 先判断;cleanTitleText 只负责剥标签 + expect(isInjectedText('unterminated caveat')).toBe(true); + }); + + it('falls back to a short id-based title', () => { + expect(fallbackTitle('1fc6ec8d-3b68-4a03-8a82-950a736eee72')).toBe('Session 1fc6ec8d'); + }); +}); diff --git a/src/index.ts b/src/index.ts index 8ea8e744..06afaaf8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -666,7 +666,7 @@ program // ─── Session subcommands ────────────────────────────────── const sessionCmd = program .command('session') - .description('Record and inspect coding-session summaries'); + .description('Session recording, cross-platform migration, and team sync'); sessionCmd .command('save') @@ -682,6 +682,10 @@ sessionCmd await saveSession({ ...globalOpts, ...cmdOpts }); }); +// SessionFlow: cross-platform session migration / sync / search / resume +const { registerSessionFlowCommands } = await import('./session-flow/session-cmd.js'); +registerSessionFlowCommands(sessionCmd); + program .command('digest') .description('Generate weekly team activity digest') diff --git a/src/session-flow/adapters/base.ts b/src/session-flow/adapters/base.ts new file mode 100644 index 00000000..4335191d --- /dev/null +++ b/src/session-flow/adapters/base.ts @@ -0,0 +1,62 @@ +/** + * adapters/base.ts — AgentAdapter 抽象基类与 SessionMeta。 + * + * 所有平台适配器(Claude Code / Codex / CodeBuddy / Cursor)都继承 AgentAdapter, + * 实现统一的 list/read/write/delete 接口,使上层迁移逻辑与具体平台解耦。 + */ + +import type { Session } from '../ir.js'; + +export interface SessionMeta { + sessionId: string; + title: string; + cwd: string; + platform: string; + createdAt: string; // ISO8601 + updatedAt: string; // ISO8601 + messageCount: number; + filePath?: string; + sizeBytes: number; +} + +export abstract class AgentAdapter { + static readonly platform: string; + + abstract get platform(): string; + + /** 列出该平台指定项目(工作目录)下的所有会话。projectPath 为 undefined 时列出所有。 */ + abstract listConversations(projectPath?: string): Promise; + + /** 读取单个会话,返回归一化 IR Session。 */ + abstract readSession(sessionId: string, projectPath?: string): Promise; + + /** 将归一化 IR Session 写入目标平台,返回写入后的 session ID。 */ + abstract writeSession(session: Session, projectPath?: string): Promise; + + /** + * 删除目标平台上的会话(用于回滚)。 + * + * 返回值用于区分「真的删掉了」和「压根没找到」: + * - `false` —— 确认没有任何东西被删除(会话不存在) + * - `true` / `undefined` —— 已删除,或该适配器不检测存在性(沿用原有行为) + * + * 之所以允许返回 void:多数适配器不具备存在性检测能力, + * 为回滚的可观测性改动全部适配器不划算,未实现的保持 undefined 即可。 + */ + abstract deleteSession(sessionId: string, projectPath?: string): Promise; + + /** 检测该平台 CLI 是否已安装且可用(静态,检查基础路径)。 */ + static isAvailable(): boolean { + return false; + } + + /** 检测该适配器实例的存储路径是否可用(实例方法,变体可覆盖)。 */ + isReady(): boolean { + return false; + } + + /** 返回该平台会话的默认存储根路径。 */ + static getDefaultStoragePath(): string { + return ''; + } +} diff --git a/src/session-flow/adapters/claude-code.ts b/src/session-flow/adapters/claude-code.ts new file mode 100644 index 00000000..2ad03095 --- /dev/null +++ b/src/session-flow/adapters/claude-code.ts @@ -0,0 +1,740 @@ +/** + * adapters/claude-code.ts — Claude Code 平台适配器。 + * + * 读取/写入 `~/.claude/projects//.jsonl` 格式。 + * + * JSONL 行类型(13 种): + * 读取:user/assistant → IR Message;其余 11 种跳过 + * 写入:user/assistant + mode + permission-mode + file-history-snapshot + + * attachment + last-prompt(6 种辅助行确保 CC 能加载) + * + * DAG 拍平:按 parentUuid 构建主链,跳过 isSidechain=true 的侧链。 + * + * 增强点(vs Python 版): + * - 写入时生成 last-prompt 行(CC --resume 依赖) + * - 写入时生成 attachment 行(工具/MCP/Agent 清单占位) + * - 写入时生成 file-history-snapshot 行(文件历史占位) + * - 写入时生成 mode + permission-mode 行 + * - thinking 块保留 signature + */ + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { AgentAdapter, type SessionMeta } from './base.js'; +import type { Session, Message, ContentBlock, TextBlock, ThinkingBlock, ToolCallBlock, ToolResultBlock } from '../ir.js'; +import { + getClaudeCodeProjectsDir, + encodeCwdClaude, + decodeCwdClaude, + readJsonl, + readJsonlHead, + writeJsonl, + fileExists, + dirExists, + scanFiles, + removeDirRecursive, +} from '../fs.js'; +import { cleanTitleText, fallbackTitle, isInjectedText } from '../title.js'; + +// --------------------------------------------------------------------------- +// 工具名归一化映射 +// --------------------------------------------------------------------------- + +const CC_TO_IR_TOOL: Record = { + Read: 'read_file', + Write: 'write_file', + Edit: 'edit_file', + MultiEdit: 'multi_edit', + Bash: 'bash', + Glob: 'glob', + Grep: 'grep', + WebSearch: 'web_search', + WebFetch: 'web_fetch', + Task: 'task', + TodoWrite: 'todo_write', + NotebookEdit: 'notebook_edit', + LSP: 'lsp', + ListMcpResourcesTool: 'list_mcp_resources', +}; + +const IR_TO_CC_TOOL: Record = Object.fromEntries( + Object.entries(CC_TO_IR_TOOL).map(([k, v]) => [v, k]), +); + +function normalizeToolName(ccName: string): string { + return CC_TO_IR_TOOL[ccName] ?? ccName; +} + +function denormalizeToolName(irName: string): string { + return IR_TO_CC_TOOL[irName] ?? irName; +} + +// CC 默认工具清单(用于 attachment 行的 deferred_tools_delta.addedNames) +// CC 加载会话时需要这个清单来重建工具上下文 +const CC_DEFAULT_TOOLS = [ + 'Bash', + 'Glob', + 'Grep', + 'Read', + 'Write', + 'Edit', + 'MultiEdit', + 'NotebookEdit', + 'WebSearch', + 'WebFetch', + 'Task', + 'TodoWrite', + 'LSP', + 'ListMcpResourcesTool', + 'ReadMcpResourceTool', + 'ReadMcpResourceDirTool', +]; + +// --------------------------------------------------------------------------- +// 读取时跳过的行类型 +// --------------------------------------------------------------------------- + +const SKIP_TYPES = new Set([ + 'last-prompt', + 'mode', + 'permission-mode', + 'file-history-snapshot', + 'file-history-delta', + 'attachment', + 'queue-operation', + 'system', + 'atis-latch', + 'cost-state', +]); + +// --------------------------------------------------------------------------- +// UUID / 时间戳工具 +// --------------------------------------------------------------------------- + +const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function isUuidV4(s: string): boolean { + return UUID_V4_RE.test(s); +} + +function uuidV4(): string { + return crypto.randomUUID(); +} + +/** + * 解析 CC 的 ISO8601 带 Z 后缀时间戳。 + */ +function parseCcTimestamp(tsStr: string | undefined): string | undefined { + if (!tsStr) return undefined; + try { + // 验证可解析 + const d = new Date(tsStr); + if (isNaN(d.getTime())) return undefined; + return tsStr; + } catch { + return undefined; + } +} + +/** + * 格式化为 CC 时间戳(毫秒精度,带 Z 后缀)。 + */ +function toCcTimestamp(isoStr: string | undefined): string { + const d = isoStr ? new Date(isoStr) : new Date(); + if (isNaN(d.getTime())) return new Date().toISOString(); + return d.toISOString(); +} + +// --------------------------------------------------------------------------- +// content 块解析(CC → IR) +// --------------------------------------------------------------------------- + +function parseCcContentBlocks(content: unknown): ContentBlock[] { + const blocks: ContentBlock[] = []; + + if (typeof content === 'string') { + blocks.push({ type: 'text', text: content }); + return blocks; + } + + if (!Array.isArray(content)) return blocks; + + for (const block of content) { + if (!block || typeof block !== 'object') continue; + const b = block as Record; + const btype = b.type as string; + + if (btype === 'thinking') { + blocks.push({ + type: 'thinking', + text: String(b.thinking ?? ''), + ...(b.signature ? { signature: String(b.signature) } : {}), + }); + } else if (btype === 'text') { + blocks.push({ type: 'text', text: String(b.text ?? '') }); + } else if (btype === 'tool_use') { + blocks.push({ + type: 'tool_call', + toolName: normalizeToolName(String(b.name ?? '')), + callId: String(b.id ?? ''), + arguments: (b.input as Record) ?? {}, + }); + } else if (btype === 'tool_result') { + let rawContent = b.content; + if (Array.isArray(rawContent)) { + const parts: string[] = []; + for (const part of rawContent) { + if (part && typeof part === 'object' && (part as Record).type === 'text') { + parts.push(String((part as Record).text ?? '')); + } else if (typeof part === 'string') { + parts.push(part); + } + } + rawContent = parts.join('\n'); + } else if (typeof rawContent !== 'string') { + rawContent = rawContent == null ? '' : String(rawContent); + } + blocks.push({ + type: 'tool_result', + callId: String(b.tool_use_id ?? ''), + content: rawContent as string, + isError: Boolean(b.is_error ?? false), + }); + } + } + return blocks; +} + +// --------------------------------------------------------------------------- +// content 块序列化(IR → CC) +// --------------------------------------------------------------------------- + +function irBlockToCc(block: ContentBlock): Record | null { + switch (block.type) { + case 'text': + return { type: 'text', text: block.text }; + case 'thinking': + return { + type: 'thinking', + thinking: block.text, + ...(block.signature ? { signature: block.signature } : {}), + }; + case 'tool_call': + return { + type: 'tool_use', + id: block.callId, + name: denormalizeToolName(block.toolName), + input: block.arguments, + }; + case 'tool_result': + return { + type: 'tool_result', + tool_use_id: block.callId, + content: block.content, + is_error: block.isError, + }; + } +} + +// --------------------------------------------------------------------------- +// ClaudeCodeAdapter +// --------------------------------------------------------------------------- + +export class ClaudeCodeAdapter extends AgentAdapter { + readonly platform: string; + private readonly storageRoot: string; + + /** + * @param platform 平台标识(默认 'claude-code',变体可传 'claude-internal' / 'tclaude') + * @param storageRoot 存储根路径(默认 ~/.claude/projects,变体传 ~/.claude-internal/projects 等) + */ + constructor(platform = 'claude-code', storageRoot?: string) { + super(); + this.platform = platform; + this.storageRoot = storageRoot ?? getClaudeCodeProjectsDir(); + } + + static isAvailable(): boolean { + return dirExists(getClaudeCodeProjectsDir()); + } + + static getDefaultStoragePath(): string { + return getClaudeCodeProjectsDir(); + } + + isReady(): boolean { + return dirExists(this.storageRoot); + } + + private resolveProjectDir(projectPath?: string): string { + if (projectPath) { + return path.join(this.storageRoot, encodeCwdClaude(projectPath)); + } + return this.storageRoot; + } + + private findSessionFile(sessionId: string, projectPath?: string): string | null { + if (projectPath) { + const target = path.join(this.resolveProjectDir(projectPath), `${sessionId}.jsonl`); + return fileExists(target) ? target : null; + } + // 遍历所有编码目录 + if (!dirExists(this.storageRoot)) return null; + for (const projDir of fs.readdirSync(this.storageRoot)) { + const candidate = path.join(this.storageRoot, projDir, `${sessionId}.jsonl`); + if (fileExists(candidate)) return candidate; + } + return null; + } + + async listConversations(projectPath?: string): Promise { + const metas: SessionMeta[] = []; + const root = this.storageRoot; + if (!dirExists(root)) return []; + + const projDirs = projectPath + ? [this.resolveProjectDir(projectPath)] + : fs.readdirSync(root).map((d) => path.join(root, d)); + + for (const projDir of projDirs) { + if (!dirExists(projDir)) continue; + const cwd = decodeCwdClaude(path.basename(projDir)); + for (const jsonlFile of fs.readdirSync(projDir).filter((f) => f.endsWith('.jsonl')).sort()) { + const fullPath = path.join(projDir, jsonlFile); + const meta = this.extractMeta(fullPath, cwd); + if (meta) metas.push(meta); + } + } + return metas; + } + + private extractMeta(jsonlPath: string, cwd: string): SessionMeta | null { + const sessionId = path.basename(jsonlPath, '.jsonl'); + let title = ''; + let createdAt: string | undefined; + let updatedAt: string | undefined; + let messageCount = 0; + let firstUserText = ''; + + try { + for (const record of readJsonlHead(jsonlPath, 50)) { + const rtype = record.type as string; + const ts = parseCcTimestamp(record.timestamp as string); + + if (ts) { + if (!createdAt) createdAt = ts; + updatedAt = ts; + } + + if (rtype === 'user' || rtype === 'assistant') { + messageCount++; + if (rtype === 'user' && !firstUserText) { + const msg = record.message as Record | undefined; + const content = msg?.content; + if (typeof content === 'string') { + if (!isInjectedText(content)) firstUserText = content; + } else if (Array.isArray(content)) { + for (const block of content) { + if (block && typeof block === 'object' && (block as Record).type === 'text') { + const text = String((block as Record).text ?? ''); + // 首个文本块常是 system-reminder 等注入,跳过继续找真正的提问 + if (!isInjectedText(text)) { + firstUserText = text; + break; + } + } + } + } + } + } + } + } catch { + return null; + } + + if (!createdAt) createdAt = new Date().toISOString(); + if (!updatedAt) updatedAt = createdAt; + + title = cleanTitleText(firstUserText) || fallbackTitle(sessionId); + + let sizeBytes = 0; + try { + sizeBytes = fs.statSync(jsonlPath).size; + } catch { + // ignore + } + + return { + sessionId, + title, + cwd, + platform: this.platform, + createdAt, + updatedAt, + messageCount, + filePath: jsonlPath, + sizeBytes, + }; + } + + async readSession(sessionId: string, projectPath?: string): Promise { + const jsonlPath = this.findSessionFile(sessionId, projectPath); + if (!jsonlPath) { + throw new Error(`Claude Code session file not found: session_id=${sessionId}, project_path=${projectPath ?? 'undefined'}`); + } + + const cwd = decodeCwdClaude(path.basename(path.dirname(jsonlPath))); + + // 收集所有消息记录 + const rawRecords: Record[] = []; + let nativeCwd: string | undefined; + let summaryTitle: string | undefined; + for (const record of readJsonl(jsonlPath)) { + const rtype = record.type as string; + if (rtype === 'summary') { + // writeSession 落盘的标题行(CC /resume 也以它为准)。读取侧不认的话, + // roundtrip 后标题会漂移成首条用户文本(可能是注入清洗后的残句)。 + const t = String(record.summary ?? ''); + if (t) summaryTitle = t; // 取最后一条(writeSession 追加在文件末尾) + continue; + } + if (SKIP_TYPES.has(rtype)) continue; + if (rtype !== 'user' && rtype !== 'assistant') continue; + // 每条消息记录都带真实 cwd(绝对路径)。目录名解码是有损的 + // (`-` 可能来自 `/` 或空格),归档键(repoIdentity)必须优先用 + // 记录里的原生 cwd(设计文档 Key invariant);恢复失败退回解码目录名。 + if (nativeCwd === undefined && typeof record.cwd === 'string' && path.isAbsolute(record.cwd)) { + nativeCwd = record.cwd; + } + rawRecords.push(record); + } + const sessionCwd = nativeCwd ?? cwd; + + // DAG 拍平 + const messages = this.flattenDag(rawRecords); + + // 提取标题:优先 summary 标题行(写入侧落盘、CC /resume 亦采用), + // 其次首条非注入用户文本,最后退回 id 前缀。 + let title = summaryTitle ? cleanTitleText(summaryTitle) : ''; + if (!title) { + for (const msg of messages) { + if (msg.role === 'user') { + for (const block of msg.content) { + if (block.type === 'text' && block.text) { + if (isInjectedText(block.text)) continue; // 注入块不当标题 + title = cleanTitleText(block.text); + if (title) break; + } + } + if (title) break; + } + } + } + if (!title) title = fallbackTitle(sessionId); + + // 时间戳 + let createdAt: string | undefined; + let updatedAt: string | undefined; + for (const record of rawRecords) { + const ts = parseCcTimestamp(record.timestamp as string); + if (ts) { + if (!createdAt) createdAt = ts; + updatedAt = ts; + } + } + if (!createdAt) createdAt = new Date().toISOString(); + if (!updatedAt) updatedAt = createdAt; + + // 元数据 + const metadata: Record = {}; + for (let i = rawRecords.length - 1; i >= 0; i--) { + if (rawRecords[i].type === 'assistant') { + const msg = rawRecords[i].message as Record | undefined; + const model = msg?.model as string | undefined; + if (model) { + metadata.model = model; + break; + } + } + } + + return { + sessionId, + title, + cwd: sessionCwd, + platform: this.platform, + createdAt, + updatedAt, + messages, + metadata, + }; + } + + /** + * DAG 拍平:按 parentUuid 构建主链,跳过 isSidechain=true 的侧链。 + * + * 注意:parentUuid 链可能经过被跳过的行类型(attachment/file-history-snapshot 等), + * 导致链断裂。对于 parentUuid 指向不存在记录的 orphan 节点,将它们作为根节点处理。 + */ + private flattenDag(rawRecords: Record[]): Message[] { + // 过滤侧链 + const mainChain = rawRecords.filter((r) => !r.isSidechain); + + // uuid → record(仅主链记录) + const uuidToRecord = new Map>(); + for (const record of mainChain) { + const uid = record.uuid as string | undefined; + if (uid) uuidToRecord.set(uid, record); + } + + // parentUuid → children + const childrenMap = new Map[]>(); + const roots: Record[] = []; + for (const record of mainChain) { + const parent = (record.parentUuid as string | null | undefined) ?? null; + if (parent === null) { + roots.push(record); + } else if (uuidToRecord.has(parent)) { + // parent 在主链中 + if (!childrenMap.has(parent)) childrenMap.set(parent, []); + childrenMap.get(parent)!.push(record); + } else { + // parent 不在主链中(被跳过的行类型),作为根节点处理 + roots.push(record); + } + } + + // BFS 遍历主链 + const ordered: Record[] = []; + const visited = new Set(); + const queue: Record[] = [...roots]; + + while (queue.length > 0) { + const record = queue.shift()!; + const uid = record.uuid as string | undefined; + if (uid && visited.has(uid)) continue; + if (uid) visited.add(uid); + ordered.push(record); + if (uid) { + queue.push(...(childrenMap.get(uid) ?? [])); + } + } + + // 转换为 IR Message + const messages: Message[] = []; + for (const record of ordered) { + const msg = this.recordToMessage(record); + if (msg) messages.push(msg); + } + return messages; + } + + private recordToMessage(record: Record): Message | null { + const rtype = record.type as string; + if (rtype !== 'user' && rtype !== 'assistant') return null; + + const msgObj = record.message as Record | undefined; + if (!msgObj || typeof msgObj !== 'object') return null; + + const content = msgObj.content; + const blocks = parseCcContentBlocks(content); + + const timestamp = parseCcTimestamp(record.timestamp as string); + const messageId = record.uuid as string | undefined; + const parentId = record.parentUuid as string | undefined; + + const metadata: Message['metadata'] = {}; + if (rtype === 'assistant') { + const model = msgObj.model as string | undefined; + if (model) metadata.model = model; + } + if (record.isMeta) metadata.isMeta = true; + if (record.promptId) metadata.promptId = record.promptId as string; + + return { + role: rtype as 'user' | 'assistant', + content: blocks, + timestamp, + messageId, + parentId, + metadata, + }; + } + + async writeSession(session: Session, projectPath?: string): Promise { + // 确定 session_id(必须是 UUIDv4) + let sessionId = session.sessionId; + if (!isUuidV4(sessionId)) { + sessionId = uuidV4(); + } + + // 确定目标目录 + const cwd = projectPath ?? session.cwd; + const projDir = path.join(this.storageRoot, encodeCwdClaude(cwd)); + const jsonlPath = path.join(projDir, `${sessionId}.jsonl`); + + // 生成 JSONL 记录(含辅助行) + const records = this.sessionToCcRecords(session, sessionId, cwd); + writeJsonl(jsonlPath, records); + + return sessionId; + } + + /** + * 将 IR Session 转换为 CC JSONL 记录列表(含辅助行)。 + * + * 辅助行生成顺序: + * 1. mode 行(首行) + * 2. permission-mode 行 + * 3. 消息行(user/assistant),每条消息后跟 file-history-snapshot + * 4. 首条 assistant 消息前插入 attachment 行(空工具清单) + * 5. last-prompt 行(末行) + */ + private sessionToCcRecords(session: Session, sessionId: string, cwd: string): Record[] { + const records: Record[] = []; + let parentUuid: string | null = null; + let firstAssistantUuid: string | null = null; + let lastUserUuid: string | null = null; + let lastUserText = ''; + + // 1. mode 行 + records.push({ + type: 'mode', + mode: 'normal', + sessionId, + }); + + // 2. permission-mode 行 + records.push({ + type: 'permission-mode', + permissionMode: 'default', + sessionId, + }); + + for (const msg of session.messages) { + const msgUuid = msg.messageId ?? uuidV4(); + + // 构建 content 块 + const ccBlocks: Record[] = []; + for (const block of msg.content) { + const ccBlock = irBlockToCc(block); + if (ccBlock) ccBlocks.push(ccBlock); + } + + // CC 消息对象 + const ccMessage: Record = { role: msg.role, content: ccBlocks }; + if (msg.role === 'assistant') { + ccMessage.model = msg.metadata?.model ?? 'claude-sonnet-4-20250514'; + } + + // 首条 assistant 消息前插入 attachment 行 + if (msg.role === 'assistant' && firstAssistantUuid === null) { + firstAssistantUuid = msgUuid; + const attachmentUuid = uuidV4(); + records.push({ + parentUuid, + isSidechain: false, + attachment: { + type: 'deferred_tools_delta', + addedNames: CC_DEFAULT_TOOLS, + }, + uuid: attachmentUuid, + timestamp: toCcTimestamp(msg.timestamp), + cwd, + sessionId, + version: '2.1.221', + gitBranch: session.metadata?.gitBranch ?? '', + userType: 'external', + entrypoint: 'cli', + }); + parentUuid = attachmentUuid; + } + + // 消息行 + const record: Record = { + parentUuid, + isSidechain: false, + type: msg.role, + message: ccMessage, + uuid: msgUuid, + timestamp: toCcTimestamp(msg.timestamp), + cwd, + sessionId, + version: '2.1.221', + gitBranch: session.metadata?.gitBranch ?? '', + userType: 'external', + entrypoint: 'cli', + }; + if (msg.metadata?.isMeta) record.isMeta = true; + if (msg.metadata?.promptId) record.promptId = msg.metadata.promptId; + + records.push(record); + parentUuid = msgUuid; + + // file-history-snapshot 行(每条消息后) + records.push({ + type: 'file-history-snapshot', + messageId: msgUuid, + snapshot: { + messageId: msgUuid, + trackedFileBackups: {}, + timestamp: toCcTimestamp(msg.timestamp), + }, + isSnapshotUpdate: false, + }); + + // 记录最后一条 user 消息 + if (msg.role === 'user') { + for (const block of msg.content) { + if (block.type === 'text' && block.text) { + lastUserText = block.text; + lastUserUuid = msgUuid; + break; + } + } + } + } + + // 3. last-prompt 行(末尾) + records.push({ + type: 'last-prompt', + lastPrompt: lastUserText, + leafUuid: lastUserUuid ?? parentUuid, + sessionId, + }); + + // 4. summary 行(标题) + // Claude Code 的 /resume 列表靠 type:"summary" 记录显示会话标题, + // 缺失时退回显示 session id 前缀(如 824ff784),迁移来的会话全中招。 + // 源适配器读出的 title 已经过注入清洗,这里直接落盘。 + const summary = cleanTitleText(session.title ?? '') || fallbackTitle(sessionId); + if (summary) { + records.push({ + type: 'summary', + summary, + leafUuid: parentUuid, + sessionId, + }); + } + + return records; + } + + async deleteSession(sessionId: string, projectPath?: string): Promise { + const jsonlPath = this.findSessionFile(sessionId, projectPath); + if (!jsonlPath) return; + + try { + fs.unlinkSync(jsonlPath); + } catch { + // ignore + } + + // 删除同名子目录 + const subdir = jsonlPath.replace(/\.jsonl$/, ''); + if (dirExists(subdir)) { + removeDirRecursive(subdir); + } + } +} diff --git a/src/session-flow/adapters/codebuddy-ide.ts b/src/session-flow/adapters/codebuddy-ide.ts new file mode 100644 index 00000000..cb345012 --- /dev/null +++ b/src/session-flow/adapters/codebuddy-ide.ts @@ -0,0 +1,336 @@ +/** + * adapters/codebuddy-ide.ts — CodeBuddy IDE(图形化)适配器。 + * + * 与 `codebuddy`(CLI)是**两套独立存储**,会话互不通用: + * - CLI: ~/.codebuddy/projects//.jsonl + * - IDE: /CodeBuddyExtension/Data//CodeBuddyIDE//history// + * + * 绝大多数用户的日常会话在 IDE 侧(实测:IDE 1650 条 vs CLI 1 条), + * 只支持 CLI 的话「从 codebuddy 迁出」基本无内容可迁。本适配器补上 IDE 侧的 + * 读 / 写 / 删,使两个平台各自闭环、互不隐式串写。 + * + * IDE 存储要点: + * - 工作区目录名 = md5(cwd),**不可逆**。故 cwd 只能由调用方传入才能还原; + * 未提供时 SessionMeta.cwd 记为 `md5:` 供展示与排错。 + * - conversation id = 32 位 hex(无横线) + * - 消息顺序由 /index.json 的 messages 数组决定,与文件名无关 + * - 消息文件 role 有三类:user / assistant / tool(tool-result 是独立消息) + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { AgentAdapter, type SessionMeta } from './base.js'; +import type { Session, Message, ContentBlock, ToolResultBlock } from '../ir.js'; +import { + findIdeHistoryDirs, + findIdeConversationDirs, + hashWorkspace, + listIdeConversations, + listIdeHistoryRoots, + readIdeConversation, + readIdeConversations, + writeIdeSession, + deleteIdeSession, + type IdeConversationEntry, + type IdeMessageParsed, +} from '../ide-history.js'; +import { cleanTitleText, isInjectedText } from '../title.js'; + +// --------------------------------------------------------------------------- +// 工具 +// --------------------------------------------------------------------------- + +/** + * IDE 会话 id 是 32 位 hex(无横线),UUID 形式去掉横线即可等价。 + * 其他形态原样返回,交由目录查找失败后报错。 + */ +function toConvId(sessionId: string): string { + const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (uuidRe.test(sessionId)) return sessionId.replace(/-/g, '').toLowerCase(); + return sessionId; +} + +/** 会话未提供 cwd 时的占位表示,让 UI 与排错时能看出「来源工作区未知」。 */ +function unknownWorkspace(hash: string): string { + return `md5:${hash}`; +} + +/** 标题兜底时最多看的消息条数(第一条通常就是用户提问)。 */ +const TITLE_LOOKAHEAD = 8; + +function firstUserText(messages: IdeMessageParsed[]): string { + for (const m of messages) { + if (m.role !== 'user') continue; + for (const block of m.content) { + if (block.type !== 'text' || typeof block.text !== 'string') continue; + if (isInjectedText(block.text)) continue; // 整条是注入,看下一条 + const cleaned = cleanTitleText(block.text); + if (cleaned) return cleaned; + } + } + return ''; +} + +/** + * IDE 会把 index.json 里的 conversation.name 原样落盘,偶尔也混入 + * 等注入块原文。不清洗的话会污染整个迁移链路 + * (列表标题、readSession.title、目标侧标题全是提示词原文)。 + */ +function safeConversationName(name: string): string { + return name && !isInjectedText(name) ? name.slice(0, 100) : ''; +} + +// --------------------------------------------------------------------------- +// CodeBuddyIdeAdapter +// --------------------------------------------------------------------------- + +export class CodeBuddyIdeAdapter extends AgentAdapter { + readonly platform = 'codebuddy-ide'; + + static isAvailable(): boolean { + return listIdeHistoryRoots().length > 0; + } + + isReady(): boolean { + return listIdeHistoryRoots().length > 0; + } + + static getDefaultStoragePath(): string { + return listIdeHistoryRoots()[0] ?? ''; + } + + async listConversations(projectPath?: string): Promise { + // 按 md5(cwd) 在工作区级过滤,而不是把 workspace 目录当成 history 根传下去 + // ——后者的子目录是会话目录,读出来的 index.json 没有 conversations 字段, + // 结果永远是空列表(且要白读一遍全部会话级 index.json,慢且错)。 + const entries = listIdeConversations(); + const hash = projectPath ? hashWorkspace(projectPath) : null; + const filtered = hash ? entries.filter((e) => e.workspaceHash === hash) : entries; + + return filtered.map((e) => this.toMeta(e, projectPath ?? '')); + } + + private toMeta(entry: IdeConversationEntry, knownCwd: string): SessionMeta { + let messageCount = 0; + let sizeBytes = 0; + const msgDir = path.join(entry.convDir, 'messages'); + try { + for (const f of fs.readdirSync(msgDir)) { + if (!f.endsWith('.json')) continue; + messageCount++; + try { + sizeBytes += fs.statSync(path.join(msgDir, f)).size; + } catch { + // 单个文件 stat 失败不阻断统计 + } + } + } catch { + // 目录不存在(会话刚建、未落盘)时按空会话处理 + } + + // 标题兜底只读开头几条:IDE 会话动辄几千条消息,为拿个标题把整会话读一遍 + // 会让列一次表耗时十几秒。 + const title = + safeConversationName(entry.name) || + firstUserText(readIdeConversation(entry.convDir, TITLE_LOOKAHEAD)) || + `Session ${entry.id.slice(0, 8)}`; + + return { + sessionId: entry.id, + title, + cwd: knownCwd || unknownWorkspace(entry.workspaceHash), + platform: this.platform, + createdAt: entry.createdAt || new Date().toISOString(), + updatedAt: entry.lastMessageAt || entry.createdAt || new Date().toISOString(), + messageCount, + filePath: entry.convDir, + sizeBytes, + }; + } + + async readSession(sessionId: string, projectPath?: string): Promise { + const convId = toConvId(sessionId); + + // 先按给定工作区定位,找不到再全局搜。 + // 会话 id 全局唯一,用户不必 cd 到当初的工作区才能迁出—— + // 而 IDE 的工作区目录名是 md5(cwd),没有 projectPath 时根本无从限定。 + let convDir: string | undefined; + let historyDir: string | undefined; + let workspaceHash = ''; + // 全局兜底命中的会话不属于 projectPath——真实工作区是 md5 不可逆的, + // cwd 只能标 md5 占位,绝不能冒充传入路径(否则归档键会跟着错)。 + let matchedGivenPath = false; + + if (projectPath) { + for (const dir of findIdeHistoryDirs(projectPath)) { + const candidate = path.join(dir, convId); + if (fs.existsSync(candidate)) { + convDir = candidate; + historyDir = dir; + workspaceHash = path.basename(dir); + matchedGivenPath = true; + break; + } + } + } + if (!convDir) { + const found = findIdeConversationDirs(convId)[0]; + if (found) { + convDir = found.convDir; + historyDir = found.historyDir; + workspaceHash = path.basename(found.historyDir); + } + } + if (!convDir || !historyDir) { + throw new Error(`CodeBuddy IDE session not found: session_id=${sessionId}`); + } + + const entry = readIdeConversations(historyDir).find((e) => e.id === convId); + const rawMessages = readIdeConversation(convDir); + + const messages: Message[] = []; + const sessionMetadata: Record = {}; + + for (const raw of rawMessages) { + if (raw.model && !sessionMetadata.model) sessionMetadata.model = raw.model; + + // IDE 的 tool-result 是独立 role:"tool" 消息,IR 没有该角色: + // 与 CLI 适配器保持一致,归入上一条 user 消息(不存在则新建一条 user)。 + if (raw.role === 'tool') { + for (const block of raw.content) { + if (block.type !== 'tool-result') continue; + const irBlock = this.parseToolResult(block); + if (!irBlock) continue; + const last = messages[messages.length - 1]; + if (last && last.role === 'user') { + last.content.push(irBlock); + } else { + // 带上原始时间戳:缺失时下游 writeSession(如 claude-code)会用 + // 迁移时刻填充,产生「后一条消息早于前一条」的时间倒挂。 + messages.push({ role: 'user', content: [irBlock], timestamp: raw.createdAt }); + } + } + continue; + } + + const content = this.parseContent(raw); + if (content.length === 0) continue; + + const msg: Message = { + role: raw.role === 'assistant' ? 'assistant' : 'user', + content, + messageId: raw.id, + timestamp: raw.createdAt, + }; + if (raw.model) msg.metadata = { model: raw.model }; + messages.push(msg); + } + + const title = + safeConversationName(entry?.name ?? '') || + firstUserText(rawMessages) || + `Session ${convId.slice(0, 8)}`; + const createdAt = entry?.createdAt || rawMessages[0]?.createdAt || new Date().toISOString(); + const updatedAt = + entry?.lastMessageAt || rawMessages[rawMessages.length - 1]?.createdAt || createdAt; + + return { + sessionId: convId, + title, + cwd: projectPath && matchedGivenPath ? projectPath : unknownWorkspace(workspaceHash), + platform: this.platform, + createdAt, + updatedAt, + messages, + metadata: sessionMetadata, + }; + } + + private parseContent(raw: IdeMessageParsed): ContentBlock[] { + const blocks: ContentBlock[] = []; + + for (const block of raw.content) { + switch (block.type) { + case 'text': { + const text = String(block.text ?? ''); + if (text) blocks.push({ type: 'text', text }); + break; + } + case 'reasoning': { + const text = String(block.text ?? block.reasoning ?? ''); + if (text) blocks.push({ type: 'thinking', text }); + break; + } + case 'tool-call': { + const callId = String(block.toolCallId ?? block.id ?? ''); + const args = (block.args ?? block.arguments ?? {}) as Record; + blocks.push({ + type: 'tool_call', + toolName: String(block.toolName ?? ''), + callId, + arguments: args, + }); + break; + } + case 'tool-result': { + const irBlock = this.parseToolResult(block); + if (irBlock) blocks.push(irBlock); + break; + } + default: + // image / 文件引用等 IDE 特有块:IR 无对应类型,跳过(保真度统计会体现) + break; + } + } + + return blocks; + } + + private parseToolResult(block: Record): ToolResultBlock | null { + const callId = String(block.toolCallId ?? block.id ?? ''); + const result = block.result as Record | undefined; + const inner = result?.result as Record | undefined; + + let content = ''; + if (typeof inner?.content === 'string') { + content = inner.content; + } else if (inner && typeof inner.content !== 'undefined') { + content = JSON.stringify(inner.content); + } else if (result) { + content = JSON.stringify(result); + } + + const isError = + Boolean(block.isError) || + result?.status === 'failed' || + result?.success === false; + + return { type: 'tool_result', callId, content, isError }; + } + + async writeSession(session: Session, projectPath?: string): Promise { + const cwd = projectPath ?? session.cwd; + + // writeIdeSession 依赖 md5(cwd) 定位工作区;cwd 是 `md5:` 这类占位值时 + // 算不出 hash 会静默跳过。静默成功比失败更危险——用户以为迁完了,侧边栏却是空的。 + // Windows 盘符路径(C:\...)也是合法绝对路径,一并放行。 + const absoluteLike = cwd.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(cwd); + if (!cwd || !absoluteLike) { + throw new Error( + `Writing to CodeBuddy IDE requires an absolute working directory, got "${cwd}". Pass --cwd/--target-cwd.`, + ); + } + + const result = writeIdeSession(session, cwd); + if (result.synced === 0 || !result.convId) { + throw new Error(`CodeBuddy IDE write failed: ${result.skipped ?? 'no IDE history directory found'}`); + } + + return result.convId; + } + + async deleteSession(sessionId: string, projectPath?: string): Promise { + const cleaned = deleteIdeSession(sessionId, projectPath); + return cleaned > 0; + } +} diff --git a/src/session-flow/adapters/codebuddy.ts b/src/session-flow/adapters/codebuddy.ts new file mode 100644 index 00000000..1de10e34 --- /dev/null +++ b/src/session-flow/adapters/codebuddy.ts @@ -0,0 +1,611 @@ +/** + * adapters/codebuddy.ts — CodeBuddy **CLI** 平台适配器。 + * + * 只覆盖 CLI 存储;CodeBuddy **IDE**(图形化侧边栏「历史对话」)是另一套 + * 独立存储,由 `adapters/codebuddy-ide.ts` 负责。两者会话互不通用, + * 本适配器不再读写 IDE 侧。 + * + * 读取/写入 `~/.codebuddy/projects//.jsonl` 格式。 + * cwd 编码: `/` → `-`,无前导 `-`。 + * 同目录下有 `/subagents/` 子目录存子代理会话(首版不迁移)。 + * + * JSONL 行类型(7 种): + * 读取:message/function_call/function_call_result/reasoning → IR;其余跳过 + * 写入:message + function_call + function_call_result + reasoning + + * ai-title + file-history-snapshot(辅助行) + * + * 增强点(vs Python 版): + * - reasoning 块写入为独立 reasoning 行(而非跳过) + * - 写入时生成 ai-title 行 + * - 写入时生成 file-history-snapshot 行 + * - timestamp 使用 Unix ms 整数 + */ + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { AgentAdapter, type SessionMeta } from './base.js'; +import type { Session, Message, ContentBlock, TextBlock, ThinkingBlock, ToolCallBlock, ToolResultBlock } from '../ir.js'; +import { + getCodeBuddyProjectsDir, + encodeCwdCodeBuddy, + decodeCwdGeneric, + readJsonl, + readJsonlHead, + writeJsonl, + fileExists, + dirExists, + removeDirRecursive, +} from '../fs.js'; +import { cleanTitleText, isInjectedText, fallbackTitle } from '../title.js'; + +// --------------------------------------------------------------------------- +// 工具名归一化映射 +// --------------------------------------------------------------------------- + +const CB_TO_IR_TOOL: Record = { + read_file: 'read_file', + write_file: 'write_file', + edit_file: 'edit_file', + bash: 'bash', + grep: 'grep', + glob: 'glob', + task: 'task', + todo_write: 'todo_write', +}; + +const IR_TO_CB_TOOL: Record = Object.fromEntries( + Object.entries(CB_TO_IR_TOOL).map(([k, v]) => [v, k]), +); + +function normalizeToolName(cbName: string): string { + return CB_TO_IR_TOOL[cbName] ?? cbName; +} + +function denormalizeToolName(irName: string): string { + return IR_TO_CB_TOOL[irName] ?? irName; +} + +// --------------------------------------------------------------------------- +// UUID / 时间戳工具 +// --------------------------------------------------------------------------- + +// 不校验 version 位:源平台的 sessionId 可能是 UUID v7(codex / codex-internal / tcodex)。 +// 只认 v4 会让这些会话每次迁移都重新生成一个 v4 ID —— 既不幂等(反复迁移堆积副本), +// 也无法再按源 sessionId 追踪或回滚。放宽到「任意合法 UUID 形状」即可复用源 ID。 +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isUuid(s: string): boolean { + return UUID_RE.test(s); +} + +function uuidV4(): string { + return crypto.randomUUID(); +} + +function toUnixMs(isoStr?: string): number { + if (!isoStr) return Date.now(); + const d = new Date(isoStr); + return isNaN(d.getTime()) ? Date.now() : d.getTime(); +} + +function fromUnixMs(ms: number): string { + return new Date(ms).toISOString(); +} + +// --------------------------------------------------------------------------- +// CodeBuddyAdapter +// --------------------------------------------------------------------------- + +export class CodeBuddyAdapter extends AgentAdapter { + readonly platform = 'codebuddy'; + + static isAvailable(): boolean { + return dirExists(getCodeBuddyProjectsDir()); + } + + isReady(): boolean { + return dirExists(getCodeBuddyProjectsDir()); + } + + static getDefaultStoragePath(): string { + return getCodeBuddyProjectsDir(); + } + + private resolveProjectDir(projectPath?: string): string { + const root = getCodeBuddyProjectsDir(); + if (projectPath) { + return path.join(root, encodeCwdCodeBuddy(projectPath)); + } + return root; + } + + private findSessionFile(sessionId: string, projectPath?: string): string | null { + if (projectPath) { + const target = path.join(this.resolveProjectDir(projectPath), `${sessionId}.jsonl`); + return fileExists(target) ? target : null; + } + const root = getCodeBuddyProjectsDir(); + if (!dirExists(root)) return null; + for (const projDir of fs.readdirSync(root)) { + const candidate = path.join(root, projDir, `${sessionId}.jsonl`); + if (fileExists(candidate)) return candidate; + } + return null; + } + + /** + * 找出该 sessionId 的**全部**副本(跨工作区)。 + * + * findSessionFile 命中首个即返回,用于读取没问题;但删除时只删首个会让其他 + * 工作区里的副本变成孤儿(无 IDE 条目、用户看不见、占空间)。 + */ + private findAllSessionFiles(sessionId: string, projectPath?: string): string[] { + if (projectPath) { + const target = path.join(this.resolveProjectDir(projectPath), `${sessionId}.jsonl`); + return fileExists(target) ? [target] : []; + } + const root = getCodeBuddyProjectsDir(); + if (!dirExists(root)) return []; + const out: string[] = []; + for (const projDir of fs.readdirSync(root)) { + const candidate = path.join(root, projDir, `${sessionId}.jsonl`); + if (fileExists(candidate)) out.push(candidate); + } + return out; + } + + async listConversations(projectPath?: string): Promise { + const metas: SessionMeta[] = []; + const root = getCodeBuddyProjectsDir(); + if (!dirExists(root)) return []; + + const projDirs = projectPath + ? [this.resolveProjectDir(projectPath)] + : fs.readdirSync(root).map((d) => path.join(root, d)); + + for (const projDir of projDirs) { + if (!dirExists(projDir)) continue; + const cwd = decodeCwdGeneric(path.basename(projDir)); + for (const jsonlFile of fs.readdirSync(projDir).filter((f) => f.endsWith('.jsonl')).sort()) { + const fullPath = path.join(projDir, jsonlFile); + const meta = this.extractMeta(fullPath, cwd); + if (meta) metas.push(meta); + } + } + return metas; + } + + private extractMeta(jsonlPath: string, cwd: string): SessionMeta | null { + const sessionId = path.basename(jsonlPath, '.jsonl'); + let title = ''; + let createdAt: string | undefined; + let updatedAt: string | undefined; + let messageCount = 0; + let firstUserText = ''; + let aiTitle = ''; + + try { + for (const record of readJsonlHead(jsonlPath, 80)) { + const rtype = record.type as string; + + if (rtype === 'ai-title') { + aiTitle = String(record.aiTitle ?? ''); + continue; + } + + const tsRaw = record.timestamp; + if (tsRaw !== undefined) { + const ts = fromUnixMs(Number(tsRaw)); + if (!createdAt) createdAt = ts; + updatedAt = ts; + } + + if (rtype === 'message') { + messageCount++; + const role = record.role as string; + if (role === 'user' && !firstUserText) { + const content = record.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block && typeof block === 'object' && (block as Record).type === 'input_text') { + const text = String((block as Record).text ?? ''); + // 首个文本块常是 system-reminder 等注入,跳过继续找真正的提问 + if (!isInjectedText(text)) { + firstUserText = text; + break; + } + } + } + } + } + } + } + } catch { + return null; + } + + if (!createdAt) createdAt = new Date().toISOString(); + if (!updatedAt) updatedAt = createdAt; + + // ai-title 是 CodeBuddy 自己起的标题,最可靠;否则退回首条用户文本。 + // 首条「用户消息」常常是 system-reminder 等注入块,不清洗的话 + // 会话列表里显示的就是一整段提示词原文。 + if (aiTitle && !isInjectedText(aiTitle)) { + title = aiTitle.slice(0, 60); + } else { + title = cleanTitleText(firstUserText) || fallbackTitle(sessionId); + } + + let sizeBytes = 0; + try { + sizeBytes = fs.statSync(jsonlPath).size; + } catch { + // ignore + } + + return { + sessionId, + title, + cwd, + platform: this.platform, + createdAt, + updatedAt, + messageCount, + filePath: jsonlPath, + sizeBytes, + }; + } + + async readSession(sessionId: string, projectPath?: string): Promise { + const jsonlPath = this.findSessionFile(sessionId, projectPath); + if (!jsonlPath) { + throw new Error(`CodeBuddy session file not found: session_id=${sessionId}`); + } + + const cwd = decodeCwdGeneric(path.basename(path.dirname(jsonlPath))); + const records = [...readJsonl(jsonlPath)]; + + // writeSession 写入的每行都带 cwd(真实绝对路径)。目录名解码是恒等函数 + // (还原不出真实路径),归档键(repoIdentity)必须优先用记录里的原生 cwd + // (设计文档 Key invariant);恢复失败退回目录名。 + let nativeCwd: string | undefined; + for (const rec of records) { + if (typeof rec.cwd === 'string' && path.isAbsolute(rec.cwd)) { + nativeCwd = rec.cwd; + break; + } + } + const sessionCwd = nativeCwd ?? cwd; + + let title = ''; + let createdAt: string | undefined; + let updatedAt: string | undefined; + const sessionMetadata: Record = {}; + const messages: Message[] = []; + + // 第一遍:提取 title/时间戳/元数据 + for (const rec of records) { + const rtype = rec.type as string; + + if (rtype === 'ai-title') { + // CodeBuddy 偶尔把注入块原文存成 ai-title,照收会污染整个迁移链路 + // (预览、目标侧标题全是提示词原文)。 + const t = String(rec.aiTitle ?? ''); + if (t && !isInjectedText(t)) title = t.slice(0, 100); + continue; + } + + const tsRaw = rec.timestamp; + if (tsRaw !== undefined) { + const ts = fromUnixMs(Number(tsRaw)); + if (!createdAt) createdAt = ts; + updatedAt = ts; + } + } + + // 第二遍:构建消息 + for (const rec of records) { + const rtype = rec.type as string; + + if (rtype === 'message') { + const role = rec.role as string; + if (role !== 'user' && role !== 'assistant') continue; + + const content = this.parseMessageContent(rec); + const msg: Message = { + role: role as 'user' | 'assistant', + content, + messageId: rec.id as string | undefined, + parentId: rec.parentId as string | undefined, + timestamp: rec.timestamp !== undefined ? fromUnixMs(Number(rec.timestamp)) : undefined, + }; + + // 提取 model + const providerData = rec.providerData as Record | undefined; + if (providerData?.model) { + msg.metadata = { model: String(providerData.model) }; + if (!sessionMetadata.model) sessionMetadata.model = String(providerData.model); + } + + messages.push(msg); + } else if (rtype === 'function_call') { + const name = String(rec.name ?? ''); + const irName = normalizeToolName(name); + const callId = String(rec.callId ?? rec.id ?? ''); + const providerData = rec.providerData as Record | undefined; + let argsRaw = providerData?.arguments ?? rec.arguments; + let arguments_: Record; + try { + arguments_ = typeof argsRaw === 'string' ? JSON.parse(argsRaw) : (argsRaw as Record) ?? {}; + } catch { + arguments_ = { _raw: String(argsRaw) }; + } + + const block: ToolCallBlock = { type: 'tool_call', toolName: irName, callId, arguments: arguments_ }; + + if (messages.length > 0 && messages[messages.length - 1].role === 'assistant') { + messages[messages.length - 1].content.push(block); + } else { + messages.push({ role: 'assistant', content: [block] }); + } + } else if (rtype === 'function_call_result') { + const callId = String(rec.callId ?? ''); + const output = rec.output as Record | undefined; + let contentStr = ''; + if (output) { + contentStr = String(output.text ?? ''); + } + const status = String(rec.status ?? 'completed'); + const isError = status === 'failed' || status === 'error'; + const block: ToolResultBlock = { type: 'tool_result', callId, content: contentStr, isError }; + + if (messages.length > 0 && messages[messages.length - 1].role === 'user') { + messages[messages.length - 1].content.push(block); + } else { + messages.push({ role: 'user', content: [block] }); + } + } else if (rtype === 'reasoning') { + const rawContent = rec.rawContent as Array> | undefined; + let text = ''; + if (Array.isArray(rawContent)) { + for (const part of rawContent) { + if (part.type === 'reasoning_text') { + text += String(part.text ?? ''); + } + } + } + const block: ThinkingBlock = { type: 'thinking', text }; + + if (messages.length > 0 && messages[messages.length - 1].role === 'assistant') { + messages[messages.length - 1].content.push(block); + } else { + messages.push({ role: 'assistant', content: [block] }); + } + } + } + + if (!title) { + for (const msg of messages) { + if (msg.role === 'user') { + for (const block of msg.content) { + if (block.type === 'text' && block.text) { + if (isInjectedText(block.text)) continue; // 注入块不当标题 + title = cleanTitleText(block.text); + if (title) break; + } + } + if (title) break; + } + } + } + if (!title) title = fallbackTitle(sessionId); + + if (!createdAt) createdAt = new Date().toISOString(); + if (!updatedAt) updatedAt = createdAt; + + return { + sessionId, + title, + cwd: sessionCwd, + platform: this.platform, + createdAt, + updatedAt, + messages, + metadata: sessionMetadata, + }; + } + + private parseMessageContent(rec: Record): ContentBlock[] { + const blocks: ContentBlock[] = []; + const contentArr = rec.content; + + if (typeof contentArr === 'string') { + blocks.push({ type: 'text', text: contentArr }); + return blocks; + } + + if (!Array.isArray(contentArr)) return blocks; + + for (const item of contentArr) { + if (!item || typeof item !== 'object') continue; + const it = item as Record; + const itemType = it.type as string; + const text = String(it.text ?? ''); + + if (itemType === 'input_text' || itemType === 'output_text') { + blocks.push({ type: 'text', text }); + } + } + return blocks; + } + + async writeSession(session: Session, projectPath?: string): Promise { + let sessionId = session.sessionId; + if (!isUuid(sessionId)) { + sessionId = uuidV4(); + } + + const cwd = projectPath ?? session.cwd; + const projDir = path.join(getCodeBuddyProjectsDir(), encodeCwdCodeBuddy(cwd)); + const jsonlPath = path.join(projDir, `${sessionId}.jsonl`); + + const records: Record[] = []; + + // 1. ai-title 行 + records.push({ + timestamp: toUnixMs(session.createdAt), + type: 'ai-title', + aiTitle: session.title, + sessionId, + cwd, + }); + + let parentId: string | null = null; + + for (const msg of session.messages) { + const msgId = msg.messageId ?? uuidV4(); + + // 分离 thinking blocks 和其他 blocks + const thinkingBlocks = msg.content.filter((b) => b.type === 'thinking'); + const otherBlocks = msg.content.filter((b) => b.type !== 'thinking'); + + // reasoning 行(thinking blocks → reasoning) + for (const tb of thinkingBlocks) { + const reasoningId = uuidV4(); + records.push({ + id: reasoningId, + parentId, + timestamp: toUnixMs(msg.timestamp), + type: 'reasoning', + providerData: msg.metadata?.model ? { model: msg.metadata.model } : {}, + content: [], + rawContent: [{ type: 'reasoning_text', text: (tb as ThinkingBlock).text }], + sessionId, + cwd, + }); + parentId = reasoningId; + } + + // message 行(text blocks) + if (otherBlocks.length > 0) { + const cbContent: Record[] = []; + let hasText = false; + for (const block of otherBlocks) { + if (block.type === 'text') { + cbContent.push({ + type: msg.role === 'user' ? 'input_text' : 'output_text', + text: block.text, + }); + hasText = true; + } + } + + if (hasText) { + records.push({ + id: msgId, + parentId, + timestamp: toUnixMs(msg.timestamp), + type: 'message', + role: msg.role, + status: 'completed', + content: cbContent, + providerData: msg.metadata?.model ? { model: msg.metadata.model } : {}, + sessionId, + cwd, + }); + parentId = msgId; + } + } + + // function_call 行(tool_call blocks) + for (const block of otherBlocks) { + if (block.type === 'tool_call') { + const fcId = block.callId || uuidV4(); + records.push({ + id: fcId, + parentId, + timestamp: toUnixMs(msg.timestamp), + type: 'function_call', + name: denormalizeToolName(block.toolName), + callId: block.callId, + providerData: { + arguments: block.arguments, + ...(msg.metadata?.model ? { model: msg.metadata.model } : {}), + }, + sessionId, + cwd, + }); + parentId = fcId; + } + } + + // function_call_result 行(tool_result blocks) + for (const block of otherBlocks) { + if (block.type === 'tool_result') { + const fcrId = uuidV4(); + records.push({ + id: fcrId, + parentId, + timestamp: toUnixMs(msg.timestamp), + type: 'function_call_result', + name: 'Agent', + callId: block.callId, + status: block.isError ? 'failed' : 'completed', + output: { type: 'text', text: block.content }, + sessionId, + cwd, + }); + parentId = fcrId; + } + } + + // file-history-snapshot 行(每条消息后) + records.push({ + id: uuidV4(), + timestamp: toUnixMs(msg.timestamp), + type: 'file-history-snapshot', + isSnapshotUpdate: false, + snapshot: { + messageId: msgId, + trackedFileBackups: {}, + }, + cwd, + }); + } + + writeJsonl(jsonlPath, records); + + // 这里**不再**顺带写 CodeBuddy IDE 的 history。 + // CLI(~/.codebuddy/projects/...)与 IDE(CodeBuddyExtension/.../history)是两套 + // 独立存储,而本适配器的 list/read/delete 只覆盖 CLI 一侧——写入时偷偷双写会造 + // 成读写不对称:迁进来的会话出现在 IDE 侧边栏,却既列不出来也删不掉。 + // 需要 IDE 侧会话请显式迁移到 `codebuddy-ide` 平台。 + + return sessionId; + } + + async deleteSession(sessionId: string, projectPath?: string): Promise { + const jsonlPaths = this.findAllSessionFiles(sessionId, projectPath); + + let cliDeleted = false; + for (const jsonlPath of jsonlPaths) { + try { + fs.unlinkSync(jsonlPath); + cliDeleted = true; + } catch { + // ignore + } + + // 删除同名子目录(subagents 等) + const subdir = jsonlPath.replace(/\.jsonl$/, ''); + if (dirExists(subdir)) { + removeDirRecursive(subdir); + } + } + + // 只清理 CLI 侧。IDE 侧会话由 `codebuddy-ide` 平台负责, + // 本适配器不再越界删除自己从未写入过的存储。 + return cliDeleted; + } +} diff --git a/src/session-flow/adapters/codex.ts b/src/session-flow/adapters/codex.ts new file mode 100644 index 00000000..4872fa8f --- /dev/null +++ b/src/session-flow/adapters/codex.ts @@ -0,0 +1,581 @@ +/** + * adapters/codex.ts — Codex (OpenAI Codex CLI) 适配器。 + * + * 读取/写入 `~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl` 格式。 + * + * JSONL 行类型(5 种顶层 type): + * - session_meta: 会话元数据(第一行) + * - response_item: 核心消息载体(message / function_call / function_call_output / reasoning) + * - event_msg: 事件日志(task_started / task_complete / user_message / agent_message / token_count) + * - turn_context: turn 上下文(cwd / sandbox_policy / model) + * + * 增强点(vs Python 版): + * - 写入时生成 turn_context 行 + * - 写入时生成 event_msg:task_started + task_complete + * - reasoning 块写入为 response_item:reasoning(而非跳过) + * - 支持 custom_tool_call / custom_tool_call_output + */ + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { AgentAdapter, type SessionMeta } from './base.js'; +import type { Session, Message, ContentBlock, TextBlock, ThinkingBlock, ToolCallBlock, ToolResultBlock } from '../ir.js'; +import { + getCodexSessionsDir, + resolveRealCwd, + readJsonl, + readJsonlHead, + writeJsonl, + fileExists, + dirExists, + scanFiles, +} from '../fs.js'; + +// --------------------------------------------------------------------------- +// 工具名归一化映射 +// --------------------------------------------------------------------------- + +const CODEX_TO_IR_TOOL: Record = { + exec_command: 'bash', + apply_patch: 'edit_file', + read_file: 'read_file', + write_file: 'write_file', +}; + +const IR_TO_CODEX_TOOL: Record = Object.fromEntries( + Object.entries(CODEX_TO_IR_TOOL).map(([k, v]) => [v, k]), +); + +function normalizeToolName(codexName: string): string { + return CODEX_TO_IR_TOOL[codexName] ?? codexName; +} + +function denormalizeToolName(irName: string): string { + return IR_TO_CODEX_TOOL[irName] ?? irName; +} + +// --------------------------------------------------------------------------- +// UUIDv7 生成 +// --------------------------------------------------------------------------- + +function generateUuidV7(): string { + const timestampMs = Date.now(); + // 前 48 位时间戳左移 80 位 + let uuidInt = BigInt(timestampMs & 0xffffffffffff) << 80n; + // 版本位 7(位 76-79) + uuidInt |= 7n << 76n; + // 随机位(低 62 位) + const randBytes = crypto.randomBytes(8); + let rand = 0n; + for (let i = 0; i < 8; i++) { + rand = (rand << 8n) | BigInt(randBytes[i]); + } + rand &= (1n << 62n) - 1n; + uuidInt |= rand; + // 设置变体位(位 62-63 为 10) + uuidInt = (uuidInt & ~(0x3n << 62n)) | (0x2n << 62n); + + // 转为 UUID 字符串 + const hex = uuidInt.toString(16).padStart(32, '0'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +function isUuidV7(sid: string): boolean { + const re = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return re.test(sid); +} + +// --------------------------------------------------------------------------- +// 时间戳工具 +// --------------------------------------------------------------------------- + +function parseCodexTimestamp(ts: unknown): string { + if (typeof ts === 'number') { + return new Date(ts).toISOString(); + } + if (typeof ts === 'string') { + try { + return new Date(ts).toISOString(); + } catch { + return new Date().toISOString(); + } + } + return new Date().toISOString(); +} + +function formatFilenameTimestamp(isoStr: string): string { + // 2026-07-09T00-00-00(: → -) + return isoStr.replace(/\.\d{3}Z$/, '').replace(/:/g, '-'); +} + +// --------------------------------------------------------------------------- +// CodexAdapter +// --------------------------------------------------------------------------- + +export class CodexAdapter extends AgentAdapter { + readonly platform: string; + private readonly storageRoot: string; + + /** + * @param platform 平台标识(默认 'codex',变体可传 'codex-internal' / 'tcodex') + * @param storageRoot 存储根路径(默认 ~/.codex/sessions,变体传 ~/.codex-internal/sessions 等) + */ + constructor(platform = 'codex', storageRoot?: string) { + super(); + this.platform = platform; + this.storageRoot = storageRoot ?? getCodexSessionsDir(); + } + + static isAvailable(): boolean { + return dirExists(getCodexSessionsDir()); + } + + static getDefaultStoragePath(): string { + return getCodexSessionsDir(); + } + + isReady(): boolean { + return dirExists(this.storageRoot); + } + + private scanJsonlFiles(): string[] { + return scanFiles(this.storageRoot, /\.jsonl$/); + } + + private findSessionFile(sessionId: string): string | null { + for (const f of this.scanJsonlFiles()) { + // 文件名是 rollout-<时间戳>-,中缀匹配;但前缀只认 ≥8 位, + // 否则 4 位前缀的子串会读到别人的会话 + const base = path.basename(f, '.jsonl'); + if (base === sessionId || (sessionId.length >= 8 && base.endsWith(sessionId))) return f; + } + return null; + } + + private readFirstLine(filePath: string): Record | null { + try { + for (const record of readJsonlHead(filePath, 1)) { + return record; + } + } catch { + // ignore + } + return null; + } + + private extractTitle(filePath: string): string { + const name = path.basename(filePath, '.jsonl'); + // rollout-2026-06-09T15-01-17- + const parts = name.split('-', 1); + if (parts.length === 1 && name.startsWith('rollout-')) { + const tsUuid = name.slice('rollout-'.length); + const idx = tsUuid.lastIndexOf('-'); + if (idx > 0) { + const tsPart = tsUuid.slice(0, idx); + if (tsPart) return `Session ${tsPart}`; + } + } + return name; + } + + async listConversations(projectPath?: string): Promise { + const metas: SessionMeta[] = []; + + for (const f of this.scanJsonlFiles()) { + const first = this.readFirstLine(f); + if (!first || first.type !== 'session_meta') continue; + + const payload = (first.payload as Record) ?? {}; + const sessionId = String(payload.id ?? ''); + const cwd = String(payload.cwd ?? ''); + const tsRaw = payload.timestamp; + + if (projectPath) { + // 不能用精确字符串比较:macOS 上 /tmp 与 /private/tmp 是同一目录的两种拼写 + // (symlink),写入时与列出时的拼写不一致会让会话「列出为空」。 + // 与 encodeCwd*/hashWorkspace 一致,先 realpath 再比较。 + if (resolveRealCwd(cwd) !== resolveRealCwd(projectPath)) continue; + } + + const createdAt = parseCodexTimestamp(tsRaw); + let updatedAt = createdAt; + try { + updatedAt = new Date(fs.statSync(f).mtimeMs).toISOString(); + } catch { + // ignore + } + + const title = this.extractTitle(f); + let sizeBytes = 0; + try { + sizeBytes = fs.statSync(f).size; + } catch { + // ignore + } + + // 统计消息数(快速扫描 response_item:message) + let messageCount = 0; + try { + for (const rec of readJsonlHead(f, 200)) { + if (rec.type === 'response_item') { + const payload = (rec.payload as Record) ?? {}; + if (payload.type === 'message') messageCount++; + } + } + } catch { + // ignore + } + + metas.push({ + sessionId, + title, + cwd, + platform: this.platform, + createdAt, + updatedAt, + messageCount, + filePath: f, + sizeBytes, + }); + } + return metas; + } + + async readSession(sessionId: string, projectPath?: string): Promise { + const f = this.findSessionFile(sessionId); + if (!f) throw new Error(`Codex session not found: ${sessionId}`); + + const records = [...readJsonl(f)]; + + let cwd = ''; + let createdAt = new Date().toISOString(); + const sessionMetadata: Record = {}; + + for (const rec of records) { + if (rec.type === 'session_meta') { + const payload = (rec.payload as Record) ?? {}; + cwd = String(payload.cwd ?? ''); + createdAt = parseCodexTimestamp(payload.timestamp); + for (const key of ['originator', 'cli_version', 'source', 'model_provider'] as const) { + if (payload[key] !== undefined) sessionMetadata[key] = payload[key]; + } + break; + } + } + + const messages = this.buildMessages(records); + + let updatedAt = createdAt; + if (messages.length > 0 && messages[messages.length - 1].timestamp) { + updatedAt = messages[messages.length - 1].timestamp!; + } else { + try { + updatedAt = new Date(fs.statSync(f).mtimeMs).toISOString(); + } catch { + // ignore + } + } + + const title = this.extractTitle(f); + + return { + sessionId, + title, + cwd, + platform: this.platform, + createdAt, + updatedAt, + messages, + metadata: sessionMetadata, + }; + } + + private buildMessages(records: Record[]): Message[] { + const messages: Message[] = []; + + for (const rec of records) { + const rtype = rec.type as string; + + if (rtype === 'turn_context') { + const payload = (rec.payload as Record) ?? {}; + const model = payload.model as string | undefined; + if (model && messages.length > 0) { + if (!messages[messages.length - 1].metadata) { + messages[messages.length - 1].metadata = {}; + } + messages[messages.length - 1].metadata!.model = model; + } + continue; + } + + if (rtype !== 'response_item') continue; + + const payload = (rec.payload as Record) ?? {}; + const ptype = payload.type as string; + + if (ptype === 'message') { + const role = payload.role as string; + if (role === 'developer') continue; // 系统提示跳过 + + const irRole = role === 'user' ? 'user' : 'assistant'; + const content = this.parseMessageContent(payload); + messages.push({ role: irRole, content, timestamp: parseCodexTimestamp(rec.timestamp) }); + } else if (ptype === 'function_call' || ptype === 'custom_tool_call') { + const name = String(payload.name ?? ''); + const irName = normalizeToolName(name); + const callId = String(payload.call_id ?? ''); + const argsRaw = payload.arguments; + let arguments_: Record; + try { + arguments_ = typeof argsRaw === 'string' ? JSON.parse(argsRaw) : (argsRaw as Record) ?? {}; + } catch { + arguments_ = { _raw: String(argsRaw) }; + } + + const block: ToolCallBlock = { type: 'tool_call', toolName: irName, callId, arguments: arguments_ }; + + if (messages.length > 0 && messages[messages.length - 1].role === 'assistant') { + messages[messages.length - 1].content.push(block); + } else { + messages.push({ role: 'assistant', content: [block], timestamp: parseCodexTimestamp(rec.timestamp) }); + } + } else if (ptype === 'function_call_output' || ptype === 'custom_tool_call_output') { + const callId = String(payload.call_id ?? ''); + const output = String(payload.output ?? ''); + const block: ToolResultBlock = { type: 'tool_result', callId, content: output, isError: false }; + + if (messages.length > 0 && messages[messages.length - 1].role === 'user') { + messages[messages.length - 1].content.push(block); + } else { + messages.push({ role: 'user', content: [block], timestamp: parseCodexTimestamp(rec.timestamp) }); + } + } else if (ptype === 'reasoning') { + // reasoning → ThinkingBlock + const rawContent = payload.rawContent as Array> | undefined; + let text = ''; + if (Array.isArray(rawContent)) { + for (const part of rawContent) { + if (part.type === 'reasoning_text') { + text += String(part.text ?? ''); + } + } + } + const block: ThinkingBlock = { type: 'thinking', text }; + + if (messages.length > 0 && messages[messages.length - 1].role === 'assistant') { + messages[messages.length - 1].content.push(block); + } else { + messages.push({ role: 'assistant', content: [block], timestamp: parseCodexTimestamp(rec.timestamp) }); + } + } + } + return messages; + } + + private parseMessageContent(payload: Record): ContentBlock[] { + const blocks: ContentBlock[] = []; + const contentArr = payload.content; + + if (typeof contentArr === 'string') { + blocks.push({ type: 'text', text: contentArr }); + return blocks; + } + + if (!Array.isArray(contentArr)) return blocks; + + for (const item of contentArr) { + if (!item || typeof item !== 'object') continue; + const it = item as Record; + const itemType = it.type as string; + const text = String(it.text ?? ''); + + if (itemType === 'input_text' || itemType === 'output_text') { + blocks.push({ type: 'text', text }); + } + } + return blocks; + } + + async writeSession(session: Session, projectPath?: string): Promise { + // session_id: 确保是 UUIDv7 + let sessionId = session.sessionId; + if (!isUuidV7(sessionId)) { + sessionId = generateUuidV7(); + } + + // 损坏输入防御:session.createdAt 非法时 new Date(...) 得到 Invalid Date, + // 直接 toISOString() 会抛 RangeError 让整个写入崩溃。 + const rawCreated = new Date(session.createdAt); + const createdAt = isNaN(rawCreated.getTime()) ? new Date() : rawCreated; + const tsIso = createdAt.toISOString(); + const tsMs = createdAt.getTime(); + const fileTs = formatFilenameTimestamp(tsIso); + + // 文件路径 + const dateDir = path.join( + this.storageRoot, + `${createdAt.getFullYear()}`, + String(createdAt.getMonth() + 1).padStart(2, '0'), + String(createdAt.getDate()).padStart(2, '0'), + ); + const filename = `rollout-${fileTs}-${sessionId}.jsonl`; + const filePath = path.join(dateDir, filename); + + // 构建 JSONL 记录 + const records: Record[] = []; + + // 1. session_meta + records.push({ + timestamp: tsIso, + type: 'session_meta', + payload: { + id: sessionId, + timestamp: tsMs, + cwd: projectPath ?? session.cwd, + originator: 'sessionflow', + cli_version: '0.1.0', + source: 'migration', + }, + }); + + // 2. 遍历 messages,写 response_item + turn_context + event_msg + let turnId = generateUuidV7(); + let turnStarted = false; + // 消息原生时间戳优先——全部用迁移时刻会让时间线塌缩成一点, + // 经 claude-code 中转后甚至无法恢复先后顺序 + let lastTs = tsIso; + + for (const msg of session.messages) { + const parsedTs = msg.timestamp ? new Date(msg.timestamp) : null; + const msgTs = + parsedTs && !isNaN(parsedTs.getTime()) ? parsedTs.toISOString() : lastTs; + lastTs = msgTs; + + // 每个 user 消息开始一个新 turn + if (msg.role === 'user') { + // 如果上一个 turn 已开始,先完成它 + if (turnStarted) { + records.push({ + timestamp: msgTs, + type: 'event_msg', + payload: { + type: 'task_complete', + turn_id: turnId, + completed_at: Math.floor(new Date(msgTs).getTime() / 1000), + }, + }); + } + // 新 turn + turnId = generateUuidV7(); + records.push({ + timestamp: msgTs, + type: 'event_msg', + payload: { + type: 'task_started', + turn_id: turnId, + started_at: Math.floor(new Date(msgTs).getTime() / 1000), + }, + }); + records.push({ + timestamp: msgTs, + type: 'turn_context', + payload: { + turn_id: turnId, + cwd: projectPath ?? session.cwd, + workspace_roots: [projectPath ?? session.cwd], + }, + }); + turnStarted = true; + } + + // 写消息的每个 content block + for (const block of msg.content) { + const rec = this.blockToResponseItem(msg.role, block, msgTs); + if (rec) records.push(rec); + } + } + + // 最后一个 turn 的 task_complete + if (turnStarted) { + records.push({ + timestamp: lastTs, + type: 'event_msg', + payload: { + type: 'task_complete', + turn_id: turnId, + completed_at: Math.floor(new Date(lastTs).getTime() / 1000), + }, + }); + } + + writeJsonl(filePath, records); + return sessionId; + } + + private blockToResponseItem(role: string, block: ContentBlock, timestamp?: string): Record | null { + const ts = timestamp ?? new Date().toISOString(); + + switch (block.type) { + case 'text': { + const contentType = role === 'user' ? 'input_text' : 'output_text'; + return { + timestamp: ts, + type: 'response_item', + payload: { + type: 'message', + role, + content: [{ type: contentType, text: block.text }], + }, + }; + } + case 'tool_call': { + const codexName = denormalizeToolName(block.toolName); + return { + timestamp: ts, + type: 'response_item', + payload: { + type: 'function_call', + name: codexName, + arguments: JSON.stringify(block.arguments), + call_id: block.callId, + }, + }; + } + case 'tool_result': { + return { + timestamp: ts, + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: block.callId, + output: block.content, + }, + }; + } + case 'thinking': { + // Codex 支持 reasoning,写入为 reasoning response_item + return { + timestamp: ts, + type: 'response_item', + payload: { + type: 'reasoning', + content: [], + rawContent: [{ type: 'reasoning_text', text: block.text }], + }, + }; + } + } + } + + async deleteSession(sessionId: string, projectPath?: string): Promise { + const f = this.findSessionFile(sessionId); + if (f && fileExists(f)) { + try { + fs.unlinkSync(f); + } catch { + // ignore + } + } + } +} diff --git a/src/session-flow/adapters/cursor.ts b/src/session-flow/adapters/cursor.ts new file mode 100644 index 00000000..a3d51b34 --- /dev/null +++ b/src/session-flow/adapters/cursor.ts @@ -0,0 +1,421 @@ +/** + * adapters/cursor.ts — Cursor 平台适配器。 + * + * 读取/写入 `~/.cursor/projects//agent-transcripts//.jsonl` 格式。 + * cwd 编码: `/` → `-`,无前导 `-`。 + * + * JSONL 行类型(2 种): + * - 消息行(无 type 字段):{role, message:{content:[block...]}} + * - turn_ended 行:{type:"turn_ended", status:"success"} + * + * 特点: + * - 消息行没有 type 字段,靠 role + message 结构识别 + * - content block 类型:text / tool_use(无 tool_result,工具结果不写入 transcript) + * - 迁移时 ToolResultBlock 降级为 TextBlock + * + * 增强点(vs Python 版): + * - 写入时生成 turn_ended 行 + * - 目录结构正确创建 agent-transcripts// + * - tool_result 降级处理 + */ + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { AgentAdapter, type SessionMeta } from './base.js'; +import type { Session, Message, ContentBlock, TextBlock, ToolCallBlock, ToolResultBlock, ThinkingBlock } from '../ir.js'; +import { + getCursorProjectsDir, + encodeCwdGeneric, + decodeCwdGeneric, + readJsonl, + readJsonlHead, + writeJsonl, + fileExists, + dirExists, + removeDirRecursive, +} from '../fs.js'; +import { cleanTitleText, fallbackTitle, isInjectedText } from '../title.js'; + +// --------------------------------------------------------------------------- +// 工具名归一化映射 +// --------------------------------------------------------------------------- + +const CURSOR_TO_IR_TOOL: Record = { + ReadFile: 'read_file', + Read: 'read_file', + WriteFile: 'write_file', + Write: 'write_file', + EditFile: 'edit_file', + Edit: 'edit_file', + Shell: 'bash', + Grep: 'grep', + Glob: 'glob', + DeleteFile: 'delete_file', + WebFetch: 'web_fetch', + WebSearch: 'web_search', + SemanticSearch: 'semantic_search', +}; + +const IR_TO_CURSOR_TOOL: Record = Object.fromEntries( + Object.entries(CURSOR_TO_IR_TOOL).map(([k, v]) => [v, k]), +); + +function normalizeToolName(cursorName: string): string { + return CURSOR_TO_IR_TOOL[cursorName] ?? cursorName; +} + +function denormalizeToolName(irName: string): string { + // 优先用 ReadFile/WriteFile 等完整名 + return IR_TO_CURSOR_TOOL[irName] ?? irName; +} + +// --------------------------------------------------------------------------- +// UUID 工具 +// --------------------------------------------------------------------------- + +// 任意合法 UUID 形状(不校验 version 位)。收紧到 v4 会让 codex v7 等来源的 +// sessionId 每次写入都被换成新随机 id:同一会话反复迁移各生成一份副本, +// 既不幂等也无法按源 sessionId 回滚。与 codebuddy.ts 的放宽策略保持一致。 +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isUuid(s: string): boolean { + return UUID_RE.test(s); +} + +function uuidV4(): string { + return crypto.randomUUID(); +} + +// --------------------------------------------------------------------------- +// CursorAdapter +// --------------------------------------------------------------------------- + +export class CursorAdapter extends AgentAdapter { + readonly platform = 'cursor'; + + static isAvailable(): boolean { + return dirExists(getCursorProjectsDir()); + } + + isReady(): boolean { + return dirExists(getCursorProjectsDir()); + } + + static getDefaultStoragePath(): string { + return getCursorProjectsDir(); + } + + private resolveProjectDir(projectPath?: string): string { + const root = getCursorProjectsDir(); + if (projectPath) { + return path.join(root, encodeCwdGeneric(projectPath)); + } + return root; + } + + private findSessionFile(sessionId: string, projectPath?: string): string | null { + if (projectPath) { + const target = path.join( + this.resolveProjectDir(projectPath), + 'agent-transcripts', + sessionId, + `${sessionId}.jsonl`, + ); + return fileExists(target) ? target : null; + } + // 遍历所有项目目录 + const root = getCursorProjectsDir(); + if (!dirExists(root)) return null; + for (const projDir of fs.readdirSync(root)) { + const transcriptsDir = path.join(root, projDir, 'agent-transcripts'); + if (!dirExists(transcriptsDir)) continue; + for (const sid of fs.readdirSync(transcriptsDir)) { + const candidate = path.join(transcriptsDir, sid, `${sid}.jsonl`); + if (fileExists(candidate) && sid === sessionId) return candidate; + } + } + return null; + } + + async listConversations(projectPath?: string): Promise { + const metas: SessionMeta[] = []; + const root = getCursorProjectsDir(); + if (!dirExists(root)) return []; + + const projDirs = projectPath + ? [this.resolveProjectDir(projectPath)] + : fs.readdirSync(root).map((d) => path.join(root, d)); + + for (const projDir of projDirs) { + if (!dirExists(projDir)) continue; + const cwd = decodeCwdGeneric(path.basename(projDir)); + const transcriptsDir = path.join(projDir, 'agent-transcripts'); + if (!dirExists(transcriptsDir)) continue; + + for (const sid of fs.readdirSync(transcriptsDir)) { + const fullPath = path.join(transcriptsDir, sid, `${sid}.jsonl`); + if (!fileExists(fullPath)) continue; + const meta = this.extractMeta(fullPath, cwd, sid); + if (meta) metas.push(meta); + } + } + return metas; + } + + private extractMeta(jsonlPath: string, cwd: string, sessionId: string): SessionMeta | null { + let title = ''; + let createdAt: string | undefined; + let updatedAt: string | undefined; + let messageCount = 0; + let firstUserText = ''; + + try { + const stat = fs.statSync(jsonlPath); + createdAt = stat.birthtime.toISOString(); + updatedAt = stat.mtime.toISOString(); + } catch { + createdAt = new Date().toISOString(); + updatedAt = createdAt; + } + + try { + for (const record of readJsonlHead(jsonlPath, 50)) { + // 消息行没有 type 字段 + if (record.type === 'turn_ended') continue; + + const role = record.role as string | undefined; + if (role !== 'user' && role !== 'assistant') continue; + + messageCount++; + if (role === 'user' && !firstUserText) { + const msg = record.message as Record | undefined; + const content = msg?.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block && typeof block === 'object' && (block as Record).type === 'text') { + const text = String((block as Record).text ?? ''); + // 首个文本块常是 system-reminder 等注入,跳过继续找真正的提问 + if (!isInjectedText(text)) { + firstUserText = text; + break; + } + } + } + } + } + } + } catch { + return null; + } + + title = cleanTitleText(firstUserText) || fallbackTitle(sessionId); + + let sizeBytes = 0; + try { + sizeBytes = fs.statSync(jsonlPath).size; + } catch { + // ignore + } + + return { + sessionId, + title, + cwd, + platform: this.platform, + createdAt, + updatedAt, + messageCount, + filePath: jsonlPath, + sizeBytes, + }; + } + + async readSession(sessionId: string, projectPath?: string): Promise { + const jsonlPath = this.findSessionFile(sessionId, projectPath); + if (!jsonlPath) { + throw new Error(`Cursor session file not found: session_id=${sessionId}`); + } + + // 目录层级是 /agent-transcripts//.jsonl, + // 需要往上取 3 层才到项目目录;取 2 层会得到中间层 agent-transcripts, + // 导致 session.cwd 变成这个占位目录名(migrate 的 Preview 会直接显示它)。 + const cwd = decodeCwdGeneric( + path.basename(path.dirname(path.dirname(path.dirname(jsonlPath)))), + ); + const records = [...readJsonl(jsonlPath)]; + + // 归档键(repoIdentity)优先用记录里的原生 cwd(真实绝对路径); + // 目录名解码有损,恢复失败退回解码目录名(设计文档 Key invariant)。 + let nativeCwd: string | undefined; + for (const rec of records) { + if (typeof rec.cwd === 'string' && path.isAbsolute(rec.cwd)) { + nativeCwd = rec.cwd; + break; + } + } + const sessionCwd = nativeCwd ?? cwd; + + const messages: Message[] = []; + + for (const rec of records) { + // turn_ended 行跳过 + if (rec.type === 'turn_ended') continue; + + // 消息行(无 type 字段) + const role = rec.role as string | undefined; + if (role !== 'user' && role !== 'assistant') continue; + + const msg = rec.message as Record | undefined; + const content = msg?.content; + const blocks = this.parseContentBlocks(content); + + messages.push({ + role: role as 'user' | 'assistant', + content: blocks, + }); + } + + // 提取标题 + let title = ''; + for (const msg of messages) { + if (msg.role === 'user') { + for (const block of msg.content) { + if (block.type === 'text' && block.text) { + // 写入端把 tool_result 降级为带该前缀的 text 块——工具输出不是标题 + if (block.text.startsWith('[tool_result')) continue; + if (isInjectedText(block.text)) continue; // 注入块不当标题 + title = cleanTitleText(block.text); + if (title) break; + } + } + if (title) break; + } + } + if (!title) title = `Session ${sessionId.slice(0, 8)}`; + + let createdAt: string; + let updatedAt: string; + try { + const stat = fs.statSync(jsonlPath); + createdAt = stat.birthtime.toISOString(); + updatedAt = stat.mtime.toISOString(); + } catch { + createdAt = new Date().toISOString(); + updatedAt = createdAt; + } + + return { + sessionId, + title, + cwd: sessionCwd, + platform: this.platform, + createdAt, + updatedAt, + messages, + metadata: {}, + }; + } + + private parseContentBlocks(content: unknown): ContentBlock[] { + const blocks: ContentBlock[] = []; + + if (typeof content === 'string') { + blocks.push({ type: 'text', text: content }); + return blocks; + } + + if (!Array.isArray(content)) return blocks; + + for (const item of content) { + if (!item || typeof item !== 'object') continue; + const it = item as Record; + const btype = it.type as string; + + if (btype === 'text') { + blocks.push({ type: 'text', text: String(it.text ?? '') }); + } else if (btype === 'tool_use') { + blocks.push({ + type: 'tool_call', + toolName: normalizeToolName(String(it.name ?? '')), + callId: String(it.id ?? ''), + arguments: (it.input as Record) ?? {}, + }); + } + // Cursor 没有 tool_result / thinking + } + return blocks; + } + + async writeSession(session: Session, projectPath?: string): Promise { + let sessionId = session.sessionId; + if (!isUuid(sessionId)) { + sessionId = uuidV4(); + } + + const cwd = projectPath ?? session.cwd; + const projDir = path.join(getCursorProjectsDir(), encodeCwdGeneric(cwd)); + const transcriptDir = path.join(projDir, 'agent-transcripts', sessionId); + const jsonlPath = path.join(transcriptDir, `${sessionId}.jsonl`); + + const records: Record[] = []; + + for (const msg of session.messages) { + const cursorContent: Record[] = []; + + for (const block of msg.content) { + switch (block.type) { + case 'text': + cursorContent.push({ type: 'text', text: block.text }); + break; + case 'thinking': + // Cursor 无 thinking,降级为 text + cursorContent.push({ type: 'text', text: `\n${block.text}\n` }); + break; + case 'tool_call': + cursorContent.push({ + type: 'tool_use', + name: denormalizeToolName(block.toolName), + input: block.arguments, + }); + break; + case 'tool_result': + // Cursor transcript 不存储 tool_result,降级为 text + cursorContent.push({ + type: 'text', + text: `[tool_result${block.isError ? ' (error)' : ''}]\n${block.content}`, + }); + break; + } + } + + if (cursorContent.length > 0) { + records.push({ + role: msg.role, + // 写入 cwd 使 readSession 能恢复真实路径(归档键派生依赖它) + cwd, + message: { content: cursorContent }, + }); + } + + // 每个 assistant turn 后加 turn_ended + if (msg.role === 'assistant') { + records.push({ type: 'turn_ended', status: 'success' }); + } + } + + writeJsonl(jsonlPath, records); + return sessionId; + } + + async deleteSession(sessionId: string, projectPath?: string): Promise { + const jsonlPath = this.findSessionFile(sessionId, projectPath); + if (!jsonlPath) return; + + // 删除整个 session 目录 + const sessionDir = path.dirname(jsonlPath); + if (dirExists(sessionDir)) { + removeDirRecursive(sessionDir); + } + } +} diff --git a/src/session-flow/adapters/index.ts b/src/session-flow/adapters/index.ts new file mode 100644 index 00000000..c0741c01 --- /dev/null +++ b/src/session-flow/adapters/index.ts @@ -0,0 +1,74 @@ +/** + * adapters/index.ts — 适配器注册。 + * + * 将所有平台适配器注册到 ADAPTER_REGISTRY,供迁移引擎使用。 + */ + +import type { AgentAdapter } from './base.js'; +import { ClaudeCodeAdapter } from './claude-code.js'; +import { CodexAdapter } from './codex.js'; +import { CodeBuddyAdapter } from './codebuddy.js'; +import { CodeBuddyIdeAdapter } from './codebuddy-ide.js'; +import { WorkBuddyAdapter } from './workbuddy.js'; +import { CursorAdapter } from './cursor.js'; +import { + getClaudeCodeProjectsDir, + getClaudeInternalProjectsDir, + getTClaudeProjectsDir, + getCodexSessionsDir, + getCodexInternalSessionsDir, + getTCodexSessionsDir, +} from '../fs.js'; + +export type AdapterFactory = () => AgentAdapter; + +export const ADAPTER_REGISTRY: Record = { + // 基础平台 + 'claude-code': () => new ClaudeCodeAdapter('claude-code', getClaudeCodeProjectsDir()), + codex: () => new CodexAdapter('codex', getCodexSessionsDir()), + // CodeBuddy 有两套独立存储,拆成两个平台: + // codebuddy = CLI(~/.codebuddy/projects/...) + // codebuddy-ide = IDE 图形化(CodeBuddyExtension/.../history) + codebuddy: () => new CodeBuddyAdapter(), + 'codebuddy-ide': () => new CodeBuddyIdeAdapter(), + workbuddy: () => new WorkBuddyAdapter(), + cursor: () => new CursorAdapter(), + // TeamAI 变体(路径前缀不同,格式完全相同) + 'claude-internal': () => new ClaudeCodeAdapter('claude-internal', getClaudeInternalProjectsDir()), + tclaude: () => new ClaudeCodeAdapter('tclaude', getTClaudeProjectsDir()), + 'codex-internal': () => new CodexAdapter('codex-internal', getCodexInternalSessionsDir()), + tcodex: () => new CodexAdapter('tcodex', getTCodexSessionsDir()), +}; + +export function getAdapter(platform: string): AgentAdapter { + const factory = ADAPTER_REGISTRY[platform]; + if (!factory) { + throw new Error(`Unsupported platform: ${platform}. Registered: ${Object.keys(ADAPTER_REGISTRY).join(', ')}`); + } + return factory(); +} + +export function listAvailablePlatforms(): string[] { + return Object.keys(ADAPTER_REGISTRY); +} + +export function listInstalledPlatforms(): string[] { + const installed: string[] = []; + for (const [platform, factory] of Object.entries(ADAPTER_REGISTRY)) { + try { + const adapter = factory(); + if (adapter.isReady()) installed.push(platform); + } catch { + // skip + } + } + return installed; +} + +export { AgentAdapter, type SessionMeta } from './base.js'; +export { ClaudeCodeAdapter } from './claude-code.js'; +export { CodexAdapter } from './codex.js'; +export { CodeBuddyAdapter } from './codebuddy.js'; +export { CodeBuddyIdeAdapter } from './codebuddy-ide.js'; +export { WorkBuddyAdapter } from './workbuddy.js'; +export { CursorAdapter } from './cursor.js'; diff --git a/src/session-flow/adapters/workbuddy.ts b/src/session-flow/adapters/workbuddy.ts new file mode 100644 index 00000000..470b0811 --- /dev/null +++ b/src/session-flow/adapters/workbuddy.ts @@ -0,0 +1,619 @@ +/** + * adapters/workbuddy.ts — WorkBuddy 平台适配器。 + * + * 读取/写入 `~/.workbuddy/projects//.jsonl` 格式。 + * cwd 编码: `/` → `-`,无前导 `-`(与 CodeBuddy 一致)。 + * + * 实测格式(2026-09-10 本机 ~/.workbuddy/projects/... 采样): + * {"id":"65d9...","logicalParentId":"65d9...","timestamp":1773734800538, + * "type":"message","role":"user","sessionId":"6d7b...", + * "content":[{"type":"input_text","text":"..."}], + * "providerData":{"references":[{"type":"memory","enabled":true,"memories":[]}]}} + * + * 行类型与 CodeBuddy 完全同构: + * message / function_call / function_call_result / reasoning / ai-title + * + * 增强点(vs CodeBuddy): + * - 同目录存在 `.meta.json`,内含**真实 cwd**,优先于目录名反解 + * (目录名编码不可逆:'-' 可能来自 '/'、空格等,反解有损) + * - meta.json 还提供 createdAt / updatedAt,优先于从行内时间戳推断 + * - providerData 完整保留到 Message.metadata(含 memory references 等扩展字段) + */ + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { AgentAdapter, type SessionMeta } from './base.js'; +import type { Session, Message, ContentBlock, ThinkingBlock, ToolCallBlock, ToolResultBlock } from '../ir.js'; +import { + getWorkBuddyProjectsDir, + encodeCwdGeneric, + decodeCwdGeneric, + readJsonl, + readJsonlHead, + writeJsonl, + fileExists, + dirExists, + removeDirRecursive, +} from '../fs.js'; +import { cleanTitleText, fallbackTitle, isInjectedText } from '../title.js'; + +// --------------------------------------------------------------------------- +// 工具名归一化映射 +// --------------------------------------------------------------------------- + +const WB_TO_IR_TOOL: Record = { + read_file: 'read_file', + write_file: 'write_file', + edit_file: 'edit_file', + bash: 'bash', + grep: 'grep', + glob: 'glob', + task: 'task', + todo_write: 'todo_write', +}; + +const IR_TO_WB_TOOL: Record = Object.fromEntries( + Object.entries(WB_TO_IR_TOOL).map(([k, v]) => [v, k]), +); + +function normalizeToolName(name: string): string { + return WB_TO_IR_TOOL[name] ?? name; +} + +function denormalizeToolName(irName: string): string { + return IR_TO_WB_TOOL[irName] ?? irName; +} + +// --------------------------------------------------------------------------- +// UUID / 时间戳工具 +// --------------------------------------------------------------------------- + +const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function isUuidV4(s: string): boolean { + return UUID_V4_RE.test(s); +} + +function uuidV4(): string { + return crypto.randomUUID(); +} + +function toUnixMs(isoStr?: string): number { + if (!isoStr) return Date.now(); + const d = new Date(isoStr); + return isNaN(d.getTime()) ? Date.now() : d.getTime(); +} + +function fromUnixMs(ms: number): string { + return new Date(ms).toISOString(); +} + +// --------------------------------------------------------------------------- +// meta.json +// --------------------------------------------------------------------------- + +interface WorkBuddyMeta { + cwd?: string; + createdAt?: number; + updatedAt?: number; + sourceConversationId?: string; + isPlayground?: boolean; + migratedFrom?: string; +} + +/** + * 读取与会话 jsonl 同目录的 `.meta.json`。 + * 缺失或损坏时返回空对象(调用方回退到目录名反解)。 + */ +function readMeta(jsonlPath: string): WorkBuddyMeta { + const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json'); + try { + if (!fileExists(metaPath)) return {}; + const parsed = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as Record; + return { + cwd: typeof parsed.cwd === 'string' ? parsed.cwd : undefined, + createdAt: typeof parsed.createdAt === 'number' ? parsed.createdAt : undefined, + updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : undefined, + sourceConversationId: + typeof parsed.sourceConversationId === 'string' ? parsed.sourceConversationId : undefined, + isPlayground: typeof parsed.isPlayground === 'boolean' ? parsed.isPlayground : undefined, + migratedFrom: typeof parsed.migratedFrom === 'string' ? parsed.migratedFrom : undefined, + }; + } catch { + return {}; + } +} + +// --------------------------------------------------------------------------- +// WorkBuddyAdapter +// --------------------------------------------------------------------------- + +export class WorkBuddyAdapter extends AgentAdapter { + readonly platform = 'workbuddy'; + + static isAvailable(): boolean { + return dirExists(getWorkBuddyProjectsDir()); + } + + isReady(): boolean { + return dirExists(getWorkBuddyProjectsDir()); + } + + static getDefaultStoragePath(): string { + return getWorkBuddyProjectsDir(); + } + + private resolveProjectDir(projectPath?: string): string { + const root = getWorkBuddyProjectsDir(); + if (projectPath) { + return path.join(root, encodeCwdGeneric(projectPath)); + } + return root; + } + + private findSessionFile(sessionId: string, projectPath?: string): string | null { + if (projectPath) { + const target = path.join(this.resolveProjectDir(projectPath), `${sessionId}.jsonl`); + return fileExists(target) ? target : null; + } + const root = getWorkBuddyProjectsDir(); + if (!dirExists(root)) return null; + for (const projDir of fs.readdirSync(root)) { + const candidate = path.join(root, projDir, `${sessionId}.jsonl`); + if (fileExists(candidate)) return candidate; + } + return null; + } + + async listConversations(projectPath?: string): Promise { + const metas: SessionMeta[] = []; + const root = getWorkBuddyProjectsDir(); + if (!dirExists(root)) return []; + + const projDirs = projectPath + ? [this.resolveProjectDir(projectPath)] + : fs.readdirSync(root).map((d) => path.join(root, d)); + + for (const projDir of projDirs) { + if (!dirExists(projDir)) continue; + const fallbackCwd = decodeCwdGeneric(path.basename(projDir)); + for (const jsonlFile of fs.readdirSync(projDir).filter((f) => f.endsWith('.jsonl')).sort()) { + const fullPath = path.join(projDir, jsonlFile); + const meta = this.extractMeta(fullPath, fallbackCwd); + if (meta) metas.push(meta); + } + } + return metas; + } + + private extractMeta(jsonlPath: string, fallbackCwd: string): SessionMeta | null { + const sessionId = path.basename(jsonlPath, '.jsonl'); + const metaInfo = readMeta(jsonlPath); + let title = ''; + let createdAt = metaInfo.createdAt !== undefined ? fromUnixMs(metaInfo.createdAt) : undefined; + let updatedAt = metaInfo.updatedAt !== undefined ? fromUnixMs(metaInfo.updatedAt) : undefined; + let messageCount = 0; + let firstUserText = ''; + let aiTitle = ''; + + try { + for (const record of readJsonlHead(jsonlPath, 80)) { + const rtype = record.type as string; + + if (rtype === 'ai-title') { + aiTitle = String(record.aiTitle ?? ''); + continue; + } + + // meta.json 未覆盖时才从行内推断 + const tsRaw = record.timestamp; + if (tsRaw !== undefined) { + const ts = fromUnixMs(Number(tsRaw)); + if (!createdAt) createdAt = ts; + if (!metaInfo.updatedAt) updatedAt = ts; + } + + if (rtype === 'message') { + messageCount++; + const role = record.role as string; + if (role === 'user' && !firstUserText) { + const content = record.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block && typeof block === 'object' && (block as Record).type === 'input_text') { + const text = String((block as Record).text ?? ''); + // 首个文本块常是 system-reminder 等注入,跳过继续找真正的提问 + if (!isInjectedText(text)) { + firstUserText = text; + break; + } + } + } + } + } + } + } + } catch { + return null; + } + + if (!createdAt) createdAt = new Date().toISOString(); + if (!updatedAt) updatedAt = createdAt; + + // aiTitle 是 WorkBuddy 自己起的标题,最可靠;注入文本清洗同 codebuddy 适配器 + title = + (aiTitle && !isInjectedText(aiTitle) && aiTitle.slice(0, 60)) || + cleanTitleText(firstUserText) || + fallbackTitle(sessionId); + + let sizeBytes = 0; + try { + sizeBytes = fs.statSync(jsonlPath).size; + } catch { + // ignore + } + + return { + sessionId, + title, + cwd: metaInfo.cwd ?? fallbackCwd, + platform: this.platform, + createdAt, + updatedAt, + messageCount, + filePath: jsonlPath, + sizeBytes, + }; + } + + async readSession(sessionId: string, projectPath?: string): Promise { + const jsonlPath = this.findSessionFile(sessionId, projectPath); + if (!jsonlPath) { + throw new Error(`WorkBuddy session file not found: session_id=${sessionId}`); + } + + const metaInfo = readMeta(jsonlPath); + const cwd = metaInfo.cwd ?? decodeCwdGeneric(path.basename(path.dirname(jsonlPath))); + const records = [...readJsonl(jsonlPath)]; + + let title = ''; + let createdAt = metaInfo.createdAt !== undefined ? fromUnixMs(metaInfo.createdAt) : undefined; + let updatedAt = metaInfo.updatedAt !== undefined ? fromUnixMs(metaInfo.updatedAt) : undefined; + const sessionMetadata: Record = {}; + const messages: Message[] = []; + + if (metaInfo.sourceConversationId) sessionMetadata.sourceConversationId = metaInfo.sourceConversationId; + if (metaInfo.isPlayground !== undefined) sessionMetadata.isPlayground = metaInfo.isPlayground; + if (metaInfo.migratedFrom) sessionMetadata.originator = metaInfo.migratedFrom; + + // 第一遍:提取 title/时间戳 + for (const rec of records) { + const rtype = rec.type as string; + + if (rtype === 'ai-title') { + // 与 codebuddy 适配器一致:注入块原文偶尔会被存成 ai-title,照收会污染迁移链路 + const t = String(rec.aiTitle ?? ''); + if (t && !isInjectedText(t)) title = t.slice(0, 100); + continue; + } + + const tsRaw = rec.timestamp; + if (tsRaw !== undefined) { + const ts = fromUnixMs(Number(tsRaw)); + if (!createdAt) createdAt = ts; + if (!metaInfo.updatedAt) updatedAt = ts; + } + } + + // 第二遍:构建消息 + for (const rec of records) { + const rtype = rec.type as string; + + if (rtype === 'message') { + const role = rec.role as string; + if (role !== 'user' && role !== 'assistant') continue; + + const content = this.parseMessageContent(rec); + const msg: Message = { + role: role as 'user' | 'assistant', + content, + messageId: rec.id as string | undefined, + parentId: rec.parentId as string | undefined, + timestamp: rec.timestamp !== undefined ? fromUnixMs(Number(rec.timestamp)) : undefined, + }; + + // providerData 完整保留(含 memory references 等 WorkBuddy 扩展字段) + const providerData = rec.providerData as Record | undefined; + if (providerData) { + const md: Record = { ...providerData }; + if (typeof providerData.model === 'string') { + if (!sessionMetadata.model) sessionMetadata.model = providerData.model; + } + msg.metadata = md; + } + + messages.push(msg); + } else if (rtype === 'function_call') { + const name = String(rec.name ?? ''); + const irName = normalizeToolName(name); + const callId = String(rec.callId ?? rec.id ?? ''); + const providerData = rec.providerData as Record | undefined; + let argsRaw = providerData?.arguments ?? rec.arguments; + let arguments_: Record; + try { + arguments_ = typeof argsRaw === 'string' ? JSON.parse(argsRaw) : (argsRaw as Record) ?? {}; + } catch { + arguments_ = { _raw: String(argsRaw) }; + } + + const block: ToolCallBlock = { type: 'tool_call', toolName: irName, callId, arguments: arguments_ }; + + if (messages.length > 0 && messages[messages.length - 1].role === 'assistant') { + messages[messages.length - 1].content.push(block); + } else { + messages.push({ role: 'assistant', content: [block] }); + } + } else if (rtype === 'function_call_result') { + const callId = String(rec.callId ?? ''); + const output = rec.output as Record | undefined; + let contentStr = ''; + if (output) { + contentStr = String(output.text ?? ''); + } + const status = String(rec.status ?? 'completed'); + const isError = status === 'failed' || status === 'error'; + const block: ToolResultBlock = { type: 'tool_result', callId, content: contentStr, isError }; + + if (messages.length > 0 && messages[messages.length - 1].role === 'user') { + messages[messages.length - 1].content.push(block); + } else { + messages.push({ role: 'user', content: [block] }); + } + } else if (rtype === 'reasoning') { + const rawContent = rec.rawContent as Array> | undefined; + let text = ''; + if (Array.isArray(rawContent)) { + for (const part of rawContent) { + if (part.type === 'reasoning_text') { + text += String(part.text ?? ''); + } + } + } + const block: ThinkingBlock = { type: 'thinking', text }; + + if (messages.length > 0 && messages[messages.length - 1].role === 'assistant') { + messages[messages.length - 1].content.push(block); + } else { + messages.push({ role: 'assistant', content: [block] }); + } + } + } + + if (!title) { + for (const msg of messages) { + if (msg.role === 'user') { + for (const block of msg.content) { + if (block.type === 'text' && block.text) { + if (isInjectedText(block.text)) continue; // 注入块不当标题 + title = cleanTitleText(block.text); + if (title) break; + } + } + if (title) break; + } + } + } + if (!title) title = `Session ${sessionId.slice(0, 8)}`; + + if (!createdAt) createdAt = new Date().toISOString(); + if (!updatedAt) updatedAt = createdAt; + + return { + sessionId, + title, + cwd, + platform: this.platform, + createdAt, + updatedAt, + messages, + metadata: sessionMetadata, + }; + } + + private parseMessageContent(rec: Record): ContentBlock[] { + const blocks: ContentBlock[] = []; + const contentArr = rec.content; + + if (typeof contentArr === 'string') { + blocks.push({ type: 'text', text: contentArr }); + return blocks; + } + + if (!Array.isArray(contentArr)) return blocks; + + for (const item of contentArr) { + if (!item || typeof item !== 'object') continue; + const it = item as Record; + const itemType = it.type as string; + const text = String(it.text ?? ''); + + if (itemType === 'input_text' || itemType === 'output_text') { + blocks.push({ type: 'text', text }); + } + } + return blocks; + } + + async writeSession(session: Session, projectPath?: string): Promise { + let sessionId = session.sessionId; + if (!isUuidV4(sessionId)) { + sessionId = uuidV4(); + } + + const cwd = projectPath ?? session.cwd; + const projDir = path.join(getWorkBuddyProjectsDir(), encodeCwdGeneric(cwd)); + const jsonlPath = path.join(projDir, `${sessionId}.jsonl`); + + const records: Record[] = []; + + // 1. ai-title 行 + records.push({ + timestamp: toUnixMs(session.createdAt), + type: 'ai-title', + aiTitle: session.title, + sessionId, + cwd, + }); + + let parentId: string | null = null; + + for (const msg of session.messages) { + const msgId = msg.messageId ?? uuidV4(); + + const thinkingBlocks = msg.content.filter((b) => b.type === 'thinking'); + const otherBlocks = msg.content.filter((b) => b.type !== 'thinking'); + + // reasoning 行 + for (const tb of thinkingBlocks) { + const reasoningId = uuidV4(); + records.push({ + id: reasoningId, + parentId, + timestamp: toUnixMs(msg.timestamp), + type: 'reasoning', + providerData: msg.metadata?.model ? { model: msg.metadata.model } : {}, + content: [], + rawContent: [{ type: 'reasoning_text', text: (tb as ThinkingBlock).text }], + sessionId, + cwd, + }); + parentId = reasoningId; + } + + // message 行 + if (otherBlocks.length > 0) { + const wbContent: Record[] = []; + let hasText = false; + for (const block of otherBlocks) { + if (block.type === 'text') { + wbContent.push({ + type: msg.role === 'user' ? 'input_text' : 'output_text', + text: block.text, + }); + hasText = true; + } + } + + if (hasText) { + records.push({ + id: msgId, + parentId, + timestamp: toUnixMs(msg.timestamp), + type: 'message', + role: msg.role, + status: 'completed', + content: wbContent, + providerData: msg.metadata?.model ? { model: msg.metadata.model } : {}, + sessionId, + cwd, + }); + parentId = msgId; + } + } + + // function_call 行 + for (const block of otherBlocks) { + if (block.type === 'tool_call') { + const fcId = block.callId || uuidV4(); + records.push({ + id: fcId, + parentId, + timestamp: toUnixMs(msg.timestamp), + type: 'function_call', + name: denormalizeToolName(block.toolName), + callId: block.callId, + providerData: { + arguments: block.arguments, + ...(msg.metadata?.model ? { model: msg.metadata.model } : {}), + }, + sessionId, + cwd, + }); + parentId = fcId; + } + } + + // function_call_result 行 + for (const block of otherBlocks) { + if (block.type === 'tool_result') { + const fcrId = uuidV4(); + records.push({ + id: fcrId, + parentId, + timestamp: toUnixMs(msg.timestamp), + type: 'function_call_result', + name: 'Agent', + callId: block.callId, + status: block.isError ? 'failed' : 'completed', + output: { type: 'text', text: block.content }, + sessionId, + cwd, + }); + parentId = fcrId; + } + } + } + + writeJsonl(jsonlPath, records); + + // 写入 meta.json —— 保留真实 cwd,使后续读取无需依赖有损的目录名反解 + try { + const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json'); + fs.writeFileSync( + metaPath, + JSON.stringify( + { + createdAt: toUnixMs(session.createdAt), + updatedAt: toUnixMs(session.updatedAt), + cwd, + sourceConversationId: sessionId, + }, + null, + 2, + ), + 'utf-8', + ); + } catch { + // meta.json 写入失败不影响主流程 + } + + return sessionId; + } + + async deleteSession(sessionId: string, projectPath?: string): Promise { + const jsonlPath = this.findSessionFile(sessionId, projectPath); + if (!jsonlPath) return; + + try { + fs.unlinkSync(jsonlPath); + } catch { + // ignore + } + + const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json'); + if (fileExists(metaPath)) { + try { + fs.unlinkSync(metaPath); + } catch { + // ignore + } + } + + // 删除同名子目录(subagents 等) + const subdir = jsonlPath.replace(/\.jsonl$/, ''); + if (dirExists(subdir)) { + removeDirRecursive(subdir); + } + } +} diff --git a/src/session-flow/codebuddy.ts b/src/session-flow/codebuddy.ts new file mode 100644 index 00000000..8799b7bb --- /dev/null +++ b/src/session-flow/codebuddy.ts @@ -0,0 +1,4 @@ +// NOTE: This file is an accidental leftover and is intentionally empty. +// The real CodeBuddy CLI adapter lives at src/session-flow/adapters/codebuddy.ts. +// TODO(m2-builder): delete this file before merging. +export {}; diff --git a/src/session-flow/fs.ts b/src/session-flow/fs.ts new file mode 100644 index 00000000..b7f9036d --- /dev/null +++ b/src/session-flow/fs.ts @@ -0,0 +1,234 @@ +/** + * fs.ts — 路径解析与 JSONL 读写工具。 + * + * 提供各平台会话存储路径的解析,以及 cwd 编码/解码(不同平台对工作目录 + * 的编码规则不同),还有流式 JSONL 读写。 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { homedir } from 'node:os'; + +// --------------------------------------------------------------------------- +// 平台默认存储路径 +// --------------------------------------------------------------------------- + +export function getClaudeCodeProjectsDir(): string { + return path.join(homedir(), '.claude', 'projects'); +} + +/** TeamAI 变体: claude-internal */ +export function getClaudeInternalProjectsDir(): string { + return path.join(homedir(), '.claude-internal', 'projects'); +} + +/** TeamAI 变体: tclaude */ +export function getTClaudeProjectsDir(): string { + return path.join(homedir(), '.tclaude', 'projects'); +} + +export function getCodexSessionsDir(): string { + return path.join(homedir(), '.codex', 'sessions'); +} + +/** TeamAI 变体: codex-internal */ +export function getCodexInternalSessionsDir(): string { + return path.join(homedir(), '.codex-internal', 'sessions'); +} + +/** TeamAI 变体: tcodex */ +export function getTCodexSessionsDir(): string { + return path.join(homedir(), '.tcodex', 'sessions'); +} + +export function getCodeBuddyProjectsDir(): string { + return path.join(homedir(), '.codebuddy', 'projects'); +} + +/** + * WorkBuddy 会话目录。与 CodeBuddy 同构(/.jsonl), + * 但额外提供 .meta.json,其中带真实 cwd —— 可绕开目录名反解的有损问题。 + */ +export function getWorkBuddyProjectsDir(): string { + return path.join(homedir(), '.workbuddy', 'projects'); +} + +export function getCursorProjectsDir(): string { + return path.join(homedir(), '.cursor', 'projects'); +} + +// --------------------------------------------------------------------------- +// cwd 编码/解码 +// --------------------------------------------------------------------------- + +/** + * 把 cwd 解析为真实路径后再编码。 + * + * macOS 上 `/tmp` 是 `/private/tmp` 的符号链接,同一个目录有两种拼写。 + * AI 工具以 `process.cwd()` 落盘——那是**解析后**的路径——于是用未解析拼写 + * 写入(如 `--target-cwd /tmp/x`)会落进一个谁也读不回的目录: + * 写入成功,列出却为空。与 ide-history.hashWorkspace 的处理保持一致。 + * 路径不存在(待创建场景)时回退原路径。 + */ +export function resolveRealCwd(cwd: string): string { + const resolved = path.resolve(cwd); + try { + return fs.realpathSync(resolved); + } catch { + return resolved; + } +} + +/** + * Claude Code 的 cwd 编码: 所有非字母数字字符 → `-`,有前导 `-`。 + * 例: `/home/user/project` → `-home-user-project` + * `/Users/foo/my project` → `-Users-foo-my-project` + */ +export function encodeCwdClaude(cwd: string): string { + return resolveRealCwd(cwd).replace(/[^a-zA-Z0-9]/g, '-'); +} + +/** + * CodeBuddy / Cursor 的 cwd 编码: 所有非字母数字字符 → `-`,无前导 `-`。 + * 例: `/home/user/project` → `home-user-project` + * `/Users/foo/my project` → `Users-foo-my-project` + */ +export function encodeCwdGeneric(cwd: string): string { + return resolveRealCwd(cwd).replace(/[^a-zA-Z0-9]/g, '-').replace(/^-+/, ''); +} + +/** + * CodeBuddy CLI 的 cwd → 目录名编码:只把路径分隔符换成 `-`。 + * + * 不能用上面的通用版本——它把所有非字母数字都换成 `-`,而 CodeBuddy 自己 + * **保留空格**,实测 `.../Desktop/Code/teamai cli` 落盘为 + * `Users-caiwenzhe-Desktop-Code-teamai cli`。通用版会算成 `...-teamai-cli`, + * 于是这类工作区永远匹配不上:列出为空、读取报「文件未找到」, + * 而带空格的项目目录很常见。 + */ +export function encodeCwdCodeBuddy(cwd: string): string { + return resolveRealCwd(cwd) + .replace(/^([a-zA-Z]:)?[\\/]+/, '') // 去掉盘符与根分隔符 + .replace(/[\\/]/g, '-'); +} + +/** + * Claude Code 的 cwd 解码: 无法精确还原(`-` 可能来自 `/`、空格等), + * 但目录名本身不需要解码为可用路径——仅用于显示。 + * 这里返回原始 encoded 字符串作为显示用 cwd。 + */ +export function decodeCwdClaude(encoded: string): string { + // 无法精确反推,返回 encoded 本身(调用方应从 session_meta 等获取真实 cwd) + return encoded; +} + +/** + * CodeBuddy / Cursor 的 cwd 解码: 同上,无法精确还原。 + */ +export function decodeCwdGeneric(encoded: string): string { + return encoded; +} + +// --------------------------------------------------------------------------- +// JSONL 流式读写 +// --------------------------------------------------------------------------- + +/** + * 流式读取 JSONL 文件,逐行返回解析后的对象。 + * 空行自动跳过;解析失败的行抛出 SyntaxError。 + */ +export function* readJsonl(filePath: string): Generator> { + const content = fs.readFileSync(filePath, 'utf-8'); + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + yield JSON.parse(trimmed) as Record; + } +} + +/** + * 逐行读取 JSONL 文件的前 N 行(用于提取元信息,避免加载大文件)。 + */ +export function* readJsonlHead(filePath: string, maxLines: number): Generator> { + const content = fs.readFileSync(filePath, 'utf-8'); + let count = 0; + for (const line of content.split('\n')) { + if (count >= maxLines) break; + const trimmed = line.trim(); + if (!trimmed) continue; + count++; + yield JSON.parse(trimmed) as Record; + } +} + +/** + * 流式写入 JSONL 文件,每个对象写一行。 + * 自动创建父目录。 + */ +export function writeJsonl(filePath: string, records: Iterable>): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const lines: string[] = []; + for (const record of records) { + lines.push(JSON.stringify(record)); + } + fs.writeFileSync(filePath, lines.join('\n') + '\n', 'utf-8'); +} + +// --------------------------------------------------------------------------- +// 文件系统辅助 +// --------------------------------------------------------------------------- + +export function fileExists(p: string): boolean { + try { + return fs.statSync(p).isFile(); + } catch { + return false; + } +} + +export function dirExists(p: string): boolean { + try { + return fs.statSync(p).isDirectory(); + } catch { + return false; + } +} + +/** + * 递归扫描目录下所有匹配的文件,按修改时间降序排列。 + */ +export function scanFiles(rootDir: string, pattern: RegExp): string[] { + if (!dirExists(rootDir)) return []; + const results: string[] = []; + const walk = (dir: string) => { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.isFile() && pattern.test(entry.name)) { + results.push(fullPath); + } + } + }; + walk(rootDir); + results.sort((a, b) => { + try { + return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; + } catch { + return 0; + } + }); + return results; +} + +/** + * 递归删除目录(用于 delete_session 清理子目录)。 + */ +export function removeDirRecursive(dirPath: string): void { + try { + fs.rmSync(dirPath, { recursive: true, force: true }); + } catch { + // ignore + } +} diff --git a/src/session-flow/ide-history.ts b/src/session-flow/ide-history.ts new file mode 100644 index 00000000..7c2c7477 --- /dev/null +++ b/src/session-flow/ide-history.ts @@ -0,0 +1,852 @@ +/** + * ide-history.ts — CodeBuddy IDE 侧边栏「历史对话」同步。 + * + * CodeBuddy IDE(图形化)与 CodeBuddy CLI 的会话存储是**互相独立**的两套: + * - CLI: ~/.codebuddy/projects//.jsonl + * - IDE: /CodeBuddyExtension/Data//CodeBuddyIDE//history// + * + * 只写 CLI 路径的话,用户在 IDE 侧边栏「历史对话」里看不到迁移过来的会话。 + * 本模块把迁移结果**同步**进 IDE 的 history 目录,使侧边栏可见且可点开继续聊。 + * + * IDE 路径要点: + * - workspace 哈希 = md5(cwd) 的 32 位小写 hex(cwd 用 path.resolve 规范化、去尾部斜杠) + * - conversation id / messages 目录名 = 32 位 hex(无横线),故 UUID 需去横线 + * - index.json: { conversations: [{id,type,name,createdAt,lastMessageAt,modelMap?}], current } + * - messages/.json: { role, message(stringified JSON), id, extra(stringified JSON), createdAt } + * + * IDE content block 类型:text / reasoning / tool-call / tool-result + * - tool-result 在 IDE 中是**独立 role:"tool" 消息**,不合并进 user/assistant + * + * 设计原则:本模块是**增强功能**,任何失败都静默降级,绝不阻断主迁移流程。 + */ + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { Session } from './ir.js'; + +// --------------------------------------------------------------------------- +// 类型 +// --------------------------------------------------------------------------- + +/** IDE 侧单条消息文件的结构(message / extra 为 stringified JSON)。 */ +interface IdeMessageFile { + role: 'user' | 'assistant' | 'tool'; + message: string; + id: string; + extra: string; + createdAt: string; +} + +/** IDE index.json 中的 conversation 条目。 */ +export interface IdeConversation { + id: string; + type: 'craft' | 'plan' | 'team-member'; + name: string; + createdAt: string; + lastMessageAt: string; + modelMap?: Record; +} + +export interface IdeSyncResult { + /** 成功同步的 IDE 实例数 */ + synced: number; + /** 写入的消息条数(按 IDE 计,tool-result 会拆成独立消息) */ + messageCount: number; + /** + * 实际写入的 conversation id(32 位 hex)。 + * 非 UUID 的 sessionId 会被哈希,调用方不能拿源 sessionId 直接当 IDE id。 + */ + convId?: string; + /** 未同步时的原因(synced===0 时有值) */ + skipped?: string; +} + +// --------------------------------------------------------------------------- +// 工具 +// --------------------------------------------------------------------------- + +function uuidV4(): string { + return crypto.randomUUID(); +} + +function hex32(): string { + return uuidV4().replace(/-/g, ''); +} + +/** + * IDE 的 workspace 哈希:md5(cwd) 的 32 位小写 hex。 + * cwd 先 resolve 再去掉尾部斜杠,保证与 IDE 内部算法一致。 + * + * 必须是**真实绝对路径**。session.cwd 可能是源平台存的 encoded 形式 + * (如 `-Users-foo-project`),此时无法可靠反推真实路径(空格会丢失), + * 返回 null 让调用方跳过——宁可不同步,也不能用错误 hash 写进无关目录。 + */ +export function hashWorkspace(cwd: string): string | null { + // POSIX 绝对路径,或 Windows 盘符绝对路径(C:\ 或 C:/) + if (!cwd || !(cwd.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(cwd))) return null; + let normalized = path.resolve(cwd).replace(/\/+$/, ''); + // macOS 上 /tmp 是 /private/tmp 的符号链接,VSCode 传给 IDE 的是解析后的真实路径。 + // 不做 realpath 的话,「用 /tmp 写入、在 /private/tmp 列出」会算出两个不同的 + // 工作区 hash:写入成功却列不出来,用户以为迁移丢了。 + try { + normalized = fs.realpathSync(normalized).replace(/\/+$/, ''); + } catch { + // 目录不存在(createIfMissing 场景)时保留原路径 + } + return crypto.createHash('md5').update(normalized).digest('hex'); +} + +/** + * conversation id:IDE 用 32 位 hex(无横线)。 + * + * 三种输入都要能映射回**同一个** id,否则读与删会各算各的: + * - 32 位 hex(IDE 原生 / codebuddy-ide 读出来的 id)→ 原样复用 + * - 带横线的 UUID(其他平台的 sessionId)→ 去横线 + * - 其余形态 → md5 兜底 + * + * 漏掉第一种会让 IDE 侧会话 id 被二次哈希:写入时生成一个新 id, + * 回滚时再哈希一次又不同,目录删不掉 —— 报告成功却留下永久残留。 + */ +function toIdeConvId(sessionId: string): string { + if (/^[0-9a-f]{32}$/i.test(sessionId)) return sessionId.toLowerCase(); + const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (uuidRe.test(sessionId)) return sessionId.replace(/-/g, '').toLowerCase(); + return crypto.createHash('md5').update(sessionId).digest('hex'); +} + +/** + * CodeBuddyExtension 的用户数据根目录(跨平台)。 + */ +function getUserDataBase(): string | null { + const home = os.homedir(); + switch (process.platform) { + case 'darwin': + return path.join(home, 'Library', 'Application Support', 'CodeBuddyExtension', 'Data'); + case 'win32': + return path.join( + process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), + 'CodeBuddyExtension', + 'Data', + ); + default: + return path.join( + process.env.XDG_CONFIG_HOME ?? path.join(home, '.config'), + 'CodeBuddyExtension', + 'Data', + ); + } +} + +/** + * 列出所有 IDE 实例的 history 根目录。 + * + * 磁盘上有两种布局,都真实存在: + * - //CodeBuddyIDE//history 常规实例(instId 通常与 extId 同名) + * - //CodeBuddyIDE/history default 实例,少一层 instId + * + * 只认第一种会让 default 实例下的会话整体消失(既读不到也写不进), + * 故两种都收集。 + */ +export function listIdeHistoryRoots(): string[] { + const base = getUserDataBase(); + if (!base || !fs.existsSync(base)) return []; + + const out: string[] = []; + try { + for (const extId of fs.readdirSync(base)) { + const ideRoot = path.join(base, extId, 'CodeBuddyIDE'); + if (!fs.existsSync(ideRoot)) continue; + + const direct = path.join(ideRoot, 'history'); + if (fs.existsSync(direct)) out.push(direct); + + for (const instId of fs.readdirSync(ideRoot)) { + if (instId === 'history') continue; + const historyRoot = path.join(ideRoot, instId, 'history'); + if (fs.existsSync(historyRoot)) out.push(historyRoot); + } + } + } catch { + return out; + } + + return out; +} + +/** + * 找出所有 IDE 实例下该 cwd 对应的 history 目录。 + * + * 通常只有 1 个(单用户单实例)。多实例时全部返回,逐个写入。 + * createIfMissing=true 时,若该项目的 history 目录尚不存在(IDE 没打开过这个项目), + * 仍返回待创建路径,使其下次打开即可见。 + */ +export function findIdeHistoryDirs(cwd: string, createIfMissing = false): string[] { + const hash = hashWorkspace(cwd); + if (!hash) return []; // cwd 不是真实绝对路径,放弃同步 + + const out: string[] = []; + for (const historyRoot of listIdeHistoryRoots()) { + const target = path.join(historyRoot, hash); + if (fs.existsSync(target) || createIfMissing) out.push(target); + } + return out; +} + +// --------------------------------------------------------------------------- +// IR → IDE 转换 +// --------------------------------------------------------------------------- + +/** + * 挑一个模型名用于 modelMap / extra。 + * 优先 session.metadata.model,其次首条带 model 的消息。 + */ +function pickModel(session: Session): string | undefined { + if (session.metadata?.model) return String(session.metadata.model); + for (const m of session.messages) { + if (m.metadata?.model) return String(m.metadata.model); + } + return undefined; +} + +/** + * IR Session → IDE 消息列表。 + * + * 一条 IR message 可能展开为多条 IDE 消息: + * - thinking/text/tool_call 合成一条(role 保持 user/assistant) + * - tool_result 拆成独立的 role:"tool" 消息 + */ +/** + * 确定性 32 位 hex id(内容派生)。 + * + * 消息文件名就是消息 id,因此 id 必须对**相同输入稳定**——用 randomUUID + * 会导致每次迁移都生成新文件名,旧文件既不被覆盖也不被清理, + * messages/ 目录每次迁移泄漏一批孤儿。 + */ +function stableId(parts: string[]): string { + return crypto.createHash('sha256').update(parts.join('\u0000')).digest('hex').slice(0, 32); +} + +function toEpochMs(iso: string | undefined): number | undefined { + if (!iso) return undefined; + const t = Date.parse(iso); + return Number.isFinite(t) ? t : undefined; +} + +/** + * 解析每条消息的 createdAt。 + * + * 源平台常常不记录 per-message 时间戳,若一律回落到 session.updatedAt, + * 会让整个会话的消息共用一个时间(实测 484 条里 468 条完全相同), + * IDE 的时间线分组 / 相对时间会明显异常。 + * 这里在已知时间戳之间线性插值,并强制严格递增。 + */ +function resolveTimestamps(session: Session): string[] { + const n = session.messages.length; + const base = toEpochMs(session.createdAt) ?? Date.now(); + const tailRaw = toEpochMs(session.updatedAt); + const tail = tailRaw !== undefined && tailRaw >= base ? tailRaw : base; + + if (n === 0) return []; + + // 虚拟边界:index -1 = createdAt,index n = updatedAt + const known: Array<{ i: number; t: number }> = [ + { i: -1, t: base }, + { i: n, t: tail }, + ]; + session.messages.forEach((m, i) => { + const t = toEpochMs(m.timestamp); + if (t !== undefined) known.push({ i, t }); + }); + known.sort((a, b) => a.i - b.i); + + const ts = new Array(n); + for (const p of known) { + if (p.i >= 0 && p.i < n) ts[p.i] = p.t; + } + for (let k = 0; k < known.length - 1; k++) { + const a = known[k]; + const b = known[k + 1]; + for (let i = a.i + 1; i <= b.i - 1; i++) { + const ratio = (i - a.i) / (b.i - a.i); + ts[i] = Math.round(a.t + (b.t - a.t) * ratio); + } + } + + // 强制严格递增(至少 +1ms):插值在塌缩区间内仍会产生大量相同值 + for (let i = 1; i < n; i++) { + if (!(ts[i] > ts[i - 1])) ts[i] = ts[i - 1] + 1; + } + + return ts.map((t) => new Date(Number.isFinite(t) ? t : base).toISOString()); +} + +function irToIdeMessages(session: Session): IdeMessageFile[] { + const out: IdeMessageFile[] = []; + const model = pickModel(session); + const timestamps = resolveTimestamps(session); + + // callId → toolName 映射(tool-result 需要回填 toolName) + const toolNameByCallId = new Map(); + for (const msg of session.messages) { + for (const b of msg.content) { + if (b.type === 'tool_call' && b.callId) toolNameByCallId.set(b.callId, b.toolName); + } + } + + session.messages.forEach((msg, msgIdx) => { + const ts = timestamps[msgIdx]; + const msgModel = msg.metadata?.model ?? model; + const extra = JSON.stringify({ + requestId: stableId([session.sessionId, String(msgIdx), 'request']), + modelId: msgModel ? `custom-local:${msgModel}` : 'custom-local:unknown', + modelName: msgModel ?? 'unknown', + isHelperMessage: false, + }); + + // 1) thinking + text + tool_call → 一条 + const content: Record[] = []; + for (const b of msg.content) { + if (b.type === 'thinking') { + content.push({ type: 'reasoning', text: b.text }); + } else if (b.type === 'text') { + content.push({ type: 'text', text: b.text }); + } else if (b.type === 'tool_call') { + content.push({ + type: 'tool-call', + toolCallId: + b.callId || + stableId([ + session.sessionId, + String(msgIdx), + 'tool-call', + b.toolName, + JSON.stringify(b.arguments ?? {}), + ]), + toolName: b.toolName, + args: b.arguments ?? {}, + }); + } + } + + if (content.length > 0) { + out.push({ + role: msg.role, + message: JSON.stringify({ role: msg.role, content }), + id: msg.messageId ?? stableId([session.sessionId, String(msgIdx), 'message']), + extra, + createdAt: ts, + }); + } + + // 2) tool_result → 独立 role:"tool" 消息 + for (const b of msg.content) { + if (b.type !== 'tool_result') continue; + out.push({ + role: 'tool', + message: JSON.stringify({ + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: b.callId, + toolName: toolNameByCallId.get(b.callId) ?? 'Agent', + // 元素级 isError:IDE 靠它把失败的 tool 渲染成错误态。 + // 缺失会被当成成功(falsy),失败的调用看起来像正常返回。 + isError: Boolean(b.isError), + result: { + status: b.isError ? 'failed' : 'success', + success: !b.isError, + result: { type: 'text_result', content: b.content }, + }, + }, + ], + }), + id: stableId([ + session.sessionId, + String(msgIdx), + 'tool-result', + b.callId ?? '', + String(b.content), + ]), + extra, + createdAt: ts, + }); + } + }); + + return out; +} + +// --------------------------------------------------------------------------- +// index.json 读写 +// --------------------------------------------------------------------------- + +interface IdeIndex { + conversations?: IdeConversation[]; + current?: string; + [key: string]: unknown; +} + +function readIndex(historyDir: string): IdeIndex { + const idxPath = path.join(historyDir, 'index.json'); + if (!fs.existsSync(idxPath)) return { conversations: [] }; + try { + const data = JSON.parse(fs.readFileSync(idxPath, 'utf-8')) as IdeIndex; + if (!Array.isArray(data.conversations)) data.conversations = []; + return data; + } catch { + // index.json 损坏时,尝试用备份恢复 + const bakPath = path.join(historyDir, '.index_bak.json'); + try { + const data = JSON.parse(fs.readFileSync(bakPath, 'utf-8')) as IdeIndex; + if (!Array.isArray(data.conversations)) data.conversations = []; + return data; + } catch { + return { conversations: [] }; + } + } +} + +function writeIndex(historyDir: string, data: IdeIndex): void { + const idxPath = path.join(historyDir, 'index.json'); + // 写前备份,与 IDE 自身的 .index_bak.json 约定保持一致 + if (fs.existsSync(idxPath)) { + try { + fs.copyFileSync(idxPath, path.join(historyDir, '.index_bak.json')); + } catch { + // 备份失败不阻断写入 + } + } + fs.writeFileSync(idxPath, JSON.stringify(data, null, 2), 'utf-8'); +} + +function upsertConversation(historyDir: string, conv: IdeConversation): void { + const data = readIndex(historyDir); + const convs = data.conversations as IdeConversation[]; + const i = convs.findIndex((c) => c.id === conv.id); + if (i >= 0) convs[i] = conv; + else convs.push(conv); + data.conversations = convs; + writeIndex(historyDir, data); +} + +function removeConversation(historyDir: string, convId: string): void { + const data = readIndex(historyDir); + const convs = (data.conversations as IdeConversation[]).filter((c) => c.id !== convId); + if (convs.length === (data.conversations as IdeConversation[]).length) return; // 无变化 + data.conversations = convs; + if (data.current === convId) data.current = convs[convs.length - 1]?.id; + writeIndex(historyDir, data); +} + +function removeDirRecursive(dir: string): void { + fs.rmSync(dir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// 对外 API +// --------------------------------------------------------------------------- + +/** + * 把会话同步进 CodeBuddy IDE 的 history(侧边栏「历史对话」可见)。 + * 失败静默降级,不抛异常。 + */ +/** + * 源平台注入的系统前缀。首条「用户消息」常常是这类包装文本, + * 直接当标题会把提示词原文泄漏到 IDE 侧边栏历史列表里。 + */ +const SYSTEM_INJECTED_TITLE = /^\s*<(local-command-caveat|system-reminder|system|command-name|command-message|command-args|timestamp)\b/i; + +/** + * 会话标题:清洗系统注入文本,拿不到有效标题时退回首条真实用户文本。 + */ +function cleanTitle(session: Session, convId: string): string { + const raw = (session.title ?? '').replace(/\s+/g, ' ').trim(); + if (raw && !SYSTEM_INJECTED_TITLE.test(raw)) return raw.slice(0, 100); + + for (const m of session.messages) { + if (m.role !== 'user') continue; + for (const b of m.content) { + if (b.type !== 'text') continue; + const t = b.text.replace(/\s+/g, ' ').trim(); + if (t && !SYSTEM_INJECTED_TITLE.test(t)) return t.slice(0, 100); + } + } + return `Session ${convId.slice(0, 8)}`; +} + +interface IdeRequest { + id: string; + type: 'craft'; + /** 该 turn 包含的消息 id(含 user 及其后的 assistant/tool) */ + messages: string[]; + state: string; + /** epoch 毫秒,与 IDE 原生一致(number,不是 ISO 串) */ + startedAt: number; + usage?: Record; +} + +/** + * 按 user turn 切分出 IDE 的 requests 数组。 + * + * 为什么必须写:消费方 `dashboard-collector` 用 + * `if (!Array.isArray(data.requests)) return null` 做守卫,缺了这个数组会让 + * prompts(user turn 数)等统计**整体**静默归零,而不只是 token 归零。 + * + * 为什么不给 usage:IR 不携带 token 用量,凭空写 0 会把「未知」伪装成「实测为 0」。 + * 消费方对缺失 usage 直接跳过累加,因此不写是安全且诚实的——token 仍为 0, + * 但 prompts 等其余统计能恢复正常。 + */ +function buildIdeRequests(session: Session, messages: IdeMessageFile[]): IdeRequest[] { + const requests: IdeRequest[] = []; + let cur: IdeRequest | null = null; + + const openRequest = (startedAtIso: string): IdeRequest => ({ + id: stableId([session.sessionId, 'request', String(requests.length)]), + type: 'craft', + messages: [], + state: 'complete', + startedAt: toEpochMs(startedAtIso) ?? toEpochMs(session.createdAt) ?? Date.now(), + }); + + for (const m of messages) { + if (m.role === 'user') { + if (cur) requests.push(cur); + cur = openRequest(m.createdAt); + } else if (!cur) { + // 首条不是 user(少见):兜底开一个 turn,避免消息无归属 + cur = openRequest(m.createdAt); + } + cur.messages.push(m.id); + } + if (cur) requests.push(cur); + + return requests; +} + +export function writeIdeSession(session: Session, cwd: string): IdeSyncResult { + const dirs = findIdeHistoryDirs(cwd, true); + if (dirs.length === 0) { + return { + synced: 0, + messageCount: 0, + skipped: 'CodeBuddy IDE storage not found (not installed or initialized); only the CLI path was written', + }; + } + + const convId = toIdeConvId(session.sessionId); + const messages = irToIdeMessages(session); + const model = pickModel(session); + const requests = buildIdeRequests(session, messages); + + const conv: IdeConversation = { + id: convId, + type: 'craft', + name: cleanTitle(session, convId), + createdAt: session.createdAt, + lastMessageAt: session.updatedAt, + ...(model ? { modelMap: { ask: model, craft: model, plan: model } } : {}), + }; + + let synced = 0; + for (const historyDir of dirs) { + try { + const convDir = path.join(historyDir, convId); + const msgDir = path.join(convDir, 'messages'); + + // 幂等:重跑迁移必须先清空 messages/。 + // 消息文件名 = 消息 id,旧版本残留的文件既不会被覆盖也不会被索引, + // 会变成孤儿并让目录随迁移次数无上限增长。 + if (fs.existsSync(msgDir)) { + for (const f of fs.readdirSync(msgDir)) { + if (f.endsWith('.json')) fs.unlinkSync(path.join(msgDir, f)); + } + } + fs.mkdirSync(msgDir, { recursive: true }); + + // 写每条消息,同时收集顺序索引。 + // conversation 级 index.json 是**消息顺序索引**——IDE 靠它决定显示顺序, + // 只写 messages/*.json 而没有它的话,会话打开会是空白。 + const messageIndex: Array> = []; + for (const m of messages) { + fs.writeFileSync(path.join(msgDir, `${m.id}.json`), JSON.stringify(m, null, 2), 'utf-8'); + messageIndex.push({ id: m.id, type: 'text', role: m.role, isComplete: true }); + } + + // 写前备份,与 IDE 自身的 .index_bak.json 约定保持一致 + // (原生会话目录都带它,用于在 index 损坏时自愈) + const convIdxPath = path.join(convDir, 'index.json'); + if (fs.existsSync(convIdxPath)) { + try { + fs.copyFileSync(convIdxPath, path.join(convDir, '.index_bak.json')); + } catch { + // 备份失败不阻断写入 + } + } + + fs.writeFileSync( + convIdxPath, + JSON.stringify({ messages: messageIndex, requests }, null, 2), + 'utf-8', + ); + + upsertConversation(historyDir, conv); + synced++; + } catch { + // 单个 IDE 实例同步失败不影响其他实例,也不影响 CLI 路径的主流程 + } + } + + return { + synced, + messageCount: messages.length, + ...(synced > 0 ? { convId } : { skipped: 'failed to write IDE history directories' }), + }; +} + +/** + * 遍历所有 IDE 实例的 history,按 convId 定位会话目录。 + * + * rollback 时拿到的 cwd 往往是 encoded 的目录名(decodeCwdGeneric 是恒等函数, + * 无法还原真实路径),算不出 workspace hash。而 convId 是全局唯一的, + * 故直接全局搜索,不依赖 cwd。 + */ +export function findIdeConversationDirs(convId: string): Array<{ historyDir: string; convDir: string }> { + const out: Array<{ historyDir: string; convDir: string }> = []; + try { + for (const historyRoot of listIdeHistoryRoots()) { + for (const wsHash of fs.readdirSync(historyRoot)) { + const historyDir = path.join(historyRoot, wsHash); + if (!fs.statSync(historyDir).isDirectory()) continue; + const convDir = path.join(historyDir, convId); + if (fs.existsSync(convDir)) out.push({ historyDir, convDir }); + } + } + } catch { + return out; + } + return out; +} + +/** + * 从 CodeBuddy IDE 的 history 中删除会话(rollback 时清理)。 + * 返回清理的 IDE 实例数。 + * + * cwd 可选:给了就额外兜底清理该项目的 index 孤儿条目,但即使不给也能按 convId 清理。 + */ +export function deleteIdeSession(sessionId: string, cwd?: string): number { + const convId = toIdeConvId(sessionId); + + // 有 cwd 且能解析出 workspace 时,只清理该工作区: + // findIdeConversationDirs 遍历的是所有 workspace hash,而同一个 sessionId 完全可能 + // 同时存在于多个工作区(把同一会话迁移到 A、B 两个项目)。此时按 convId 全局删 + // 会连带删掉另一个工作区的副本——那是不可恢复的数据丢失。 + // + // 但 cwd 本身常常不可靠:deleteSession 里的 cwd 是从 CLI 侧 jsonl 目录名反解出来的, + // 而目录名编码有损(路径分隔符与连字符无法区分),解出来的往往不是真实绝对路径, + // findIdeHistoryDirs 会因此返回空数组。所以限定失败时必须回退到全局搜索—— + // 宁可多删,也绝不能让清理退化成 no-op 留下永久残留。 + // 需要精确定界时用 rollback --cwd <真实路径>。 + const scoped = cwd ? findIdeHistoryDirs(cwd, false) : []; + const targets: Array<{ historyDir: string; convDir: string }> = scoped.length > 0 + ? scoped.map((historyDir) => ({ historyDir, convDir: path.join(historyDir, convId) })) + : findIdeConversationDirs(convId); + + let cleaned = 0; + for (const { historyDir, convDir } of targets) { + // 目录删除与 index 条目清理必须分成两个 try。 + // 合在一个 try 里时,rm 一旦抛异常(大会话容易和运行中的 IDE 进程回写竞争), + // 后面的 removeConversation 就被跳过,index 条目永远清不掉—— + // 结果是目录还在、侧边栏也还显示,用户以为回滚失败且无法补救。 + try { + removeDirRecursive(convDir); + } catch { + // ignore:目录删不掉至少要让它从侧边栏消失 + } + try { + removeConversation(historyDir, convId); + cleaned++; + } catch { + // ignore + } + } + + return cleaned; +} + +// --------------------------------------------------------------------------- +// IDE → IR 读取(供 codebuddy-ide 适配器使用) +// --------------------------------------------------------------------------- + +/** 解析后的单条 IDE 消息(content 为 IDE 原生 block 数组)。 */ +export interface IdeMessageParsed { + id: string; + role: 'user' | 'assistant' | 'tool'; + createdAt?: string; + content: Array>; + model?: string; +} + +/** 一个 IDE 会话条目,带定位信息,适配器可直接命中目录而不必二次搜索。 */ +export interface IdeConversationEntry { + id: string; + name: string; + type: string; + createdAt: string; + lastMessageAt: string; + /** history/ */ + historyDir: string; + /** md5(cwd),不可逆——cwd 只能由调用方传入才能还原 */ + workspaceHash: string; + convDir: string; +} + +/** + * 读取某个工作区(history/)下 index.json 里的会话元数据。 + * + * 注意入参是**工作区目录**而非 history 根——两者的子目录语义完全不同 + * (工作区下是会话目录,history 根下是工作区目录),传错会得到空列表。 + */ +export function readIdeConversations(historyDir: string): IdeConversation[] { + return (readIndex(historyDir).conversations ?? []).filter((c) => Boolean(c?.id)); +} + +/** + * 列出 IDE 侧的全部会话。 + * + * 不传 historyDirs 时遍历所有 IDE 实例;传入则只遍历指定工作区(按 md5(cwd) 定位)。 + */ +export function listIdeConversations(historyDirs?: string[]): IdeConversationEntry[] { + const roots = historyDirs ?? listIdeHistoryRoots(); + const out: IdeConversationEntry[] = []; + + for (const historyRoot of roots) { + let wsHashes: string[] = []; + try { + wsHashes = fs.readdirSync(historyRoot); + } catch { + continue; + } + for (const wsHash of wsHashes) { + const historyDir = path.join(historyRoot, wsHash); + try { + if (!fs.statSync(historyDir).isDirectory()) continue; + } catch { + continue; + } + for (const c of readIndex(historyDir).conversations as IdeConversation[]) { + if (!c?.id) continue; + out.push({ + id: c.id, + name: c.name ?? '', + type: c.type ?? 'craft', + createdAt: c.createdAt ?? '', + lastMessageAt: c.lastMessageAt ?? c.createdAt ?? '', + historyDir, + workspaceHash: wsHash, + convDir: path.join(historyDir, c.id), + }); + } + } + } + + return out; +} + +function parseIdeMessageFile(file: string): IdeMessageParsed | null { + let raw: IdeMessageFile; + try { + raw = JSON.parse(fs.readFileSync(file, 'utf-8')) as IdeMessageFile; + } catch { + return null; + } + + // message 是 stringified JSON;损坏(IDE 正在写的半截文件)时整条跳过, + // 半截 JSON 无法降级为文本——拼回去只会得到无法阅读的乱码。 + let body: { role?: string; content?: unknown }; + try { + body = JSON.parse(raw.message) as { role?: string; content?: unknown }; + } catch { + return null; + } + + const content = Array.isArray(body.content) ? (body.content as Array>) : []; + + let model: string | undefined; + try { + const extra = JSON.parse(raw.extra ?? '{}') as { modelName?: string }; + if (extra?.modelName) model = String(extra.modelName).replace(/^custom-local:/, ''); + } catch { + // extra 缺失不影响消息本身 + } + + const role = (raw.role ?? body.role ?? 'user') as IdeMessageParsed['role']; + + return { + id: raw.id ?? path.basename(file, '.json'), + role, + createdAt: raw.createdAt, + content, + ...(model ? { model } : {}), + }; +} + +/** + * 按会话目录读取消息,保持 IDE 侧显示顺序。 + * + * 顺序来源是 convDir/index.json 的 messages 数组——IDE 靠它决定展示顺序, + * 文件名的字典序与真实顺序无关(消息 id 是内容哈希/UUID)。 + * index 缺失时才退回文件名排序,并在末尾补上 index 未覆盖的孤儿文件。 + */ +export function readIdeConversation(convDir: string, limit?: number): IdeMessageParsed[] { + const msgDir = path.join(convDir, 'messages'); + if (!fs.existsSync(msgDir)) return []; + + let order: string[] = []; + try { + const idx = JSON.parse(fs.readFileSync(path.join(convDir, 'index.json'), 'utf-8')) as { + messages?: Array<{ id?: string }>; + }; + if (Array.isArray(idx.messages)) { + order = idx.messages.map((m) => String(m?.id ?? '')).filter(Boolean); + } + } catch { + order = []; + } + + let files: string[] = []; + try { + files = fs.readdirSync(msgDir).filter((f) => f.endsWith('.json')); + } catch { + return []; + } + + if (order.length === 0) { + files.sort(); + order = files.map((f) => f.replace(/\.json$/, '')); + } + + const messages: IdeMessageParsed[] = []; + const seen = new Set(); + const reached = (): boolean => limit !== undefined && messages.length >= limit; + + for (const id of order) { + if (reached()) break; + if (seen.has(id)) continue; + seen.add(id); + const parsed = parseIdeMessageFile(path.join(msgDir, `${id}.json`)); + if (parsed) messages.push(parsed); + } + for (const f of files) { + if (reached()) break; + const id = f.replace(/\.json$/, ''); + if (seen.has(id)) continue; + seen.add(id); + const parsed = parseIdeMessageFile(path.join(msgDir, f)); + if (parsed) messages.push(parsed); + } + + return messages; +} diff --git a/src/session-flow/index.ts b/src/session-flow/index.ts new file mode 100644 index 00000000..8007c322 --- /dev/null +++ b/src/session-flow/index.ts @@ -0,0 +1,14 @@ +/** + * index.ts — SessionFlow 公共 API 导出。 + * + * SessionFlow 是跨平台 AI Agent 会话迁移引擎,在 Claude Code / Codex / + * CodeBuddy / WorkBuddy / Cursor 等平台之间迁移和同步会话。 + * + * 通过 `teamai session migrate/push/pull/list/resume/search/rollback` 使用。 + */ +export * from './ir.js'; +export * from './migrate.js'; +export * from './search.js'; +export * from './sync.js'; +export * from './fs.js'; +export * from './adapters/index.js'; diff --git a/src/session-flow/ir.ts b/src/session-flow/ir.ts new file mode 100644 index 00000000..b31e91bc --- /dev/null +++ b/src/session-flow/ir.ts @@ -0,0 +1,175 @@ +/** + * Canonical IR — 跨平台归一化的会话表示。 + * + * 所有适配器读取的源平台会话都会被归一化为本模块定义的 IR 结构, + * 所有写入操作也基于 IR 进行,从而实现平台无关的会话迁移。 + * + * 增强点(vs Python 版): + * - ThinkingBlock 保留 signature 字段(同平台迁移可用) + * - MessageMetadata 增加 isMeta/promptId(CC 特有) + * - SessionMetadata 增加 originator/sourcePlatform + */ + +// --------------------------------------------------------------------------- +// 内容块(ContentBlock) +// --------------------------------------------------------------------------- + +export interface TextBlock { + type: 'text'; + text: string; +} + +export interface ThinkingBlock { + type: 'thinking'; + text: string; + signature?: string; // 保留原始签名(同平台迁移时可用) +} + +export interface ToolCallBlock { + type: 'tool_call'; + toolName: string; + callId: string; + arguments: Record; +} + +export interface ToolResultBlock { + type: 'tool_result'; + callId: string; + content: string; + isError: boolean; +} + +export type ContentBlock = TextBlock | ThinkingBlock | ToolCallBlock | ToolResultBlock; + +export function blockToDict(block: ContentBlock): Record { + switch (block.type) { + case 'text': + return { type: 'text', text: block.text }; + case 'thinking': + return { type: 'thinking', text: block.text, ...(block.signature ? { signature: block.signature } : {}) }; + case 'tool_call': + return { type: 'tool_call', toolName: block.toolName, callId: block.callId, arguments: block.arguments }; + case 'tool_result': + return { type: 'tool_result', callId: block.callId, content: block.content, isError: block.isError }; + } +} + +export function blockFromDict(data: Record): ContentBlock { + const t = data.type as string; + switch (t) { + case 'text': + return { type: 'text', text: String(data.text ?? '') }; + case 'thinking': + return { type: 'thinking', text: String(data.text ?? ''), ...(data.signature ? { signature: String(data.signature) } : {}) }; + case 'tool_call': + return { + type: 'tool_call', + toolName: String(data.toolName ?? ''), + callId: String(data.callId ?? ''), + arguments: (data.arguments as Record) ?? {}, + }; + case 'tool_result': + return { + type: 'tool_result', + callId: String(data.callId ?? ''), + content: String(data.content ?? ''), + isError: Boolean(data.isError ?? false), + }; + default: + throw new Error(`Unknown content block type: ${t}`); + } +} + +// --------------------------------------------------------------------------- +// 消息(Message) +// --------------------------------------------------------------------------- + +export interface MessageMetadata { + model?: string; + isMeta?: boolean; + promptId?: string; + [key: string]: unknown; +} + +export interface Message { + role: 'user' | 'assistant'; + content: ContentBlock[]; + timestamp?: string; // ISO8601 + messageId?: string; + parentId?: string; + metadata?: MessageMetadata; +} + +export function messageToDict(msg: Message): Record { + return { + role: msg.role, + content: msg.content.map(blockToDict), + timestamp: msg.timestamp ?? null, + messageId: msg.messageId ?? null, + parentId: msg.parentId ?? null, + metadata: msg.metadata ?? {}, + }; +} + +export function messageFromDict(data: Record): Message { + const rawContent = (data.content as Array>) ?? []; + return { + role: data.role as 'user' | 'assistant', + content: rawContent.map(blockFromDict), + timestamp: (data.timestamp as string) ?? undefined, + messageId: (data.messageId as string) ?? undefined, + parentId: (data.parentId as string) ?? undefined, + metadata: (data.metadata as MessageMetadata) ?? {}, + }; +} + +// --------------------------------------------------------------------------- +// 会话(Session) +// --------------------------------------------------------------------------- + +export interface SessionMetadata { + model?: string; + gitBranch?: string; + version?: string; + originator?: string; + sourcePlatform?: string; + [key: string]: unknown; +} + +export interface Session { + sessionId: string; + title: string; + cwd: string; + platform: string; + createdAt: string; // ISO8601 + updatedAt: string; // ISO8601 + messages: Message[]; + metadata?: SessionMetadata; +} + +export function sessionToDict(session: Session): Record { + return { + sessionId: session.sessionId, + title: session.title, + cwd: session.cwd, + platform: session.platform, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + messages: session.messages.map(messageToDict), + metadata: session.metadata ?? {}, + }; +} + +export function sessionFromDict(data: Record): Session { + const rawMessages = (data.messages as Array>) ?? []; + return { + sessionId: String(data.sessionId ?? ''), + title: String(data.title ?? ''), + cwd: String(data.cwd ?? ''), + platform: String(data.platform ?? ''), + createdAt: String(data.createdAt ?? new Date().toISOString()), + updatedAt: String(data.updatedAt ?? new Date().toISOString()), + messages: rawMessages.map(messageFromDict), + metadata: (data.metadata as SessionMetadata) ?? {}, + }; +} diff --git a/src/session-flow/migrate.ts b/src/session-flow/migrate.ts new file mode 100644 index 00000000..f55e9214 --- /dev/null +++ b/src/session-flow/migrate.ts @@ -0,0 +1,332 @@ +/** + * migrate.ts — 迁移引擎。 + * + * 编排源平台适配器读取 → IR → 目标平台适配器写入的完整流程, + * 同时计算保真度(FidelityReport)、生成迁移预览(MigrationPreview)。 + * + * 增强保真度:ThinkingBlock 迁移到不支持思考块的平台时,降级为 TextBlock + * 而非直接丢弃,用 标签包裹保留内容。 + */ + +import type { Session, ContentBlock } from './ir.js'; +import { getAdapter, listAvailablePlatforms, listInstalledPlatforms } from './adapters/index.js'; + +// --------------------------------------------------------------------------- +// 各平台能力矩阵 +// --------------------------------------------------------------------------- + +export const THINKING_SUPPORT: Record = { + 'claude-code': true, + 'claude-internal': true, + tclaude: true, + codex: true, + 'codex-internal': true, + tcodex: true, + codebuddy: true, + 'codebuddy-ide': true, + cursor: false, // Cursor 无 thinking,降级为 text +}; + +export const NATIVE_TOOLS: Record> = { + 'claude-code': new Set([ + 'read_file', 'write_file', 'edit_file', 'multi_edit', 'bash', 'glob', 'grep', + 'web_search', 'web_fetch', 'task', 'todo_write', 'notebook_edit', 'lsp', + ]), + 'claude-internal': new Set([ + 'read_file', 'write_file', 'edit_file', 'multi_edit', 'bash', 'glob', 'grep', + 'web_search', 'web_fetch', 'task', 'todo_write', 'notebook_edit', 'lsp', + ]), + tclaude: new Set([ + 'read_file', 'write_file', 'edit_file', 'multi_edit', 'bash', 'glob', 'grep', + 'web_search', 'web_fetch', 'task', 'todo_write', 'notebook_edit', 'lsp', + ]), + codex: new Set(['bash', 'edit_file', 'read_file', 'write_file']), + 'codex-internal': new Set(['bash', 'edit_file', 'read_file', 'write_file']), + tcodex: new Set(['bash', 'edit_file', 'read_file', 'write_file']), + codebuddy: new Set([ + 'read_file', 'write_file', 'edit_file', 'bash', 'grep', 'glob', 'task', 'todo_write', + ]), + // IDE 侧工具名与 CLI 不完全一致(write_to_file / execute_command / search_content …), + // 不登记的话迁移预览会把这些正常工具全报成 tool_not_in_target。 + 'codebuddy-ide': new Set([ + 'read_file', 'write_file', 'write_to_file', 'edit_file', 'replace_in_file', 'delete_file', + 'bash', 'execute_command', 'grep', 'search_content', 'glob', 'list_dir', 'codebase_search', + 'web_search', 'web_fetch', 'preview_url', 'lsp', 'task', 'todo_write', 'use_skill', + 'update_memory', 'image_gen', + ]), + cursor: new Set([ + 'read_file', 'write_file', 'edit_file', 'bash', 'grep', 'glob', 'delete_file', + 'web_fetch', 'web_search', 'semantic_search', + ]), +}; + +// --------------------------------------------------------------------------- +// FidelityReport +// --------------------------------------------------------------------------- + +export interface FidelityReport { + score: number; + mode: 'A'; + totalMessages: number; + totalBlocks: number; + preservedBlocks: number; + degradedBlocks: number; + lostBlocks: number; + losses: string[]; + degradations: string[]; + warnings: string[]; + platformSpecificLosses: string[]; +} + +export function fidelityFromSession(session: Session, targetPlatform: string): FidelityReport { + const totalMessages = session.messages.length; + let totalBlocks = 0; + let preservedBlocks = 0; + let degradedBlocks = 0; + let lostBlocks = 0; + const losses: string[] = []; + const degradations: string[] = []; + const warnings: string[] = []; + const platformSpecificLosses: string[] = []; + let thinkingCount = 0; + const unknownTools = new Set(); + + const targetSupportsThinking = THINKING_SUPPORT[targetPlatform] ?? true; + const nativeTools = NATIVE_TOOLS[targetPlatform] ?? new Set(); + + for (const msg of session.messages) { + for (const block of msg.content) { + totalBlocks++; + + if (block.type === 'thinking') { + if (targetSupportsThinking) { + preservedBlocks++; + } else { + thinkingCount++; + degradedBlocks++; + } + } else if (block.type === 'text') { + preservedBlocks++; + } else if (block.type === 'tool_result') { + // Cursor 不存储 tool_result,降级为 text + if (targetPlatform === 'cursor') { + degradedBlocks++; + platformSpecificLosses.push('tool_result_degraded_to_text (cursor)'); + } else { + preservedBlocks++; + } + } else if (block.type === 'tool_call') { + preservedBlocks++; + if (nativeTools.size > 0 && !nativeTools.has(block.toolName)) { + unknownTools.add(block.toolName); + } + } + } + } + + if (thinkingCount > 0 && !targetSupportsThinking) { + degradations.push('thinking_blocks_degraded_to_text'); + } + + for (const toolName of [...unknownTools].sort()) { + warnings.push(`tool_not_in_target: ${toolName}`); + } + + const score = totalBlocks === 0 ? 1.0 : (preservedBlocks + 0.7 * degradedBlocks) / totalBlocks; + + return { + score, + mode: 'A', + totalMessages, + totalBlocks, + preservedBlocks, + degradedBlocks, + lostBlocks, + losses, + degradations, + warnings, + platformSpecificLosses, + }; +} + +// --------------------------------------------------------------------------- +// 增强处理:降级 ThinkingBlock +// --------------------------------------------------------------------------- + +export function degradeThinkingBlocks(session: Session, targetPlatform: string): Session { + const targetSupportsThinking = THINKING_SUPPORT[targetPlatform] ?? true; + if (targetSupportsThinking) return session; + + return { + ...session, + messages: session.messages.map((msg) => ({ + ...msg, + content: msg.content.map((block): ContentBlock => { + if (block.type === 'thinking') { + return { type: 'text', text: `\n${block.text}\n` }; + } + return block; + }), + })), + }; +} + +// --------------------------------------------------------------------------- +// MigrationPreview / MigrationResult +// --------------------------------------------------------------------------- + +export interface MigrationPreview { + sourcePlatform: string; + targetPlatform: string; + sessionId: string; + sessionTitle: string; + cwd: string; + messageCount: number; + fidelity: FidelityReport; + targetSessionId?: string; +} + +export interface MigrationResult { + preview: MigrationPreview; + success: boolean; + targetSessionId?: string; + targetFilePath?: string; + error?: string; + startedAt: string; + completedAt?: string; +} + +// --------------------------------------------------------------------------- +// MigrationEngine +// --------------------------------------------------------------------------- + +export class MigrationEngine { + constructor( + private sourcePlatform: string, + private targetPlatform: string, + ) {} + + async preview(sessionId: string, projectPath?: string): Promise { + const source = getAdapter(this.sourcePlatform); + const session = await source.readSession(sessionId, projectPath); + const fidelity = fidelityFromSession(session, this.targetPlatform); + + return { + sourcePlatform: this.sourcePlatform, + targetPlatform: this.targetPlatform, + sessionId: session.sessionId, + sessionTitle: session.title, + cwd: session.cwd, + messageCount: session.messages.length, + fidelity, + }; + } + + async migrate(sessionId: string, projectPath?: string, targetProjectPath?: string): Promise { + const startedAt = new Date().toISOString(); + let targetSid: string | undefined; + + try { + const source = getAdapter(this.sourcePlatform); + const target = getAdapter(this.targetPlatform); + + const session = await source.readSession(sessionId, projectPath); + const fidelity = fidelityFromSession(session, this.targetPlatform); + + // 降级 ThinkingBlock + const enhancedSession = degradeThinkingBlocks(session, this.targetPlatform); + + targetSid = await target.writeSession(enhancedSession, targetProjectPath); + + // 尝试定位目标文件路径 + let targetFilePath: string | undefined; + try { + const targetAdapter = getAdapter(this.targetPlatform); + // 通过 list 查找刚写入的会话 + const metas = await targetAdapter.listConversations(targetProjectPath); + const found = metas.find((m) => m.sessionId === targetSid); + if (found) targetFilePath = found.filePath; + } catch { + // ignore + } + + const completedAt = new Date().toISOString(); + return { + preview: { + sourcePlatform: this.sourcePlatform, + targetPlatform: this.targetPlatform, + sessionId: session.sessionId, + sessionTitle: session.title, + cwd: session.cwd, + messageCount: session.messages.length, + fidelity, + targetSessionId: targetSid, + }, + success: true, + targetSessionId: targetSid, + targetFilePath, + startedAt, + completedAt, + }; + } catch (e) { + // 自动回退 + if (targetSid) { + try { + const target = getAdapter(this.targetPlatform); + await target.deleteSession(targetSid); + } catch { + // 回退失败不掩盖原始错误 + } + } + + const completedAt = new Date().toISOString(); + return { + preview: { + sourcePlatform: this.sourcePlatform, + targetPlatform: this.targetPlatform, + sessionId, + sessionTitle: '', + cwd: '', + messageCount: 0, + fidelity: { + score: 0, + mode: 'A', + totalMessages: 0, + totalBlocks: 0, + preservedBlocks: 0, + degradedBlocks: 0, + lostBlocks: 0, + losses: [], + degradations: [], + warnings: [], + platformSpecificLosses: [], + }, + }, + success: false, + error: (e as Error).message, + startedAt, + completedAt, + }; + } + } + + async migrateBatch(sessionIds: string[], projectPath?: string, targetProjectPath?: string): Promise { + const results: MigrationResult[] = []; + for (const sid of sessionIds) { + results.push(await this.migrate(sid, projectPath, targetProjectPath)); + } + return results; + } + + async rollback(targetSessionId: string): Promise { + try { + const target = getAdapter(this.targetPlatform); + await target.deleteSession(targetSessionId); + return true; + } catch { + return false; + } + } +} + +export { getAdapter, listAvailablePlatforms, listInstalledPlatforms }; diff --git a/src/session-flow/search.ts b/src/session-flow/search.ts new file mode 100644 index 00000000..8a96424c --- /dev/null +++ b/src/session-flow/search.ts @@ -0,0 +1,269 @@ +/** + * 会话检索引擎 — BM25 + 时间衰减。 + * + * 对已迁移的会话建立 BM25 索引,支持关键词检索。 + * 标题加权 3x,时间衰减半衰期 30 天(影响 30% 权重)。 + * + * Ported from sessionflow/core/search.py + */ + +import type { Session, Message } from './ir.js'; + +// --------------------------------------------------------------------------- +// 数据结构 +// --------------------------------------------------------------------------- + +export interface SearchHit { + sessionName: string; + author: string; + platform: string; + title: string; + cwd: string; + score: number; + snippet: string; + messageCount: number; + createdAt: string; + matchedMessages: Array<{ + role: string; + snippet: string; + timestamp?: string; + }>; +} + +// --------------------------------------------------------------------------- +// 分词 +// --------------------------------------------------------------------------- + +export function tokenize(text: string): string[] { + if (!text) return []; + const lower = text.toLowerCase(); + const tokens: string[] = []; + let currentWord = ''; + + for (const ch of lower) { + if (/[a-z0-9_]/.test(ch)) { + currentWord += ch; + } else { + if (currentWord) { + tokens.push(currentWord); + currentWord = ''; + } + // 中文字符单独成 token + if (/[\u4e00-\u9fff]/.test(ch)) { + tokens.push(ch); + } + } + } + if (currentWord) tokens.push(currentWord); + return tokens; +} + +function extractTextFromMessage(msg: Message): string { + const parts: string[] = []; + for (const block of msg.content) { + if (block.type === 'text') { + parts.push(block.text); + } else if (block.type === 'thinking') { + parts.push(block.text); + } else if (block.type === 'tool_call') { + parts.push(block.toolName); + parts.push(Object.values(block.arguments).map(String).join(' ')); + } + } + return parts.join(' '); +} + +function makeSnippet(text: string, query: string, maxLen = 200): string { + if (!text) return ''; + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + + let idx = lowerText.indexOf(lowerQuery); + if (idx === -1) { + for (const token of tokenize(query)) { + idx = lowerText.indexOf(token); + if (idx !== -1) break; + } + } + if (idx === -1) { + return text.slice(0, maxLen) + (text.length > maxLen ? '...' : ''); + } + + const start = Math.max(0, idx - Math.floor(maxLen / 3)); + const end = Math.min(text.length, start + maxLen); + let snippet = text.slice(start, end); + if (start > 0) snippet = '...' + snippet; + if (end < text.length) snippet = snippet + '...'; + return snippet; +} + +// --------------------------------------------------------------------------- +// 时间衰减 +// --------------------------------------------------------------------------- + +function timeDecay(createdAt: string, halfLifeDays = 30): number { + const dt = new Date(createdAt); + if (isNaN(dt.getTime())) return 0.5; + const now = Date.now(); + const daysAgo = (now - dt.getTime()) / 86_400_000; + if (daysAgo < 0) return 1.0; + return Math.pow(0.5, daysAgo / halfLifeDays); +} + +// --------------------------------------------------------------------------- +// BM25 Okapi(自行实现,零依赖) +// --------------------------------------------------------------------------- + +class BM25Okapi { + private corpus: string[][]; + private k1: number; + private b: number; + private avgDl: number; + private idf: Map; + private docFreq: Map; + private docLen: number[]; + + constructor(corpus: string[][], k1 = 1.5, b = 0.75) { + this.corpus = corpus; + this.k1 = k1; + this.b = b; + this.docLen = corpus.map((doc) => doc.length); + this.avgDl = this.docLen.length > 0 + ? this.docLen.reduce((s, n) => s + n, 0) / this.docLen.length + : 0; + + // 计算 document frequency + this.docFreq = new Map(); + for (const doc of corpus) { + const seen = new Set(doc); + for (const term of seen) { + this.docFreq.set(term, (this.docFreq.get(term) ?? 0) + 1); + } + } + + // 计算 IDF (Okapi BM25 variant) + const N = corpus.length; + this.idf = new Map(); + for (const [term, df] of this.docFreq) { + this.idf.set(term, Math.log(1 + (N - df + 0.5) / (df + 0.5))); + } + } + + getScores(queryTokens: string[]): number[] { + const scores = new Array(this.corpus.length).fill(0); + + for (let i = 0; i < this.corpus.length; i++) { + const doc = this.corpus[i]; + const docTermFreq = new Map(); + for (const term of doc) { + docTermFreq.set(term, (docTermFreq.get(term) ?? 0) + 1); + } + + const dl = this.docLen[i] || 1; + const normFactor = 1 - this.b + this.b * (dl / (this.avgDl || 1)); + + for (const term of queryTokens) { + const tf = docTermFreq.get(term) ?? 0; + if (tf === 0) continue; + const idf = this.idf.get(term) ?? 0; + const numerator = tf * (this.k1 + 1); + const denominator = tf + this.k1 * normFactor; + scores[i] += idf * (numerator / denominator); + } + } + + return scores; + } +} + +// --------------------------------------------------------------------------- +// 搜索引擎 +// --------------------------------------------------------------------------- + +export interface LoadedSession { + sessionName: string; + author: string; + session: Session; +} + +export class SessionSearchEngine { + /** + * 搜索已加载的会话列表。 + * + * @param sessions 已加载的会话列表(sessionName + author + Session) + * @param query 搜索关键词 + * @param options 搜索选项 + */ + async search( + sessions: LoadedSession[], + query: string, + options: { limit?: number; enableDecay?: boolean } = {}, + ): Promise { + const { limit = 20, enableDecay = true } = options; + + if (!query.trim()) return []; + + const queryTokens = tokenize(query); + if (queryTokens.length === 0) return []; + + if (sessions.length === 0) return []; + + // 构建文档 + const docs = sessions.map((s) => { + const msgTexts = s.session.messages.map(extractTextFromMessage); + const fullText = msgTexts.join(' '); + let tokens = tokenize(fullText); + // 标题加权 3x + const titleTokens = tokenize(s.session.title); + tokens = [...titleTokens, ...titleTokens, ...titleTokens, ...tokens]; + return { meta: s, tokens, fullText, msgTexts }; + }); + + // BM25 检索 + const corpus = docs.map((d) => d.tokens); + const bm25 = new BM25Okapi(corpus); + const scores = bm25.getScores(queryTokens); + + // 构建结果 + const results: SearchHit[] = []; + for (let i = 0; i < docs.length; i++) { + const rawScore = scores[i]; + if (rawScore <= 0) continue; + + const doc = docs[i]; + const decay = enableDecay ? timeDecay(doc.meta.session.createdAt) : 1.0; + const finalScore = rawScore * (0.7 + 0.3 * decay); + + // 找到匹配的消息 + const lowerQuery = query.toLowerCase(); + const matched: SearchHit['matchedMessages'] = []; + for (let j = 0; j < doc.msgTexts.length; j++) { + if (doc.msgTexts[j].toLowerCase().includes(lowerQuery)) { + const msg = doc.meta.session.messages[j]; + matched.push({ + role: msg.role, + snippet: makeSnippet(doc.msgTexts[j], query), + timestamp: msg.timestamp, + }); + if (matched.length >= 3) break; + } + } + + results.push({ + sessionName: doc.meta.sessionName, + author: doc.meta.author, + platform: doc.meta.session.platform, + title: doc.meta.session.title, + cwd: doc.meta.session.cwd, + score: finalScore, + snippet: makeSnippet(doc.fullText, query), + messageCount: doc.meta.session.messages.length, + createdAt: doc.meta.session.createdAt, + matchedMessages: matched, + }); + } + + results.sort((a, b) => b.score - a.score); + return results.slice(0, limit); + } +} diff --git a/src/session-flow/session-cmd.ts b/src/session-flow/session-cmd.ts new file mode 100644 index 00000000..50b37046 --- /dev/null +++ b/src/session-flow/session-cmd.ts @@ -0,0 +1,758 @@ +/** + * session-cmd.ts — SessionFlow 子命令注册。 + * + * 把 SessionFlow 的会话迁移/同步/搜索/恢复能力注册为 `teamai session` 的子命令: + * + * teamai session migrate 跨平台迁移会话(或同平台存档) + * teamai session push 推送会话到团队仓 + * teamai session pull 从团队仓拉取当前项目的会话 + * teamai session list 列出当前项目下团队成员的会话 + * teamai session resume 恢复会话到本地平台,接着聊 + * teamai session search 搜索历史会话内容 + * teamai session rollback 回滚一次迁移 + * + * 与现有的 `teamai session save`(脱敏摘要)并列,互不干扰。 + * + * 项目隔离:按当前 cwd 的 git remote origin → canonical → 团队仓目录。 + * 非 git 目录降级到 _unattributed/,不报错。 + */ + +import type { Command } from 'commander'; +import readline from 'node:readline'; +import * as path from 'node:path'; +import { getAdapter, listAvailablePlatforms, listInstalledPlatforms } from './adapters/index.js'; +import { MigrationEngine } from './migrate.js'; +import { SyncManager, getRepoIdentity, getGitAuthor, defaultSyncMeta } from './sync.js'; +import { SessionSearchEngine, type LoadedSession } from './search.js'; + +// --------------------------------------------------------------------------- +// 辅助 +// --------------------------------------------------------------------------- + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / 1024 / 1024).toFixed(1)}MB`; +} + +/** + * 预读所有 stdin 行到队列,ask 从队列取。 + * + * 不能用 rl.question 逐次等待:管道批量输入时多个 question 的回调会竞争 + * (前一个问题 shift 回调后、下一个问题的回调尚未注册,中间的输入行会被丢弃)。 + * 队列式 reader 在 TTY 和管道下都稳定。 + */ +let lineQueue: string[] = []; +let lineResolver: ((line: string) => void) | null = null; +let lineReaderStarted = false; + +let sharedRl: readline.Interface | null = null; + +function startLineReader(): void { + if (lineReaderStarted) return; + lineReaderStarted = true; + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + sharedRl = rl; + rl.on('line', (line) => { + if (lineResolver) { + const r = lineResolver; + lineResolver = null; + r(line.trim()); + } else { + lineQueue.push(line.trim()); + } + }); +} + +/** + * 关闭 readline 接口,释放 event loop。 + * 必须在所有交互式输入结束后调用,否则进程会挂起不退出(终端不返回提示符)。 + */ +function closeStdin(): void { + if (sharedRl) { + sharedRl.close(); + sharedRl = null; + } + lineResolver = null; +} + +/** + * 读一行用户输入(交互式)。优先取预读队列,否则等待下一行。 + */ +function ask(question: string): Promise { + startLineReader(); + return new Promise((resolve) => { + if (lineQueue.length > 0) { + resolve(lineQueue.shift() as string); + return; + } + // stdin 关闭(EOF/管道结束)时 'line' 永不触发,promise 悬挂到事件循环 + // 清空后进程静默退出——脚本化调用得到 exit 0 + 无输出,被当成成功。 + // 显式 resolve 空串,让调用方走各自的 "Cancelled." 分支。 + const onEnd = () => { + if (lineResolver) { + const r = lineResolver; + lineResolver = null; + r(''); + } + }; + sharedRl?.once?.('close', onEnd); + lineResolver = (line) => { + sharedRl?.off?.('close', onEnd); + resolve(line); + }; + process.stdout.write(question); + }); +} + +/** + * 显示一个编号菜单,让用户选择一项。 + * 输入数字或名称都接受,返回选中的字符串。 + */ +async function promptSelect(question: string, options: string[]): Promise { + console.log(question); + for (let i = 0; i < options.length; i++) { + console.log(` [${i + 1}] ${options[i]}`); + } + const ans = await ask('Select (number or name): '); + if (!ans) throw new Error('Selection cancelled'); + const num = parseInt(ans, 10); + if (!Number.isNaN(num) && num >= 1 && num <= options.length) { + return options[num - 1]; + } + const lower = ans.toLowerCase(); + const byName = options.find((o) => o.toLowerCase() === lower); + if (byName) return byName; + throw new Error(`Invalid selection: ${ans}`); +} + +/** + * 安全获取适配器,无效平台给出友好提示而非 stack trace。 + */ +function safeGetAdapter(platform: string) { + try { + return getAdapter(platform); + } catch { + const available = listAvailablePlatforms().join(', '); + console.error(`Error: Unknown platform "${platform}".`); + console.error(`Available platforms: ${available}`); + process.exit(1); + } +} + +/** + * 解析当前 cwd 的 repoIdentity(git remote canonical)。 + * 非 git 目录返回 null(降级到 _unattributed),不报错。 + */ +function resolveRepoIdentity(cwd?: string): string | null { + return getRepoIdentity(cwd ?? process.cwd()); +} + +/** + * 按会话**原生 cwd** 派生归档键(repoIdentity)——Key invariant: + * 归档键来自会话自身的工作目录,绝不是 CLI 恰好运行所在的目录(设计文档 P3)。 + * + * 各适配器 readSession 已尽量恢复原生 cwd: + * - codex / workbuddy:存储自带真实路径 + * - claude-code / codebuddy / cursor:从 JSONL 记录的 cwd 字段恢复 + * - codebuddy-ide:工作区目录是 md5(cwd) 不可逆——恢复不出真实路径时 + * session.cwd 是 `md5:` 占位,归 `_unattributed` 并打印英文警告 + */ +function deriveArchiveIdentity(session: { cwd: string }, platform: string): string | null { + const nativeCwd = session.cwd; + if (nativeCwd && path.isAbsolute(nativeCwd)) { + return getRepoIdentity(nativeCwd); + } + console.warn( + ` ⚠ native cwd unknowable for ${platform} session, archived under _unattributed`, + ); + return null; +} + +/** + * 获取团队仓根目录。 + * 优先用 --repo-root;否则用 cwd(假设 cwd 就是团队仓 clone)。 + */ +function resolveRepoRoot(repoRoot?: string): string { + return repoRoot ?? process.cwd(); +} + +/** + * 推送团队仓远端;失败时降级为警告而非崩溃。 + * + * 走到这里时本地 saveSession + gitCommit 已经成功——会话数据没有丢。 + * 远端失败的原因常常与数据无关(无 upstream、只读 HTTP 模式、网络), + * 用堆栈炸掉会把一次成功的归档伪装成彻底失败,用户再跑一次还会造出重复提交。 + */ +function pushToRemote(syncMgr: SyncManager): void { + try { + syncMgr.gitPush(); + } catch (err) { + // "Command failed: git push origin" 首行没有信息量,git 的 fatal 行才是原因 + const msg = err instanceof Error ? err.message : String(err); + const fatal = msg.split('\n').find((l) => /^(fatal|error):/i.test(l.trim())); + const reason = fatal?.trim() ?? msg.split('\n')[0]; + console.log(` · Remote push failed (local commit kept): ${reason}`); + } +} + +/** + * 包装 gitCommit / gitPull:git 层失败(非 git 目录、无 remote、index.lock + * 竞态等)给出单行英文错误并 exit 1,而不是让 execFileSync 的异常以裸 + * stack trace 打到用户面(内部路径泄漏 + 伪造的崩溃感)。 + */ +function runGitStep(step: () => string | null, repoRoot: string, what: string): string | null { + try { + return step(); + } catch (err) { + const reason = err instanceof Error ? err.message.split('\n')[0] : String(err); + console.error(`Error: ${what} failed in ${repoRoot}: ${reason}`); + console.error(`Check that ${repoRoot} is a git repository with a configured remote, then retry.`); + process.exit(1); + } +} + +// --------------------------------------------------------------------------- +// 命令注册 +// --------------------------------------------------------------------------- + +/** + * 在 `teamai session` 子命令对象上注册 SessionFlow 的 7 个子命令。 + */ +export function registerSessionFlowCommands(sessionCmd: Command): void { + // 输出管道被下游关闭(如 `session push --all | head`)时,EPIPE 会让整条 + // 命令以堆栈崩溃收场——数据早已写完,这不是错误,安静退出即可。 + process.stdout?.on?.('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EPIPE') process.exit(0); + throw err; + }); + + // --dry-run / -v 是顶层 program 上的全局选项,不会自动出现在子命令的 opts 里。 + // 原项目各命令统一用 `program.opts()` 取全局选项再与命令自身选项合并 + // (见 src/index.ts 中 init/push/pull 的 action),这里保持一致。 + const root = sessionCmd.parent ?? sessionCmd; + const isDryRun = (): boolean => Boolean((root.opts() as { dryRun?: boolean }).dryRun); + + // ── session platforms ────────────────────────────────────── + sessionCmd + .command('platforms') + .description('List supported and installed AI agent platforms') + .action(async () => { + const available = listAvailablePlatforms(); + const installed = listInstalledPlatforms(); + console.log('Available platforms:'); + for (const p of available) { + const status = installed.includes(p) ? '✓ installed' : '✗ not installed'; + console.log(` ${p}: ${status}`); + } + }); + + // ── session migrate ──────────────────────────────────────── + sessionCmd + .command('migrate') + .description('Migrate a session from one platform to another (or archive to same platform)') + .argument('[sessionId]', 'Session ID to migrate') + .option('-s, --source ', 'Source platform (e.g. claude-code, codebuddy)') + .option('-t, --target ', 'Target platform') + .option('--cwd ', 'Working directory (defaults to current directory)') + .option('--target-cwd ', 'Override cwd for the target session') + .option('--push', 'Also push the migrated session to the team repo') + .option('--repo-root ', 'Team repo root (for --push)') + .option('--all', 'Migrate all recent sessions from source (top 5)') + .option('-y, --yes', 'Skip confirmation prompt') + .action(async (sessionId, opts) => { + try { + let source = opts.source; + let target = opts.target; + + // 交互式:缺 source/target 时引导选择 + if (!source) { + source = await promptSelect('Select source platform:', listAvailablePlatforms()); + } + if (!target) { + const others = listAvailablePlatforms().filter((p) => p !== source); + target = await promptSelect('Select target platform:', others); + } + + let workCwd = opts.cwd ?? process.cwd(); + const sourceAdapter = safeGetAdapter(source); + let metas = await sourceAdapter.listConversations(workCwd); + + // 当前 cwd 无会话时,交互式提示列出全部目录的会话 + if (metas.length === 0 && !opts.cwd && !sessionId) { + const allMetas = await sourceAdapter.listConversations(); + if (allMetas.length > 0) { + console.log(`\nNo sessions found in current directory: ${workCwd}`); + console.log(`But ${allMetas.length} session(s) found across all directories on ${source}.`); + const ans = await ask('List all? (y/N): '); + if (ans.toLowerCase() === 'y' || ans.toLowerCase() === 'yes') { + metas = allMetas; + } + } + } + + if (metas.length === 0) { + console.log('No sessions found on source platform.'); + return; + } + + // 选择会话 + let targets: typeof metas; + if (opts.all) { + targets = metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 5); + } else if (sessionId) { + targets = metas.filter((m) => m.sessionId === sessionId || m.sessionId.startsWith(sessionId)); + if (targets.length === 0) { + console.error(`Session not found: ${sessionId}`); + process.exit(1); + } + } else { + // 交互式:列出最近的 10 个,让用户选号 + const recent = metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 10); + console.log('\nRecent sessions on ' + source + ':'); + for (let i = 0; i < recent.length; i++) { + const m = recent[i]; + const title = m.title.length > 50 ? m.title.slice(0, 50) + '...' : m.title; + console.log(` [${i + 1}] ${m.sessionId.slice(0, 8)} ${title} (${m.messageCount} msgs, ${formatBytes(m.sizeBytes)})`); + } + const ans = await ask('\nSelect session (number) or Enter to cancel: '); + const num = parseInt(ans, 10); + if (!ans || Number.isNaN(num) || num < 1 || num > recent.length) { + console.log('Cancelled.'); + return; + } + targets = [recent[num - 1]]; + } + + const engine = new MigrationEngine(source, target); + let migrated = 0; + // 记录每次成功迁移产出的目标会话 ID + 真实保真度, + // --push 时精确回读这些 ID(而非"目标平台最近 N 条",避免推错,见 P4/P7)。 + const migratedTargets: { sessionId: string; fidelityScore: number }[] = []; + + for (const m of targets) { + const preview = await engine.preview(m.sessionId, workCwd); + + console.log(`\n Migration Preview`); + console.log(` ─────────────────────────────────`); + console.log(` Source: ${preview.sourcePlatform}`); + console.log(` Target: ${preview.targetPlatform}`); + console.log(` Session: ${preview.sessionTitle} (${preview.sessionId.slice(0, 8)}...)`); + console.log(` CWD: ${preview.cwd}`); + console.log(` Messages: ${preview.messageCount}`); + console.log(` ─────────────────────────────────`); + console.log(` Fidelity: ${(preview.fidelity.score * 100).toFixed(1)}% (Mode ${preview.fidelity.mode})`); + console.log(` Preserved: ${preview.fidelity.preservedBlocks}/${preview.fidelity.totalBlocks} blocks`); + if (preview.fidelity.degradedBlocks > 0) { + console.log(` Degraded: ${preview.fidelity.degradedBlocks} blocks`); + } + for (const d of preview.fidelity.degradations) { + console.log(` ⚠ ${d}`); + } + for (const w of preview.fidelity.warnings) { + console.log(` ⚠ ${w}`); + } + + // --dry-run:Preview 打印完就停。 + // 迁移没有确认环节(打完 Preview 就直接执行),不接全局 --dry-run 的话, + // 想看保真度和告警就只能真迁一次、不满意再 rollback。 + if (isDryRun()) { + console.log(`\n · --dry-run: preview only, not migrated: ${m.sessionId.slice(0, 8)}...\n`); + continue; + } + + // 目标 cwd 默认为当前工作目录(真实绝对路径)。 + // 不传的话 writeSession 会回退到 session.cwd——那可能是源平台存的 + // encoded 形式(如 `-Users-foo-project`),无法还原真实路径。 + const result = await engine.migrate(m.sessionId, workCwd, opts.targetCwd ?? workCwd); + if (result.success) { + console.log(`\n ✓ Migration successful`); + console.log(` Target session ID: ${result.targetSessionId}`); + if (result.targetFilePath) { + console.log(` Target file: ${result.targetFilePath}`); + } + console.log(` Fidelity: ${(result.preview.fidelity.score * 100).toFixed(1)}%`); + migrated++; + if (result.targetSessionId) { + migratedTargets.push({ + sessionId: result.targetSessionId, + fidelityScore: result.preview.fidelity.score, + }); + } + } else { + console.error(`\n ✗ Migration failed: ${result.error}`); + } + } + + // --push: 推送到团队仓 + if (opts.push && migrated > 0) { + const repoRoot = resolveRepoRoot(opts.repoRoot); + const author = getGitAuthor(workCwd); + const targetAdapter = safeGetAdapter(target); + + const syncMgr = new SyncManager(repoRoot); + let saved = 0; + for (const t of migratedTargets) { + const session = await targetAdapter.readSession(t.sessionId, opts.targetCwd ?? workCwd); + // P3:归档键按会话原生 cwd 派生,而非 migrate 运行目录(见 deriveArchiveIdentity) + const meta = defaultSyncMeta( + { + platform: target, + author, + cwd: session.cwd || opts.targetCwd || workCwd, + sessionId: t.sessionId, + repoIdentity: deriveArchiveIdentity(session, target), + }, + session.createdAt, + ); + meta.migration.migratedAt = new Date().toISOString(); + meta.migration.sourcePlatform = source; + meta.migration.targetPlatform = target; + meta.migration.fidelityScore = t.fidelityScore; + syncMgr.saveSession(session, meta); + saved++; + } + // [已修] gitCommit 失败(非 git 目录 / index.lock 竞态)此前裸堆栈崩溃 + const commitHash = runGitStep( + () => syncMgr.gitCommit(`sync: migrate ${saved} session(s) ${source}→${target}`), + repoRoot, + 'git commit', + ); + if (commitHash) { + pushToRemote(syncMgr); + console.log(`\n ✓ Pushed ${saved} session(s) to team repo`); + console.log(` commit: ${commitHash.slice(0, 8)}`); + } else { + console.log(`\n · No changes to push\n`); + } + } + + console.log( + isDryRun() + ? `\n ${targets.length} session(s) would be migrated (--dry-run, no changes made).\n` + : `\n ${migrated} session(s) migrated.\n`, + ); + } catch (err) { + // 交互取消(promptSelect 抛 'Selection cancelled',EOF 时 ask 返回空串 + // 触发该路径)不是故障——静默退出,不能变成 unhandled rejection 堆栈。 + const msg = err instanceof Error ? err.message : String(err); + if (/cancel/i.test(msg)) { + console.log('Cancelled.'); + return; + } + console.error(`Error: ${msg}`); + process.exit(1); + } finally { + closeStdin(); + } + }); + + // ── session push ─────────────────────────────────────────── + sessionCmd + .command('push') + .description('Push local sessions to the team repo') + .option('--source ', 'Source platform to read sessions from') + .option('--repo-root ', 'Team repo root (defaults to cwd)') + .option('--cwd ', 'Working directory (defaults to current directory)') + .option('--limit ', 'Max sessions to push (default: 5; ignored with --all)', '5') + .option('--all', 'Push every session of the platform across all workspace directories (ignores --limit)') + .option('-y, --yes', 'Skip the confirmation prompt for large batches (--all)') + .action(async (opts) => { + try { + const source = opts.source; + if (!source) { + console.error('Error: --source required.'); + console.error('Usage: teamai session push --source [--repo-root ] [--all]'); + process.exit(1); + } + const workCwd = opts.cwd ?? process.cwd(); + const repoRoot = resolveRepoRoot(opts.repoRoot); + const author = getGitAuthor(workCwd); + const adapter = safeGetAdapter(source); + // --all:listConversations() 无参即枚举该平台的全部工作区目录(P5), + // 影响面收敛在单一平台(与 status --all 一致);单 cwd 模式保持原行为。 + const metas = opts.all + ? await adapter.listConversations() + : await adapter.listConversations(workCwd); + const limitRaw = parseInt(opts.limit, 10); + // 非数字/0/负数一律回退默认——slice(0, -1) 的负数语义会把结果悄悄吃掉一条 + const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 5; + const sorted = metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + const selected = opts.all ? sorted : sorted.slice(0, limit); + + if (selected.length === 0) { + console.log('No sessions found to push.'); + return; + } + + // 大批量确认:--all 推送超过 5 条时列清单(id/标题/条数)要求确认,-y 跳过 + if (opts.all && selected.length > 5 && !opts.yes) { + console.log(`\nAbout to push ${selected.length} session(s) from ${source}:`); + for (const m of selected) { + const title = m.title.length > 50 ? m.title.slice(0, 50) + '...' : m.title; + console.log(` ${m.sessionId.slice(0, 8)} ${title} (${m.messageCount} msgs)`); + } + const ans = await ask('\nPush all of the above? (y/N): '); + if (ans.toLowerCase() !== 'y' && ans.toLowerCase() !== 'yes') { + console.log('Cancelled.'); + return; + } + } + + const syncMgr = new SyncManager(repoRoot); + let saved = 0; + for (const m of selected) { + // --all 时会话可能来自任意工作区,scoped 查找(按 cwd 编码目录)会因 + // 目录名解码有损而 miss——交由适配器全局查找;单 cwd 模式仍传 workCwd。 + const session = await adapter.readSession(m.sessionId, opts.all ? undefined : workCwd); + if (opts.all) { + console.log(` Source: ${session.cwd || 'unknown directory'}`); + } + // P3:归档键按会话原生 cwd 派生(见 deriveArchiveIdentity),而非 CLI 运行目录 + const meta = defaultSyncMeta( + { + platform: source, + author, + cwd: session.cwd || workCwd, + sessionId: m.sessionId, + repoIdentity: deriveArchiveIdentity(session, source), + }, + session.createdAt, + ); + syncMgr.saveSession(session, meta); + saved++; + } + // [已修] gitCommit 失败(非 git 目录 / index.lock 竞态)此前裸堆栈崩溃 + const commitHash = runGitStep( + () => syncMgr.gitCommit(`sync: push ${saved} session(s) from ${source}${opts.all ? ' (all workspaces)' : ''}`), + repoRoot, + 'git commit', + ); + if (commitHash) { + pushToRemote(syncMgr); + console.log(`\n ✓ Pushed ${saved} session(s) from ${source}`); + console.log(` commit: ${commitHash.slice(0, 8)}\n`); + } else { + console.log(`\n · No changes to push\n`); + } + } finally { + closeStdin(); + } + }); + + // ── session pull ─────────────────────────────────────────── + sessionCmd + .command('pull') + .description('Pull team sessions for the current project') + .option('--repo-root ', 'Team repo root (defaults to cwd)') + .option('--cwd ', 'Working directory (defaults to current directory)') + .option('--all', 'Rebuild indexes for every repo in the team repo (not just the current project)') + .action(async (opts) => { + const workCwd = opts.cwd ?? process.cwd(); + const repoRoot = resolveRepoRoot(opts.repoRoot); + + const syncMgr = new SyncManager(repoRoot); + // [已修] gitPull 失败(无 remote / repoRoot 不存在)此前裸堆栈崩溃 + runGitStep(() => { + syncMgr.gitPull(); + return null; + }, repoRoot, 'git pull'); + if (opts.all) { + // P2:对所有 identity(含 _unattributed)逐个幂等重建索引 + const identities = syncMgr.listAllRepoIdentities(); + let total = 0; + for (const identity of identities) { + total += syncMgr.rebuildIndex(identity); + } + console.log(`\n ✓ Pulled and indexed ${total} session(s) across ${identities.length} repo(s)\n`); + } else { + const repoIdentity = resolveRepoIdentity(workCwd); + const count = syncMgr.rebuildIndex(repoIdentity); + console.log(`\n ✓ Pulled and indexed ${count} session(s)\n`); + } + }); + + // ── session list ─────────────────────────────────────────── + sessionCmd + .command('list') + .description('List team sessions for the current project') + .option('--repo-root ', 'Team repo root (defaults to cwd)') + .option('--cwd ', 'Working directory (defaults to current directory)') + .option('--author ', 'Filter by author') + .option('--all', 'List sessions across all projects in the team repo (not just the current one)') + .action(async (opts) => { + const workCwd = opts.cwd ?? process.cwd(); + const repoRoot = resolveRepoRoot(opts.repoRoot); + const repoIdentity = resolveRepoIdentity(workCwd); + + const syncMgr = new SyncManager(repoRoot); + // P2:--all 走跨 repo 视图(含 _unattributed);条目 repoIdentity 标识来源 + const sessions = opts.all + ? syncMgr.listSessionsAcrossRepos(opts.author) + : syncMgr.listSessions(repoIdentity, opts.author); + + if (sessions.length === 0) { + console.log('No team sessions found.'); + return; + } + + if (opts.all) { + console.log(`\nSessions across all projects:\n`); + console.log(` SESSION AUTHOR PLATFORM MSGS UPDATED SOURCE`); + console.log(` ─────────────────────────────────────────────────────────────────────────────────────`); + for (const s of sessions) { + const name = s.sessionName.length > 36 ? s.sessionName.slice(0, 34) + '..' : s.sessionName.padEnd(36); + const authorCol = s.author.padEnd(12); + const platCol = s.platform.padEnd(16); + const msgCol = String(s.messageCount).padStart(4); + const dateCol = s.updatedAt.slice(0, 10); + const source = s.repoIdentity ?? '_unattributed'; + console.log(` ${name} ${authorCol}${platCol}${msgCol} ${dateCol} ${source}`); + } + } else { + const repoLabel = repoIdentity ?? '_unattributed'; + console.log(`\nSessions for ${repoLabel}:\n`); + console.log(` SESSION AUTHOR PLATFORM MSGS UPDATED`); + console.log(` ──────────────────────────────────────────────────────────────────────────`); + for (const s of sessions) { + const name = s.sessionName.length > 36 ? s.sessionName.slice(0, 34) + '..' : s.sessionName.padEnd(36); + const authorCol = s.author.padEnd(12); + const platCol = s.platform.padEnd(16); + const msgCol = String(s.messageCount).padStart(4); + const dateCol = s.updatedAt.slice(0, 10); + console.log(` ${name} ${authorCol}${platCol}${msgCol} ${dateCol}`); + } + } + console.log(`\n ${sessions.length} session(s)\n`); + }); + + // ── session resume ───────────────────────────────────────── + sessionCmd + .command('resume') + .description('Restore a team session to a local platform') + .argument('', 'Session name (from `teamai session list`)') + // 选项名用 --platform 而非 --in:与 rollback 的 --platform 对齐, + // 也符合本项目其余命令的名词式命名(--source / --target / --agent / --role)。 + .requiredOption('--platform ', 'Target platform to restore into') + .option('--repo-root ', 'Team repo root (defaults to cwd)') + .option('--cwd ', 'Working directory for the restored session (defaults to current directory)') + .option('--author ', 'Author of the session (if ambiguous)') + .action(async (sessionName, opts) => { + const workCwd = opts.cwd ?? process.cwd(); + const repoRoot = resolveRepoRoot(opts.repoRoot); + const repoIdentity = resolveRepoIdentity(workCwd); + + const syncMgr = new SyncManager(repoRoot); + // resume 无 try/catch 时 loadSession 的错误会以未捕获异常打到终端。 + // 常见根因是会话归档在其他项目名下(repoIdentity 不匹配)—— + // 给出可操作的指引而不是裸 stack trace(见设计文档 P9)。 + let session; + try { + ({ session } = syncMgr.loadSession(repoIdentity, sessionName, opts.author)); + } catch (e) { + console.error(`Error: ${(e as Error).message}`); + console.error('The session may be archived under another project identity.'); + console.error('Try `teamai session search --all ` to find it,'); + console.error('or rerun with `--cwd ` of the project it belongs to.'); + process.exit(1); + } + + const resumeAdapter = safeGetAdapter(opts.platform); + const resumeCwd = opts.cwd ?? process.cwd(); + session.cwd = resumeCwd; + + const newSessionId = await resumeAdapter.writeSession(session, resumeCwd); + + console.log(`\n ✓ Session restored to ${opts.platform}`); + console.log(` Session ID: ${newSessionId}`); + console.log(` Messages: ${session.messages.length}`); + console.log(` CWD: ${resumeCwd}`); + console.log(`\n To continue: ${opts.platform} --resume ${newSessionId}\n`); + }); + + // ── session search ───────────────────────────────────────── + sessionCmd + .command('search') + .description('Search team session content') + .argument('', 'Search query') + .option('--repo-root ', 'Team repo root (defaults to cwd)') + .option('--cwd ', 'Working directory (defaults to current directory)') + .option('--limit ', 'Max results (default: 10)', '10') + .option('--all', 'Search across all projects (not just current)') + .action(async (query, opts) => { + const workCwd = opts.cwd ?? process.cwd(); + const repoRoot = resolveRepoRoot(opts.repoRoot); + const repoIdentity = resolveRepoIdentity(workCwd); + const limitRaw = parseInt(opts.limit, 10); + const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 10; + + const syncMgr = new SyncManager(repoRoot); + const loadedSessions: LoadedSession[] = []; + + // P1:--all 遍历所有 repo(含 _unattributed)加载会话; + // 非 --all 只加载当前 repo。原实现的循环体是死代码——listRepos() + // 返回编码后的目录名且无解码器,canonical identity 只能从各 repo 的 + // _index.json 反查(见 SyncManager.listAllRepoIdentities)。 + const identitiesToSearch: Array = opts.all + ? syncMgr.listAllRepoIdentities() + : [repoIdentity]; + for (const identity of identitiesToSearch) { + for (const entry of syncMgr.listSessions(identity)) { + try { + const { session } = syncMgr.loadSession(identity, entry.sessionName, entry.author); + loadedSessions.push({ sessionName: entry.sessionName, author: entry.author, session }); + } catch { + // skip corrupted + } + } + } + + const searchEngine = new SessionSearchEngine(); + const results = await searchEngine.search(loadedSessions, query, { limit }); + + if (results.length === 0) { + console.log('No results found.'); + return; + } + + console.log(''); + for (let i = 0; i < results.length; i++) { + const hit = results[i]; + const date = hit.createdAt ? hit.createdAt.slice(0, 10) : 'unknown'; + const snippet = hit.snippet.length > 150 ? hit.snippet.slice(0, 150) + '...' : hit.snippet; + console.log(` [${i + 1}] ${hit.sessionName} (${hit.author}, ${date})`); + console.log(` Score: ${hit.score.toFixed(1)}`); + console.log(` ${snippet}`); + console.log(''); + } + console.log(` ${results.length} result(s) found`); + }); + + // ── session rollback ─────────────────────────────────────── + sessionCmd + .command('rollback') + .description('Rollback a migration (delete the target session)') + .argument('', 'Target session ID to delete') + .requiredOption('--platform ', 'Platform where the session was written') + .option('--cwd ', 'Only roll back the copy under this project path (default: all)') + .action(async (sessionId, opts) => { + // 回滚是破坏性操作(删 CLI 文件 + 删 IDE 侧边栏条目),先看清楚再删。 + if (isDryRun()) { + console.log( + `\n · --dry-run: would delete ${opts.platform}/${sessionId}` + + `${opts.cwd ? ` (only ${opts.cwd})` : ' (all workspaces)'}\n`, + ); + return; + } + + const adapter = safeGetAdapter(opts.platform); + const deleted = await adapter.deleteSession(sessionId, opts.cwd); + // 适配器返回 false 表示确认没删到任何东西(会话不存在)。 + // 之前无论是否存在都打印 ✓,静默 no-op 却报成功,脚本无法判断是否生效。 + if (deleted === false) { + console.log(`\n · Session not found: ${opts.platform}/${sessionId}, no changes (may have already been deleted)\n`); + return; + } + console.log(`\n ✓ Rolled back: ${opts.platform}/${sessionId}\n`); + }); +} diff --git a/src/session-flow/sync.ts b/src/session-flow/sync.ts new file mode 100644 index 00000000..4db05469 --- /dev/null +++ b/src/session-flow/sync.ts @@ -0,0 +1,713 @@ +/** + * sync.ts — 会话团队同步引擎。 + * + * 管理团队仓中完整会话 IR 的存储布局、索引、Git 操作。 + * + * 目录结构(团队仓或 reports branch): + * + * sessions/ + * ├── repos/ ← 按仓库标识隔离(与 AI agent 行为一致) + * │ ├── github.com_org_payment-service/ ← canonical remote(/ → _) + * │ │ ├── _index.json ← 该仓库所有会话的索引 + * │ │ ├── alice/ ← 按成员分子目录 + * │ │ │ ├── claude-code_fix-port_20260910.jsonl + * │ │ │ └── claude-code_fix-port_20260910.meta.json + * │ │ └── bob/ + * │ │ └── ... + * │ └── github.com_org_infra-tools/ + * │ └── ... + * └── _unattributed/ ← 非 git 仓库下的会话(降级) + * └── alice/ + * └── ... + * + * 设计约束: + * - repoIdentity 从 cwd 的 git remote 采集,canonical 化后不可变 + * - 隔离在下行(pull)时按 repoIdentity 过滤,与 AI agent 按 cwd 隔离一致 + * - _index.json 是 per-repo 的,rebuild_index 可从磁盘幂等重建 + * - 与 TeamAI projects.yaml 零耦合(可选增强,不阻塞核心功能) + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import type { Session } from './ir.js'; +import { messageToDict, messageFromDict } from './ir.js'; + +// --------------------------------------------------------------------------- +// 辅助函数 +// --------------------------------------------------------------------------- + +function utcNow(): string { + return new Date().toISOString(); +} + +/** + * 获取当前 git user.name 或 user.email 作为 author 标识。 + * 优先 user.name(更人类友好),fallback 到 user.email,再 fallback 'unknown'。 + */ +export function getGitAuthor(cwd?: string): string { + for (const key of ['user.name', 'user.email']) { + try { + const result = execFileSync('git', ['config', key], { + cwd: cwd ?? process.cwd(), + encoding: 'utf-8', + timeout: 3000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const val = result.trim(); + if (val) return val; + } catch { + // continue + } + } + return 'unknown'; +} + +/** + * 从 cwd 获取 git remote canonical 标识。 + * + * 归一化规则: + * https://github.com/org/repo.git → github.com/org/repo + * git@github.com:org/repo.git → github.com/org/repo + * https://gitlab.company.com/g/repo → gitlab.company.com/g/repo + * + * 非 git 仓库或无 remote 时返回 null。 + */ +export function getRepoIdentity(cwd?: string): string | null { + try { + const raw = execFileSync('git', ['remote', 'get-url', 'origin'], { + cwd: cwd ?? process.cwd(), + encoding: 'utf-8', + timeout: 3000, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + if (!raw) return null; + return canonicalizeRemote(raw); + } catch { + return null; + } +} + +/** + * 归一化 git remote URL → `host/owner/repo`(去协议、去 .git 后缀)。 + */ +export function canonicalizeRemote(remote: string): string { + let s = remote.trim().replace(/\.git$/i, ''); + // https://github.com/org/repo → github.com/org/repo + s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); + // git@github.com:org/repo → github.com/org/repo + s = s.replace(/^git@([^:]+):/i, '$1/'); + // 去前导 / + s = s.replace(/^\/+/, ''); + return s; +} + +/** + * 将 canonical remote 编码为目录安全字符串(/ → _)。 + */ +export function encodeRepoIdentity(identity: string): string { + return identity.replace(/[^a-zA-Z0-9.-]/g, '_'); +} + +/** + * 将标题转为文件名安全的 slug。 + */ +function slugify(title: string, maxLength = 50): string { + const slug = title.toLowerCase().replace(/[^a-zA-Z0-9\u4e00-\u9fff]+/g, '-').replace(/^-+|-+$/g, ''); + return slug.slice(0, maxLength) || 'untitled'; +} + +/** + * 生成 session_name: `{platform}_{title_slug}_{YYYYMMDD}`。 + */ +export function generateSessionName(platform: string, title: string, createdAt: string): string { + const date = createdAt.slice(0, 10).replace(/-/g, ''); + return `${platform}_${slugify(title)}_${date}`; +} + +// --------------------------------------------------------------------------- +// SessionSyncMeta — meta.json 数据模型 +// --------------------------------------------------------------------------- + +export interface SessionSyncMeta { + origin: { + platform: string; + author: string; + cwd: string; + repoIdentity: string | null; + createdAt: string; + sessionId: string; + }; + migration: { + migratedAt: string | null; + sourcePlatform: string | null; + targetPlatform: string | null; + fidelityScore: number; + degradations: string[]; + }; + sync: { + version: number; + pushedAt: string | null; + }; + status: 'active' | 'archived'; +} + +/** + * 构造默认 meta。 + * + * @param partial 基础字段 + * @param createdAt 会话原生创建时间(session.createdAt)。不传时降级为当前时间—— + * 但调用方(push / migrate --push)应始终传入,否则 origin.createdAt 记录的是 + * 推送时间而非会话创建时间,会破坏 search 的时间衰减排序(见设计文档 P7)。 + */ +export function defaultSyncMeta( + partial: { + platform: string; + author: string; + cwd: string; + sessionId: string; + repoIdentity?: string | null; + }, + createdAt?: string, +): SessionSyncMeta { + return { + origin: { + platform: partial.platform, + author: partial.author, + cwd: partial.cwd, + repoIdentity: partial.repoIdentity ?? null, + createdAt: createdAt ?? utcNow(), + sessionId: partial.sessionId, + }, + migration: { + migratedAt: null, + sourcePlatform: null, + targetPlatform: null, + fidelityScore: 1.0, + degradations: [], + }, + sync: { + version: 1, + pushedAt: null, + }, + status: 'active', + }; +} + +// --------------------------------------------------------------------------- +// IndexEntry +// --------------------------------------------------------------------------- + +export interface IndexEntry { + sessionName: string; + author: string; + platform: string; + title: string; + cwd: string; + repoIdentity: string | null; + messageCount: number; + createdAt: string; + updatedAt: string; + status: string; + /** + * 源平台的会话 ID(origin.sessionId)。 + * + * push 去重键(P8)= sessionId + author:重复推送同一会话时更新既有条目, + * 而不是 resolveNameConflict 生成 `xxx_1` 副本。可选项——旧索引/损坏索引 + * 重建前没有该字段,此时去重退化为旧的名冲突行为。 + */ + sessionId?: string; +} + +interface RepoIndex { + version: number; + repoIdentity: string | null; + updatedAt: string; + sessions: IndexEntry[]; +} + +// --------------------------------------------------------------------------- +// SyncManager +// --------------------------------------------------------------------------- + +/** + * 管理团队仓 `sessions/` 目录下的完整会话存储。 + * + * 与 AI agent 行为一致: + * - 同一 git 仓库(remote)的会话聚在一起 + * - 不同仓库的会话天然隔离 + * - pull 时只拉当前 cwd 匹配的 repo 子目录 + */ +export class SyncManager { + private readonly sessionsDir: string; + + constructor(private readonly repoRoot: string) { + this.sessionsDir = path.join(repoRoot, 'sessions'); + } + + // ------------------------------------------------------------------ + // 路径解析 + // ------------------------------------------------------------------ + + /** 获取 repo 子目录路径。null identity → _unattributed */ + private repoDir(repoIdentity: string | null): string { + if (!repoIdentity) { + return path.join(this.sessionsDir, '_unattributed'); + } + return path.join(this.sessionsDir, 'repos', encodeRepoIdentity(repoIdentity)); + } + + private indexPath(repoIdentity: string | null): string { + return path.join(this.repoDir(repoIdentity), '_index.json'); + } + + private authorDir(repoIdentity: string | null, author: string): string { + return path.join(this.repoDir(repoIdentity), author); + } + + private sessionPaths(repoIdentity: string | null, author: string, sessionName: string) { + const dir = this.authorDir(repoIdentity, author); + return { + jsonl: path.join(dir, `${sessionName}.jsonl`), + meta: path.join(dir, `${sessionName}.meta.json`), + }; + } + + // ------------------------------------------------------------------ + // 索引操作 + // ------------------------------------------------------------------ + + private readIndex(repoIdentity: string | null): RepoIndex { + const p = this.indexPath(repoIdentity); + try { + if (fs.existsSync(p)) { + return JSON.parse(fs.readFileSync(p, 'utf-8')) as RepoIndex; + } + } catch { + // 索引损坏(解析失败)时静默重建会让 dedup 键丢失——同一会话再推 + // 会生成 _1 副本而用户毫无感知。至少喊一声,并给出自救命令。 + console.warn(`Warning: corrupted session index at ${p}, treating as empty.`); + console.warn(`Run 'teamai session pull --all --repo-root ' to rebuild indexes.`); + } + return { version: 1, repoIdentity, updatedAt: utcNow(), sessions: [] }; + } + + private writeIndex(repoIdentity: string | null, index: RepoIndex): void { + const dir = this.repoDir(repoIdentity); + fs.mkdirSync(dir, { recursive: true }); + index.updatedAt = utcNow(); + fs.writeFileSync(this.indexPath(repoIdentity), JSON.stringify(index, null, 2), 'utf-8'); + } + + private upsertIndexEntry(repoIdentity: string | null, entry: IndexEntry): void { + const index = this.readIndex(repoIdentity); + const key = `${entry.sessionName}:${entry.author}`; + const idx = index.sessions.findIndex((s) => `${s.sessionName}:${s.author}` === key); + if (idx >= 0) { + index.sessions[idx] = entry; + } else { + index.sessions.push(entry); + } + this.writeIndex(repoIdentity, index); + } + + private removeIndexEntry(repoIdentity: string | null, sessionName: string, author: string): void { + const index = this.readIndex(repoIdentity); + index.sessions = index.sessions.filter( + (s) => !(s.sessionName === sessionName && s.author === author), + ); + this.writeIndex(repoIdentity, index); + } + + // ------------------------------------------------------------------ + // 名字冲突处理 + // ------------------------------------------------------------------ + + private resolveNameConflict(repoIdentity: string | null, author: string, baseName: string): string { + const { jsonl } = this.sessionPaths(repoIdentity, author, baseName); + if (!fs.existsSync(jsonl)) return baseName; + for (let i = 1; ; i++) { + const candidate = `${baseName}_${i}`; + const { jsonl: cJsonl } = this.sessionPaths(repoIdentity, author, candidate); + if (!fs.existsSync(cJsonl)) return candidate; + } + } + + // ------------------------------------------------------------------ + // 保存 / 加载 + // ------------------------------------------------------------------ + + /** + * 按源平台 sessionId(+可选 author)在 repo 索引中查找既有条目。 + * + * push 去重键(P8):origin.sessionId + author。命中说明该会话曾推送过, + * 应更新既有条目与文件,而不是再写一个 `_1` 副本。 + */ + private findByOriginSessionId( + repoIdentity: string | null, + sessionId: string, + author?: string, + platform?: string, + ): IndexEntry | undefined { + const index = this.readIndex(repoIdentity); + return index.sessions.find( + (s) => + s.sessionId === sessionId && + (!author || s.author === author) && + // platform 参与去重键:同一 sessionId 迁移到不同平台是不同的归档物 + // (meta.platform 各自独立),不能互相顶替——否则先 push codebuddy + // 再 migrate --push 到 claude-code 会把前一条归档覆盖掉。 + (!platform || s.platform === platform), + ); + } + + /** + * 保存会话到团队仓。 + * + * @param session IR Session + * @param meta 同步元数据 + * @returns 写入的 jsonl 文件路径(相对于 repoRoot) + */ + saveSession(session: Session, meta: SessionSyncMeta): string { + const repoId = meta.origin.repoIdentity; + const author = meta.origin.author; + + let sessionName = generateSessionName(session.platform, session.title, session.createdAt); + // P8 去重:同一 origin.sessionId + author 重复推送时,复用原 sessionName + // 覆盖写(upsertIndexEntry 按 sessionName:author 命中既有条目原地更新), + // 而不是 resolveNameConflict 生成 `xxx_1` 副本。 + const existing = this.findByOriginSessionId(repoId, meta.origin.sessionId, author, session.platform); + if (existing) { + sessionName = existing.sessionName; + } else { + sessionName = this.resolveNameConflict(repoId, author, sessionName); + } + + const paths = this.sessionPaths(repoId, author, sessionName); + fs.mkdirSync(path.dirname(paths.jsonl), { recursive: true }); + + // 写 JSONL — 每条消息一行 + const lines = session.messages.map((m) => JSON.stringify(messageToDict(m))); + fs.writeFileSync(paths.jsonl, lines.join('\n') + '\n', 'utf-8'); + + // 写 meta.json + meta.sync.pushedAt = utcNow(); + fs.writeFileSync(paths.meta, JSON.stringify(meta, null, 2), 'utf-8'); + + // 更新索引 + this.upsertIndexEntry(repoId, { + sessionName, + author, + platform: session.platform, + title: session.title, + cwd: session.cwd, + repoIdentity: repoId, + messageCount: session.messages.length, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + status: meta.status, + sessionId: meta.origin.sessionId, + }); + + return path.relative(this.repoRoot, paths.jsonl); + } + + /** + * 从团队仓加载会话。 + */ + loadSession( + repoIdentity: string | null, + sessionName: string, + author?: string, + ): { session: Session; meta: SessionSyncMeta } { + const resolvedAuthor = author ?? this.findAuthor(repoIdentity, sessionName); + const paths = this.sessionPaths(repoIdentity, resolvedAuthor, sessionName); + + if (!fs.existsSync(paths.jsonl)) { + throw new Error(`Session file not found: ${paths.jsonl}`); + } + if (!fs.existsSync(paths.meta)) { + throw new Error(`Meta file not found: ${paths.meta}`); + } + + // 读 meta + const meta = JSON.parse(fs.readFileSync(paths.meta, 'utf-8')) as SessionSyncMeta; + + // 读 JSONL → 重建 Session + const content = fs.readFileSync(paths.jsonl, 'utf-8'); + const messages = content + .split('\n') + .filter((l) => l.trim()) + .map((l) => messageFromDict(JSON.parse(l) as Record)); + + // 从 meta + sessionName 提取标题 + const titleSlug = this.extractTitleFromSessionName(sessionName); + + const session: Session = { + sessionId: meta.origin.sessionId, + title: titleSlug, + cwd: meta.origin.cwd, + platform: meta.origin.platform, + createdAt: meta.origin.createdAt, + updatedAt: utcNow(), + messages, + metadata: { originator: meta.migration.sourcePlatform ?? undefined }, + }; + + return { session, meta }; + } + + /** 在 repo 目录下搜索 sessionName 属于哪个 author */ + private findAuthor(repoIdentity: string | null, sessionName: string): string { + const dir = this.repoDir(repoIdentity); + if (!fs.existsSync(dir)) throw new Error(`Repo directory not found: ${dir}`); + for (const entry of fs.readdirSync(dir)) { + if (entry.startsWith('_')) continue; + const candidate = path.join(dir, entry); + if (!fs.statSync(candidate).isDirectory()) continue; + if (fs.existsSync(path.join(candidate, `${sessionName}.jsonl`))) { + return entry; + } + } + throw new Error(`Session ${sessionName} not found (searched all author directories)`); + } + + private extractTitleFromSessionName(sessionName: string): string { + // 格式: {platform}_{title_slug}_{YYYYMMDD} + const parts = sessionName.split('_'); + if (parts.length >= 3) { + // 去掉首段(platform)和末段(date) + return parts.slice(1, -1).join('_').replace(/-/g, ' '); + } + return sessionName; + } + + // ------------------------------------------------------------------ + // 列表 / 删除 + // ------------------------------------------------------------------ + + /** + * 列出指定 repo 下的会话。 + * repoIdentity=null → _unattributed。 + * author 可选过滤。 + */ + listSessions(repoIdentity: string | null, author?: string): IndexEntry[] { + const index = this.readIndex(repoIdentity); + let sessions = index.sessions; + if (author) { + sessions = sessions.filter((s) => s.author === author); + } + return sessions; + } + + /** + * 列出所有 repo 的 repoIdentity。 + */ + listRepos(): string[] { + const reposDir = path.join(this.sessionsDir, 'repos'); + if (!fs.existsSync(reposDir)) return []; + return fs.readdirSync(reposDir).filter((d) => { + return fs.statSync(path.join(reposDir, d)).isDirectory(); + }); + } + + /** + * 列出团队仓中**所有** repo 的 canonical identity(含 `_unattributed` → null)。 + * + * 目录名是编码后的(`/` → `_`)且解码有损,不能反推 canonical 原文—— + * 每个 repo 目录的 `_index.json` 存有 RepoIndex.repoIdentity(canonical 原文), + * 从索引反查。目录损坏 / 无索引 / 无 identity 的目录跳过。 + * + * `_unattributed` 在其 `_index.json` 存在或目录下有会话文件时以 null 一并返回。 + */ + listAllRepoIdentities(): Array { + const identities: Array = []; + const reposDir = path.join(this.sessionsDir, 'repos'); + if (fs.existsSync(reposDir)) { + for (const dir of fs.readdirSync(reposDir)) { + const full = path.join(reposDir, dir); + try { + if (!fs.statSync(full).isDirectory()) continue; + const idxPath = path.join(full, '_index.json'); + if (!fs.existsSync(idxPath)) continue; + const index = JSON.parse(fs.readFileSync(idxPath, 'utf-8')) as RepoIndex; + if (index.repoIdentity) identities.push(index.repoIdentity); + } catch { + // corrupted index / unreadable directory → skip + } + } + } + + // _unattributed:索引存在,或目录下有会话内容(author 子目录)时纳入 + const unattrDir = path.join(this.sessionsDir, '_unattributed'); + if (fs.existsSync(unattrDir)) { + let hasContent = fs.existsSync(path.join(unattrDir, '_index.json')); + if (!hasContent) { + try { + hasContent = fs.readdirSync(unattrDir).some((e) => { + if (e.startsWith('_')) return false; + try { + return fs.statSync(path.join(unattrDir, e)).isDirectory(); + } catch { + return false; + } + }); + } catch { + hasContent = false; + } + } + if (hasContent) identities.push(null); + } + + return identities; + } + + /** + * 跨 repo 列出全部会话(`list --all` / `search --all` 的数据源)。 + * + * 对 listAllRepoIdentities() 的每个 identity 调 listSessions 并合并。 + * 每个条目的 repoIdentity 字段标识来源 repo(null → `_unattributed`), + * 供展示层输出「来源」列;旧索引条目缺该值时用所在 repo 的 identity 回填。 + */ + listSessionsAcrossRepos(author?: string): IndexEntry[] { + const out: IndexEntry[] = []; + for (const identity of this.listAllRepoIdentities()) { + for (const entry of this.listSessions(identity, author)) { + out.push({ ...entry, repoIdentity: entry.repoIdentity ?? identity }); + } + } + return out; + } + + deleteSession(repoIdentity: string | null, sessionName: string, author?: string): void { + const resolvedAuthor = author ?? this.findAuthor(repoIdentity, sessionName); + const paths = this.sessionPaths(repoIdentity, resolvedAuthor, sessionName); + + if (fs.existsSync(paths.jsonl)) fs.unlinkSync(paths.jsonl); + if (fs.existsSync(paths.meta)) fs.unlinkSync(paths.meta); + + this.removeIndexEntry(repoIdentity, sessionName, resolvedAuthor); + } + + // ------------------------------------------------------------------ + // 索引重建 + // ------------------------------------------------------------------ + + /** + * 扫描 repo 目录,幂等重建 _index.json。 + */ + rebuildIndex(repoIdentity: string | null): number { + const dir = this.repoDir(repoIdentity); + if (!fs.existsSync(dir)) return 0; + + const entries: IndexEntry[] = []; + + for (const authorName of fs.readdirSync(dir)) { + if (authorName.startsWith('_')) continue; + const authorDir = path.join(dir, authorName); + if (!fs.statSync(authorDir).isDirectory()) continue; + + for (const file of fs.readdirSync(authorDir)) { + if (!file.endsWith('.meta.json')) continue; + const sessionName = file.replace('.meta.json', ''); + const metaPath = path.join(authorDir, file); + const jsonlPath = path.join(authorDir, `${sessionName}.jsonl`); + + try { + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as SessionSyncMeta; + const msgCount = fs.existsSync(jsonlPath) + ? fs.readFileSync(jsonlPath, 'utf-8').split('\n').filter((l) => l.trim()).length + : 0; + + entries.push({ + sessionName, + author: authorName, + platform: meta.origin.platform, + title: this.extractTitleFromSessionName(sessionName), + cwd: meta.origin.cwd, + repoIdentity: meta.origin.repoIdentity, + messageCount: msgCount, + createdAt: meta.origin.createdAt, + updatedAt: meta.sync.pushedAt ?? meta.origin.createdAt, + status: meta.status, + sessionId: meta.origin.sessionId, + }); + } catch { + // skip corrupted entries + } + } + } + + this.writeIndex(repoIdentity, { + version: 1, + repoIdentity, + updatedAt: utcNow(), + sessions: entries, + }); + + return entries.length; + } + + // ------------------------------------------------------------------ + // Git 操作 + // ------------------------------------------------------------------ + + private runGit(args: string[], check = true): string { + try { + return execFileSync('git', args, { + cwd: this.repoRoot, + encoding: 'utf-8', + timeout: 30_000, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + } catch (e) { + if (check) throw e; + return ''; + } + } + + /** + * git add sessions/ && git commit → 返回 commit hash。 + * + * 无变更时 commit 静默失败,而 `rev-parse HEAD` 仍会返回旧 HEAD——调用方会 + * 误报 "Pushed N"。因此 commit 前先用 `status --porcelain -- sessions/` + * 检测暂存区是否有变更,无变更返回 null,由调用方打印 "No changes to push"。 + */ + gitCommit(message: string): string | null { + this.runGit(['add', 'sessions/']); + const staged = this.runGit(['status', '--porcelain', '--', 'sessions/'], false); + if (!staged.trim()) return null; + this.runGit(['commit', '-m', message], false); + return this.runGit(['rev-parse', 'HEAD']); + } + + gitPush(remote = 'origin', branch?: string): void { + const args = ['push', remote]; + if (branch) args.push(branch); + this.runGit(args); + } + + gitPull(remote = 'origin', branch?: string): void { + const args = ['pull', remote]; + if (branch) args.push(branch); + this.runGit(args); + } + + getSyncStatus(): { uncommitted: number; ahead: number; behind: number } { + const status = this.runGit(['status', '--porcelain'], false); + const uncommitted = status ? status.split('\n').filter((l) => l.trim()).length : 0; + + let ahead = 0; + let behind = 0; + const revResult = this.runGit(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], false); + if (revResult) { + const parts = revResult.split(/\s+/); + if (parts.length === 2) { + ahead = parseInt(parts[0], 10) || 0; + behind = parseInt(parts[1], 10) || 0; + } + } + + return { uncommitted, ahead, behind }; + } +} diff --git a/src/session-flow/title.ts b/src/session-flow/title.ts new file mode 100644 index 00000000..5751db5c --- /dev/null +++ b/src/session-flow/title.ts @@ -0,0 +1,44 @@ +/** + * 会话标题清洗。 + * + * 各平台的第一条「用户消息」往往不是人话:CLI 和 IDE 都会往里塞 + * ``、``、`` 之类的注入块, + * 而标题又只能从首条用户消息里取。直接拿来显示,会话列表里就成了 + * 一整段提示词原文——用户看不到自己问了什么,只看到一堆 XML。 + */ + +/** 标题长度上限:首条用户消息可能是几十 KB 的注入上下文。 */ +const TITLE_MAX = 60; + +/** 整条消息都是平台注入时,开头会出现这些包裹标签。 */ +const INJECTED_HEAD = + /^\s*<(memories|system-reminder|system|additional_data|local-command-caveat|command-name|command-message|command-args|agent_requestable_workspace_rules|agent_requestable_user_rules|project_context|project_guidance|teammate-message|user_query|rules)\b/i; + +const INJECTED_PAIR = /<[a-zA-Z][\w-]*(?:\s[^>]*)?>[\s\S]*?<\/[\w-]+>/g; +const INJECTED_TAG = /<\/?[a-zA-Z][\w-]*(?:\s[^>]*)?\/?>/g; + +/** 文本是否整段由平台注入构成。 */ +export function isInjectedText(text: string): boolean { + return INJECTED_HEAD.test(text); +} + +/** + * 剥离注入标签后的干净标题。 + * + * 剥不干净(仍残留尖括号:半截标签、嵌套注入)时返回空串, + * 让调用方退回 `Session `——宁可难看,也不能把提示词原文当标题。 + */ +export function cleanTitleText(text: string, maxLen = TITLE_MAX): string { + const stripped = text + .replace(INJECTED_PAIR, ' ') + .replace(INJECTED_TAG, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (!stripped || /[<>]/.test(stripped)) return ''; + return stripped.slice(0, maxLen); +} + +/** 拿不到可用标题时的兜底。 */ +export function fallbackTitle(sessionId: string): string { + return `Session ${sessionId.slice(0, 8)}`; +}