From 15db1294a4c61e3875888116b187a4175bd9411a Mon Sep 17 00:00:00 2001 From: lurkacai Date: Mon, 14 Sep 2026 16:17:56 +0800 Subject: [PATCH 01/28] feat(session): cross-platform session migration, IDE sync, and team sync --- src/index.ts | 6 +- src/session-flow/adapters/base.ts | 62 ++ src/session-flow/adapters/claude-code.ts | 701 +++++++++++++++++++++++ src/session-flow/adapters/codebuddy.ts | 600 +++++++++++++++++++ src/session-flow/adapters/codex.ts | 563 ++++++++++++++++++ src/session-flow/adapters/cursor.ts | 397 +++++++++++++ src/session-flow/adapters/index.ts | 68 +++ src/session-flow/adapters/workbuddy.ts | 607 ++++++++++++++++++++ src/session-flow/fs.ts | 201 +++++++ src/session-flow/ide-history.ts | 641 +++++++++++++++++++++ src/session-flow/index.ts | 14 + src/session-flow/ir.ts | 175 ++++++ src/session-flow/migrate.ts | 323 +++++++++++ src/session-flow/search.ts | 269 +++++++++ src/session-flow/session-cmd.ts | 578 +++++++++++++++++++ src/session-flow/sync.ts | 581 +++++++++++++++++++ 16 files changed, 5785 insertions(+), 1 deletion(-) create mode 100644 src/session-flow/adapters/base.ts create mode 100644 src/session-flow/adapters/claude-code.ts create mode 100644 src/session-flow/adapters/codebuddy.ts create mode 100644 src/session-flow/adapters/codex.ts create mode 100644 src/session-flow/adapters/cursor.ts create mode 100644 src/session-flow/adapters/index.ts create mode 100644 src/session-flow/adapters/workbuddy.ts create mode 100644 src/session-flow/fs.ts create mode 100644 src/session-flow/ide-history.ts create mode 100644 src/session-flow/index.ts create mode 100644 src/session-flow/ir.ts create mode 100644 src/session-flow/migrate.ts create mode 100644 src/session-flow/search.ts create mode 100644 src/session-flow/session-cmd.ts create mode 100644 src/session-flow/sync.ts diff --git a/src/index.ts b/src/index.ts index c3d07a564..b73fc9ebd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -910,7 +910,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') @@ -926,6 +926,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 000000000..4335191df --- /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 000000000..a2faf838c --- /dev/null +++ b/src/session-flow/adapters/claude-code.ts @@ -0,0 +1,701 @@ +/** + * 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'; + +// --------------------------------------------------------------------------- +// 工具名归一化映射 +// --------------------------------------------------------------------------- + +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') { + firstUserText = content; + } else if (Array.isArray(content)) { + for (const block of content) { + if (block && typeof block === 'object' && (block as Record).type === 'text') { + firstUserText = String((block as Record).text ?? ''); + break; + } + } + } + } + } + } + } catch { + return null; + } + + if (!createdAt) createdAt = new Date().toISOString(); + if (!updatedAt) updatedAt = createdAt; + + title = firstUserText ? firstUserText.slice(0, 50) : `Session ${sessionId.slice(0, 8)}`; + + 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_id=${sessionId}, project_path=${projectPath ?? 'undefined'}`); + } + + const cwd = decodeCwdClaude(path.basename(path.dirname(jsonlPath))); + + // 收集所有消息记录 + const rawRecords: Record[] = []; + for (const record of readJsonl(jsonlPath)) { + const rtype = record.type as string; + if (SKIP_TYPES.has(rtype)) continue; + if (rtype !== 'user' && rtype !== 'assistant') continue; + rawRecords.push(record); + } + + // DAG 拍平 + const messages = this.flattenDag(rawRecords); + + // 提取标题 + let title = ''; + for (const msg of messages) { + if (msg.role === 'user') { + for (const block of msg.content) { + if (block.type === 'text' && block.text) { + title = block.text.slice(0, 50); + break; + } + } + if (title) break; + } + } + if (!title) title = `Session ${sessionId.slice(0, 8)}`; + + // 时间戳 + 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, + 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, + }); + + 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.ts b/src/session-flow/adapters/codebuddy.ts new file mode 100644 index 000000000..5da031474 --- /dev/null +++ b/src/session-flow/adapters/codebuddy.ts @@ -0,0 +1,600 @@ +/** + * adapters/codebuddy.ts — CodeBuddy 平台适配器。 + * + * 读取/写入 `~/.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 { writeIdeSession, deleteIdeSession } from '../ide-history.js'; +import { + getCodeBuddyProjectsDir, + encodeCwdGeneric, + decodeCwdGeneric, + readJsonl, + readJsonlHead, + writeJsonl, + fileExists, + dirExists, + removeDirRecursive, +} from '../fs.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, 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 = 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') { + firstUserText = String((block as Record).text ?? ''); + break; + } + } + } + } + } + } + } catch { + return null; + } + + if (!createdAt) createdAt = new Date().toISOString(); + if (!updatedAt) updatedAt = createdAt; + + title = aiTitle || (firstUserText ? firstUserText.slice(0, 50) : `Session ${sessionId.slice(0, 8)}`); + + 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_id=${sessionId}`); + } + + const cwd = decodeCwdGeneric(path.basename(path.dirname(jsonlPath))); + const records = [...readJsonl(jsonlPath)]; + + 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') { + title = String(rec.aiTitle ?? ''); + 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) { + title = block.text.slice(0, 50); + 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 (!isUuid(sessionId)) { + sessionId = uuidV4(); + } + + const cwd = projectPath ?? session.cwd; + const projDir = path.join(getCodeBuddyProjectsDir(), 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(); + + // 分离 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 侧边栏「历史对话」。 + // CLI 路径(~/.codebuddy/projects/...)与 IDE 的 history 是两套独立存储, + // 只写前者的话 IDE 侧边栏看不到。此为增强步骤,失败静默降级。 + try { + const ideResult = writeIdeSession({ ...session, sessionId }, cwd); + if (ideResult.synced > 0) { + console.log(` ✓ IDE 侧边栏已同步(${ideResult.messageCount} 条消息)`); + } else if (ideResult.skipped && process.env.TEAMAI_DEBUG) { + console.log(` · IDE 侧边栏未同步:${ideResult.skipped}`); + } + } catch { + // IDE 同步失败不影响 CLI 路径的迁移结果 + } + + return sessionId; + } + + async deleteSession(sessionId: string, projectPath?: string): Promise { + const jsonlPaths = this.findAllSessionFiles(sessionId, projectPath); + const cwd = + projectPath ?? + (jsonlPaths[0] ? decodeCwdGeneric(path.basename(path.dirname(jsonlPaths[0]))) : undefined); + + 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); + } + } + + // 同步清理 IDE 侧边栏里的对应会话。 + // 不能包在 if (cwd) 里——jsonl 缺失时 cwd 为 undefined, + // 会导致 IDE 侧会话永久残留且再也清不掉(此后也无法再用 rollback 清理)。 + // 反过来,cwd 存在时 deleteIdeSession 只清理该工作区,避免误删别的项目里的同名副本。 + let ideCleaned = 0; + try { + ideCleaned = deleteIdeSession(sessionId, cwd); + } catch { + // ignore + } + + return cliDeleted || ideCleaned > 0; + } +} diff --git a/src/session-flow/adapters/codex.ts b/src/session-flow/adapters/codex.ts new file mode 100644 index 000000000..bc79dc29d --- /dev/null +++ b/src/session-flow/adapters/codex.ts @@ -0,0 +1,563 @@ +/** + * 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, + 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()) { + if (path.basename(f).includes(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) { + if (cwd !== 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 会话: ${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 }); + } 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] }); + } + } 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] }); + } + } 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] }); + } + } + } + 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(); + } + + const createdAt = new Date(session.createdAt); + 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; + + for (const msg of session.messages) { + // 每个 user 消息开始一个新 turn + if (msg.role === 'user') { + // 如果上一个 turn 已开始,先完成它 + if (turnStarted) { + records.push({ + timestamp: new Date().toISOString(), + type: 'event_msg', + payload: { + type: 'task_complete', + turn_id: turnId, + completed_at: Math.floor(Date.now() / 1000), + }, + }); + } + // 新 turn + turnId = generateUuidV7(); + records.push({ + timestamp: new Date().toISOString(), + type: 'event_msg', + payload: { + type: 'task_started', + turn_id: turnId, + started_at: Math.floor(Date.now() / 1000), + }, + }); + records.push({ + timestamp: new Date().toISOString(), + 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); + if (rec) records.push(rec); + } + } + + // 最后一个 turn 的 task_complete + if (turnStarted) { + records.push({ + timestamp: new Date().toISOString(), + type: 'event_msg', + payload: { + type: 'task_complete', + turn_id: turnId, + completed_at: Math.floor(Date.now() / 1000), + }, + }); + } + + writeJsonl(filePath, records); + return sessionId; + } + + private blockToResponseItem(role: string, block: ContentBlock): Record | null { + const timestamp = new Date().toISOString(); + + switch (block.type) { + case 'text': { + const contentType = role === 'user' ? 'input_text' : 'output_text'; + return { + timestamp, + type: 'response_item', + payload: { + type: 'message', + role, + content: [{ type: contentType, text: block.text }], + }, + }; + } + case 'tool_call': { + const codexName = denormalizeToolName(block.toolName); + return { + timestamp, + type: 'response_item', + payload: { + type: 'function_call', + name: codexName, + arguments: JSON.stringify(block.arguments), + call_id: block.callId, + }, + }; + } + case 'tool_result': { + return { + timestamp, + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: block.callId, + output: block.content, + }, + }; + } + case 'thinking': { + // Codex 支持 reasoning,写入为 reasoning response_item + return { + timestamp, + 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 000000000..2bd39fc57 --- /dev/null +++ b/src/session-flow/adapters/cursor.ts @@ -0,0 +1,397 @@ +/** + * 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'; + +// --------------------------------------------------------------------------- +// 工具名归一化映射 +// --------------------------------------------------------------------------- + +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 工具 +// --------------------------------------------------------------------------- + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{3}-[89ab][0-9a-f]{3}-[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') { + firstUserText = String((block as Record).text ?? ''); + break; + } + } + } + } + } + } catch { + return null; + } + + title = firstUserText ? firstUserText.slice(0, 50) : `Session ${sessionId.slice(0, 8)}`; + + 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_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)]; + + 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) { + title = block.text.slice(0, 50); + 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, + 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, + 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 000000000..96f286e16 --- /dev/null +++ b/src/session-flow/adapters/index.ts @@ -0,0 +1,68 @@ +/** + * 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 { 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: () => new CodeBuddyAdapter(), + 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 { 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 000000000..7ed7f4cc9 --- /dev/null +++ b/src/session-flow/adapters/workbuddy.ts @@ -0,0 +1,607 @@ +/** + * 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'; + +// --------------------------------------------------------------------------- +// 工具名归一化映射 +// --------------------------------------------------------------------------- + +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') { + firstUserText = String((block as Record).text ?? ''); + break; + } + } + } + } + } + } + } catch { + return null; + } + + if (!createdAt) createdAt = new Date().toISOString(); + if (!updatedAt) updatedAt = createdAt; + + title = aiTitle || (firstUserText ? firstUserText.slice(0, 50) : `Session ${sessionId.slice(0, 8)}`); + + 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_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') { + title = String(rec.aiTitle ?? ''); + 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) { + title = block.text.slice(0, 50); + 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/fs.ts b/src/session-flow/fs.ts new file mode 100644 index 000000000..79147b321 --- /dev/null +++ b/src/session-flow/fs.ts @@ -0,0 +1,201 @@ +/** + * 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 编码/解码 +// --------------------------------------------------------------------------- + +/** + * Claude Code 的 cwd 编码: 所有非字母数字字符 → `-`,有前导 `-`。 + * 例: `/home/user/project` → `-home-user-project` + * `/Users/foo/my project` → `-Users-foo-my-project` + */ +export function encodeCwdClaude(cwd: string): string { + return 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 cwd.replace(/[^a-zA-Z0-9]/g, '-').replace(/^-+/, ''); +} + +/** + * 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 000000000..704dbed56 --- /dev/null +++ b/src/session-flow/ide-history.ts @@ -0,0 +1,641 @@ +/** + * 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 条目。 */ +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; + /** 未同步时的原因(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 { + if (!cwd || !cwd.startsWith('/')) return null; + const normalized = path.resolve(cwd).replace(/\/+$/, ''); + return crypto.createHash('md5').update(normalized).digest('hex'); +} + +/** + * conversation id:IDE 用 32 位 hex(无横线)。 + * UUID v4 去横线即可;非 UUID 则回退为 md5(sessionId)。 + */ +function toIdeConvId(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 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 实例下该 cwd 对应的 history 目录。 + * + * 通常只有 1 个(单用户单实例)。多实例时全部返回,逐个写入。 + * createIfMissing=true 时,若该项目的 history 目录尚不存在(IDE 没打开过这个项目), + * 仍返回待创建路径,使其下次打开即可见。 + */ +export function findIdeHistoryDirs(cwd: string, createIfMissing = false): string[] { + const base = getUserDataBase(); + if (!base || !fs.existsSync(base)) return []; + + const hash = hashWorkspace(cwd); + if (!hash) return []; // cwd 不是真实绝对路径,放弃同步 + + const out: string[] = []; + try { + for (const extId of fs.readdirSync(base)) { + const ideRoot = path.join(base, extId, 'CodeBuddyIDE'); + if (!fs.existsSync(ideRoot)) continue; + for (const instId of fs.readdirSync(ideRoot)) { + const historyRoot = path.join(ideRoot, instId, 'history'); + if (!fs.existsSync(historyRoot)) continue; + const target = path.join(historyRoot, hash); + if (fs.existsSync(target) || createIfMissing) out.push(target); + } + } + } catch { + return []; + } + + 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 存储未找到(未安装或未初始化),仅写入 CLI 路径', + }; + } + + 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 ? { skipped: 'IDE history 目录写入失败' } : {}), + }; +} + +/** + * 遍历所有 IDE 实例的 history,按 convId 定位会话目录。 + * + * rollback 时拿到的 cwd 往往是 encoded 的目录名(decodeCwdGeneric 是恒等函数, + * 无法还原真实路径),算不出 workspace hash。而 convId 是全局唯一的, + * 故直接全局搜索,不依赖 cwd。 + */ +function findIdeConversationDirs(convId: string): Array<{ historyDir: string; convDir: string }> { + const base = getUserDataBase(); + if (!base || !fs.existsSync(base)) return []; + + const out: Array<{ historyDir: string; convDir: string }> = []; + try { + for (const extId of fs.readdirSync(base)) { + const ideRoot = path.join(base, extId, 'CodeBuddyIDE'); + if (!fs.existsSync(ideRoot)) continue; + for (const instId of fs.readdirSync(ideRoot)) { + const historyRoot = path.join(ideRoot, instId, 'history'); + if (!fs.existsSync(historyRoot)) continue; + for (const wsHash of fs.readdirSync(historyRoot)) { + const historyDir = path.join(historyRoot, wsHash); + const convDir = path.join(historyDir, convId); + if (fs.existsSync(convDir)) out.push({ historyDir, convDir }); + } + } + } + } catch { + return []; + } + 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; +} diff --git a/src/session-flow/index.ts b/src/session-flow/index.ts new file mode 100644 index 000000000..8007c3220 --- /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 000000000..b31e91bc6 --- /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 000000000..20f451927 --- /dev/null +++ b/src/session-flow/migrate.ts @@ -0,0 +1,323 @@ +/** + * 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, + 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', + ]), + 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 000000000..8a96424c1 --- /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 000000000..6ceae57ee --- /dev/null +++ b/src/session-flow/session-cmd.ts @@ -0,0 +1,578 @@ +/** + * 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 { 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; + } + lineResolver = resolve; + 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()); +} + +/** + * 获取团队仓根目录。 + * 优先用 --repo-root;否则用 cwd(假设 cwd 就是团队仓 clone)。 + */ +function resolveRepoRoot(repoRoot?: string): string { + return repoRoot ?? process.cwd(); +} + +// --------------------------------------------------------------------------- +// 命令注册 +// --------------------------------------------------------------------------- + +/** + * 在 `teamai session` 子命令对象上注册 SessionFlow 的 7 个子命令。 + */ +export function registerSessionFlowCommands(sessionCmd: Command): void { + // --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; + + 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:仅预览,未迁移 ${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++; + } else { + console.error(`\n ✗ Migration failed: ${result.error}`); + } + } + + // --push: 推送到团队仓 + if (opts.push && migrated > 0) { + const repoRoot = resolveRepoRoot(opts.repoRoot); + const repoIdentity = resolveRepoIdentity(workCwd); + const author = getGitAuthor(workCwd); + const targetAdapter = safeGetAdapter(target); + const targetMetas = await targetAdapter.listConversations(opts.targetCwd ?? workCwd); + const recent = targetMetas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, migrated); + + const syncMgr = new SyncManager(repoRoot); + let saved = 0; + for (const m of recent) { + const session = await targetAdapter.readSession(m.sessionId, opts.targetCwd ?? workCwd); + const meta = defaultSyncMeta({ + platform: target, + author, + cwd: opts.targetCwd ?? workCwd, + sessionId: m.sessionId, + repoIdentity, + }); + meta.migration.migratedAt = new Date().toISOString(); + meta.migration.sourcePlatform = source; + meta.migration.targetPlatform = target; + meta.migration.fidelityScore = 1.0; + syncMgr.saveSession(session, meta); + saved++; + } + const commitHash = syncMgr.gitCommit(`sync: migrate ${saved} session(s) ${source}→${target}`); + syncMgr.gitPush(); + console.log(`\n ✓ Pushed ${saved} session(s) to team repo`); + console.log(` commit: ${commitHash.slice(0, 8)}`); + } + + console.log( + isDryRun() + ? `\n ${targets.length} session(s) would be migrated (--dry-run, no changes made).\n` + : `\n ${migrated} session(s) migrated.\n`, + ); + } 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)', '5') + .action(async (opts) => { + const source = opts.source; + if (!source) { + console.error('Error: --source required.'); + console.error('Usage: teamai session push --source [--repo-root ]'); + process.exit(1); + } + const workCwd = opts.cwd ?? process.cwd(); + const repoRoot = resolveRepoRoot(opts.repoRoot); + const repoIdentity = resolveRepoIdentity(workCwd); + const author = getGitAuthor(workCwd); + const adapter = safeGetAdapter(source); + const metas = await adapter.listConversations(workCwd); + const limit = parseInt(opts.limit, 10) || 5; + const sorted = metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit); + + if (sorted.length === 0) { + console.log('No sessions found to push.'); + return; + } + + const syncMgr = new SyncManager(repoRoot); + let saved = 0; + for (const m of sorted) { + const session = await adapter.readSession(m.sessionId, workCwd); + const meta = defaultSyncMeta({ + platform: source, + author, + cwd: workCwd, + sessionId: m.sessionId, + repoIdentity, + }); + syncMgr.saveSession(session, meta); + saved++; + } + const commitHash = syncMgr.gitCommit(`sync: push ${saved} session(s) from ${source}`); + syncMgr.gitPush(); + console.log(`\n ✓ Pushed ${saved} session(s) from ${source}`); + console.log(` commit: ${commitHash.slice(0, 8)}\n`); + }); + + // ── 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)') + .action(async (opts) => { + const workCwd = opts.cwd ?? process.cwd(); + const repoRoot = resolveRepoRoot(opts.repoRoot); + const repoIdentity = resolveRepoIdentity(workCwd); + + const syncMgr = new SyncManager(repoRoot); + syncMgr.gitPull(); + 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') + .action(async (opts) => { + const workCwd = opts.cwd ?? process.cwd(); + const repoRoot = resolveRepoRoot(opts.repoRoot); + const repoIdentity = resolveRepoIdentity(workCwd); + + const syncMgr = new SyncManager(repoRoot); + const sessions = syncMgr.listSessions(repoIdentity, opts.author); + + if (sessions.length === 0) { + console.log('No team sessions found.'); + return; + } + + 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); + const { session } = syncMgr.loadSession(repoIdentity, sessionName, opts.author); + + 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 limit = parseInt(opts.limit, 10) || 10; + + const syncMgr = new SyncManager(repoRoot); + const loadedSessions: LoadedSession[] = []; + + if (opts.all) { + // 遍历所有 repo + const repos = syncMgr.listRepos(); + for (const repo of repos) { + const entries = syncMgr.listSessions(null); // _unattributed + // listRepos 返回的是编码后的目录名,需要用 null 遍历 _unattributed + } + // 简化:直接遍历 sessions/repos/ 下所有子目录 + const allRepos = syncMgr.listRepos(); + for (const _repo of allRepos) { + // listSessions 需要 repoIdentity(canonical),但 listRepos 返回的是编码名 + // 这里用一个简化方案:遍历 _index.json + } + // _unattributed + const unattributed = syncMgr.listSessions(null); + for (const entry of unattributed) { + try { + const { session } = syncMgr.loadSession(null, entry.sessionName, entry.author); + loadedSessions.push({ sessionName: entry.sessionName, author: entry.author, session }); + } catch { + // skip corrupted + } + } + } + + // 当前 repo + const entries = syncMgr.listSessions(repoIdentity); + for (const entry of entries) { + try { + const { session } = syncMgr.loadSession(repoIdentity, 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:将删除 ${opts.platform}/${sessionId}` + + `${opts.cwd ? ` (仅 ${opts.cwd})` : ' (所有工作区)'}\n`, + ); + return; + } + + const adapter = safeGetAdapter(opts.platform); + const deleted = await adapter.deleteSession(sessionId, opts.cwd); + // 适配器返回 false 表示确认没删到任何东西(会话不存在)。 + // 之前无论是否存在都打印 ✓,静默 no-op 却报成功,脚本无法判断是否生效。 + if (deleted === false) { + console.log(`\n · 未找到会话 ${opts.platform}/${sessionId},无变更(可能已被删除)\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 000000000..4d6a14d2d --- /dev/null +++ b/src/session-flow/sync.ts @@ -0,0 +1,581 @@ +/** + * 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'; +} + +export function defaultSyncMeta(partial: { + platform: string; + author: string; + cwd: string; + sessionId: string; + repoIdentity?: string | null; +}): SessionSyncMeta { + return { + origin: { + platform: partial.platform, + author: partial.author, + cwd: partial.cwd, + repoIdentity: partial.repoIdentity ?? null, + 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; +} + +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 { + // corrupted index → rebuild + } + 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; + } + } + + // ------------------------------------------------------------------ + // 保存 / 加载 + // ------------------------------------------------------------------ + + /** + * 保存会话到团队仓。 + * + * @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); + 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, + }); + + 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(`会话文件不存在: ${paths.jsonl}`); + } + if (!fs.existsSync(paths.meta)) { + throw new Error(`元数据文件不存在: ${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(`仓库目录不存在: ${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(`会话 ${sessionName} 未找到(已搜索所有 author 目录)`); + } + + 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(); + }); + } + + 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, + }); + } 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 */ + gitCommit(message: string): string { + this.runGit(['add', 'sessions/']); + 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 }; + } +} From f7948269d08e93bb5d124c639c99fb56d2682a11 Mon Sep 17 00:00:00 2001 From: lurkacai Date: Wed, 16 Sep 2026 00:03:52 +0800 Subject: [PATCH 02/28] docs(session): design for user-repo session sync (M1-M3) --- docs/designs/session-user-repo-sync.md | 118 +++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/designs/session-user-repo-sync.md diff --git a/docs/designs/session-user-repo-sync.md b/docs/designs/session-user-repo-sync.md new file mode 100644 index 000000000..41e1f6cfd --- /dev/null +++ b/docs/designs/session-user-repo-sync.md @@ -0,0 +1,118 @@ +# 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) + +## 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 From 09767656122cab8115e33ab69f43698ed11b3b7b Mon Sep 17 00:00:00 2001 From: lurkacai Date: Wed, 16 Sep 2026 10:35:37 +0800 Subject: [PATCH 03/28] feat(session): cross-project consumption, native archive key, and correctness fixes M1 correctness: - migrate --push re-reads exactly the migrated target session ids instead of 'the N most recent', which could push unrelated sessions - meta no longer lies: fidelityScore uses the real preview score and createdAt records the session's own creation time, not push time - resume failures hint that the session may be archived under another project identity (search --all / --cwd) - empty pushes no longer report success when nothing was committed - 14 Chinese user-facing strings converted to English M2 user-repo capability: - SyncManager: listAllRepoIdentities() reverse-maps canonical identities from each repo's _index.json; listSessionsAcrossRepos() merges entries - list --all / pull --all / search --all give the cross-project view (search --all previously had a dead loop and never searched other repos) - push --all archives every workspace of one platform (--source), confirming before pushing more than 5 sessions (-y skips) - archive key derives from the session's native cwd instead of the directory the command ran in; unknowable workspaces (codebuddy-ide md5 placeholders) archive under _unattributed with a warning - claude-code / codebuddy / cursor readSession recover the native cwd from the first JSONL record instead of lossy directory-name decoding - pushing the same session twice updates the entry instead of creating _1 duplicates (dedup key: origin sessionId + author) --- src/session-flow/adapters/claude-code.ts | 12 +- src/session-flow/adapters/codebuddy.ts | 95 ++++---- src/session-flow/adapters/codex.ts | 2 +- src/session-flow/adapters/cursor.ts | 17 +- src/session-flow/adapters/index.ts | 6 + src/session-flow/adapters/workbuddy.ts | 2 +- src/session-flow/codebuddy.ts | 4 + src/session-flow/fs.ts | 16 ++ src/session-flow/ide-history.ts | 274 ++++++++++++++++++++--- src/session-flow/migrate.ts | 9 + src/session-flow/session-cmd.ts | 263 +++++++++++++++------- src/session-flow/sync.ts | 152 +++++++++++-- 12 files changed, 674 insertions(+), 178 deletions(-) create mode 100644 src/session-flow/codebuddy.ts diff --git a/src/session-flow/adapters/claude-code.ts b/src/session-flow/adapters/claude-code.ts index a2faf838c..37f57f9b9 100644 --- a/src/session-flow/adapters/claude-code.ts +++ b/src/session-flow/adapters/claude-code.ts @@ -376,19 +376,27 @@ export class ClaudeCodeAdapter extends AgentAdapter { async readSession(sessionId: string, projectPath?: string): Promise { const jsonlPath = this.findSessionFile(sessionId, projectPath); if (!jsonlPath) { - throw new Error(`Claude Code 会话文件未找到: session_id=${sessionId}, project_path=${projectPath ?? 'undefined'}`); + 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; for (const record of readJsonl(jsonlPath)) { const rtype = record.type as string; 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); @@ -437,7 +445,7 @@ export class ClaudeCodeAdapter extends AgentAdapter { return { sessionId, title, - cwd, + cwd: sessionCwd, platform: this.platform, createdAt, updatedAt, diff --git a/src/session-flow/adapters/codebuddy.ts b/src/session-flow/adapters/codebuddy.ts index 5da031474..1de10e343 100644 --- a/src/session-flow/adapters/codebuddy.ts +++ b/src/session-flow/adapters/codebuddy.ts @@ -1,5 +1,9 @@ /** - * adapters/codebuddy.ts — CodeBuddy 平台适配器。 + * adapters/codebuddy.ts — CodeBuddy **CLI** 平台适配器。 + * + * 只覆盖 CLI 存储;CodeBuddy **IDE**(图形化侧边栏「历史对话」)是另一套 + * 独立存储,由 `adapters/codebuddy-ide.ts` 负责。两者会话互不通用, + * 本适配器不再读写 IDE 侧。 * * 读取/写入 `~/.codebuddy/projects//.jsonl` 格式。 * cwd 编码: `/` → `-`,无前导 `-`。 @@ -22,10 +26,9 @@ 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 { writeIdeSession, deleteIdeSession } from '../ide-history.js'; import { getCodeBuddyProjectsDir, - encodeCwdGeneric, + encodeCwdCodeBuddy, decodeCwdGeneric, readJsonl, readJsonlHead, @@ -34,6 +37,7 @@ import { dirExists, removeDirRecursive, } from '../fs.js'; +import { cleanTitleText, isInjectedText, fallbackTitle } from '../title.js'; // --------------------------------------------------------------------------- // 工具名归一化映射 @@ -111,7 +115,7 @@ export class CodeBuddyAdapter extends AgentAdapter { private resolveProjectDir(projectPath?: string): string { const root = getCodeBuddyProjectsDir(); if (projectPath) { - return path.join(root, encodeCwdGeneric(projectPath)); + return path.join(root, encodeCwdCodeBuddy(projectPath)); } return root; } @@ -205,8 +209,12 @@ export class CodeBuddyAdapter extends AgentAdapter { if (Array.isArray(content)) { for (const block of content) { if (block && typeof block === 'object' && (block as Record).type === 'input_text') { - firstUserText = String((block as Record).text ?? ''); - break; + const text = String((block as Record).text ?? ''); + // 首个文本块常是 system-reminder 等注入,跳过继续找真正的提问 + if (!isInjectedText(text)) { + firstUserText = text; + break; + } } } } @@ -220,7 +228,14 @@ export class CodeBuddyAdapter extends AgentAdapter { if (!createdAt) createdAt = new Date().toISOString(); if (!updatedAt) updatedAt = createdAt; - title = aiTitle || (firstUserText ? firstUserText.slice(0, 50) : `Session ${sessionId.slice(0, 8)}`); + // ai-title 是 CodeBuddy 自己起的标题,最可靠;否则退回首条用户文本。 + // 首条「用户消息」常常是 system-reminder 等注入块,不清洗的话 + // 会话列表里显示的就是一整段提示词原文。 + if (aiTitle && !isInjectedText(aiTitle)) { + title = aiTitle.slice(0, 60); + } else { + title = cleanTitleText(firstUserText) || fallbackTitle(sessionId); + } let sizeBytes = 0; try { @@ -245,12 +260,24 @@ export class CodeBuddyAdapter extends AgentAdapter { async readSession(sessionId: string, projectPath?: string): Promise { const jsonlPath = this.findSessionFile(sessionId, projectPath); if (!jsonlPath) { - throw new Error(`CodeBuddy 会话文件未找到: session_id=${sessionId}`); + 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; @@ -262,7 +289,10 @@ export class CodeBuddyAdapter extends AgentAdapter { const rtype = rec.type as string; if (rtype === 'ai-title') { - title = String(rec.aiTitle ?? ''); + // CodeBuddy 偶尔把注入块原文存成 ai-title,照收会污染整个迁移链路 + // (预览、目标侧标题全是提示词原文)。 + const t = String(rec.aiTitle ?? ''); + if (t && !isInjectedText(t)) title = t.slice(0, 100); continue; } @@ -360,15 +390,16 @@ export class CodeBuddyAdapter extends AgentAdapter { if (msg.role === 'user') { for (const block of msg.content) { if (block.type === 'text' && block.text) { - title = block.text.slice(0, 50); - break; + if (isInjectedText(block.text)) continue; // 注入块不当标题 + title = cleanTitleText(block.text); + if (title) break; } } if (title) break; } } } - if (!title) title = `Session ${sessionId.slice(0, 8)}`; + if (!title) title = fallbackTitle(sessionId); if (!createdAt) createdAt = new Date().toISOString(); if (!updatedAt) updatedAt = createdAt; @@ -376,7 +407,7 @@ export class CodeBuddyAdapter extends AgentAdapter { return { sessionId, title, - cwd, + cwd: sessionCwd, platform: this.platform, createdAt, updatedAt, @@ -416,7 +447,7 @@ export class CodeBuddyAdapter extends AgentAdapter { } const cwd = projectPath ?? session.cwd; - const projDir = path.join(getCodeBuddyProjectsDir(), encodeCwdGeneric(cwd)); + const projDir = path.join(getCodeBuddyProjectsDir(), encodeCwdCodeBuddy(cwd)); const jsonlPath = path.join(projDir, `${sessionId}.jsonl`); const records: Record[] = []; @@ -545,28 +576,17 @@ export class CodeBuddyAdapter extends AgentAdapter { writeJsonl(jsonlPath, records); - // 同步进 CodeBuddy IDE 侧边栏「历史对话」。 - // CLI 路径(~/.codebuddy/projects/...)与 IDE 的 history 是两套独立存储, - // 只写前者的话 IDE 侧边栏看不到。此为增强步骤,失败静默降级。 - try { - const ideResult = writeIdeSession({ ...session, sessionId }, cwd); - if (ideResult.synced > 0) { - console.log(` ✓ IDE 侧边栏已同步(${ideResult.messageCount} 条消息)`); - } else if (ideResult.skipped && process.env.TEAMAI_DEBUG) { - console.log(` · IDE 侧边栏未同步:${ideResult.skipped}`); - } - } catch { - // IDE 同步失败不影响 CLI 路径的迁移结果 - } + // 这里**不再**顺带写 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); - const cwd = - projectPath ?? - (jsonlPaths[0] ? decodeCwdGeneric(path.basename(path.dirname(jsonlPaths[0]))) : undefined); let cliDeleted = false; for (const jsonlPath of jsonlPaths) { @@ -584,17 +604,8 @@ export class CodeBuddyAdapter extends AgentAdapter { } } - // 同步清理 IDE 侧边栏里的对应会话。 - // 不能包在 if (cwd) 里——jsonl 缺失时 cwd 为 undefined, - // 会导致 IDE 侧会话永久残留且再也清不掉(此后也无法再用 rollback 清理)。 - // 反过来,cwd 存在时 deleteIdeSession 只清理该工作区,避免误删别的项目里的同名副本。 - let ideCleaned = 0; - try { - ideCleaned = deleteIdeSession(sessionId, cwd); - } catch { - // ignore - } - - return cliDeleted || ideCleaned > 0; + // 只清理 CLI 侧。IDE 侧会话由 `codebuddy-ide` 平台负责, + // 本适配器不再越界删除自己从未写入过的存储。 + return cliDeleted; } } diff --git a/src/session-flow/adapters/codex.ts b/src/session-flow/adapters/codex.ts index bc79dc29d..d6663fce8 100644 --- a/src/session-flow/adapters/codex.ts +++ b/src/session-flow/adapters/codex.ts @@ -237,7 +237,7 @@ export class CodexAdapter extends AgentAdapter { async readSession(sessionId: string, projectPath?: string): Promise { const f = this.findSessionFile(sessionId); - if (!f) throw new Error(`未找到 Codex 会话: ${sessionId}`); + if (!f) throw new Error(`Codex session not found: ${sessionId}`); const records = [...readJsonl(f)]; diff --git a/src/session-flow/adapters/cursor.ts b/src/session-flow/adapters/cursor.ts index 2bd39fc57..a96fb0e87 100644 --- a/src/session-flow/adapters/cursor.ts +++ b/src/session-flow/adapters/cursor.ts @@ -226,7 +226,7 @@ export class CursorAdapter extends AgentAdapter { async readSession(sessionId: string, projectPath?: string): Promise { const jsonlPath = this.findSessionFile(sessionId, projectPath); if (!jsonlPath) { - throw new Error(`Cursor 会话文件未找到: session_id=${sessionId}`); + throw new Error(`Cursor session file not found: session_id=${sessionId}`); } // 目录层级是 /agent-transcripts//.jsonl, @@ -237,6 +237,17 @@ export class CursorAdapter extends AgentAdapter { ); 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) { @@ -286,7 +297,7 @@ export class CursorAdapter extends AgentAdapter { return { sessionId, title, - cwd, + cwd: sessionCwd, platform: this.platform, createdAt, updatedAt, @@ -370,6 +381,8 @@ export class CursorAdapter extends AgentAdapter { if (cursorContent.length > 0) { records.push({ role: msg.role, + // 写入 cwd 使 readSession 能恢复真实路径(归档键派生依赖它) + cwd, message: { content: cursorContent }, }); } diff --git a/src/session-flow/adapters/index.ts b/src/session-flow/adapters/index.ts index 96f286e16..c0741c017 100644 --- a/src/session-flow/adapters/index.ts +++ b/src/session-flow/adapters/index.ts @@ -8,6 +8,7 @@ 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 { @@ -25,7 +26,11 @@ 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 变体(路径前缀不同,格式完全相同) @@ -64,5 +69,6 @@ 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 index 7ed7f4cc9..fb6730e95 100644 --- a/src/session-flow/adapters/workbuddy.ts +++ b/src/session-flow/adapters/workbuddy.ts @@ -261,7 +261,7 @@ export class WorkBuddyAdapter extends AgentAdapter { async readSession(sessionId: string, projectPath?: string): Promise { const jsonlPath = this.findSessionFile(sessionId, projectPath); if (!jsonlPath) { - throw new Error(`WorkBuddy 会话文件未找到: session_id=${sessionId}`); + throw new Error(`WorkBuddy session file not found: session_id=${sessionId}`); } const metaInfo = readMeta(jsonlPath); diff --git a/src/session-flow/codebuddy.ts b/src/session-flow/codebuddy.ts new file mode 100644 index 000000000..8799b7bba --- /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 index 79147b321..f1e494355 100644 --- a/src/session-flow/fs.ts +++ b/src/session-flow/fs.ts @@ -79,6 +79,22 @@ export function encodeCwdGeneric(cwd: string): string { return 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 path + .resolve(cwd) + .replace(/^([a-zA-Z]:)?[\\/]+/, '') // 去掉盘符与根分隔符 + .replace(/[\\/]/g, '-'); +} + /** * Claude Code 的 cwd 解码: 无法精确还原(`-` 可能来自 `/`、空格等), * 但目录名本身不需要解码为可用路径——仅用于显示。 diff --git a/src/session-flow/ide-history.ts b/src/session-flow/ide-history.ts index 704dbed56..9d30bbb75 100644 --- a/src/session-flow/ide-history.ts +++ b/src/session-flow/ide-history.ts @@ -40,7 +40,7 @@ interface IdeMessageFile { } /** IDE index.json 中的 conversation 条目。 */ -interface IdeConversation { +export interface IdeConversation { id: string; type: 'craft' | 'plan' | 'team-member'; name: string; @@ -54,6 +54,11 @@ export interface IdeSyncResult { synced: number; /** 写入的消息条数(按 IDE 计,tool-result 会拆成独立消息) */ messageCount: number; + /** + * 实际写入的 conversation id(32 位 hex)。 + * 非 UUID 的 sessionId 会被哈希,调用方不能拿源 sessionId 直接当 IDE id。 + */ + convId?: string; /** 未同步时的原因(synced===0 时有值) */ skipped?: string; } @@ -80,15 +85,31 @@ function hex32(): string { */ export function hashWorkspace(cwd: string): string | null { if (!cwd || !cwd.startsWith('/')) return null; - const normalized = path.resolve(cwd).replace(/\/+$/, ''); + 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(无横线)。 - * UUID v4 去横线即可;非 UUID 则回退为 md5(sessionId)。 + * + * 三种输入都要能映射回**同一个** 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'); @@ -118,38 +139,60 @@ function getUserDataBase(): string | null { } /** - * 找出所有 IDE 实例下该 cwd 对应的 history 目录。 + * 列出所有 IDE 实例的 history 根目录。 * - * 通常只有 1 个(单用户单实例)。多实例时全部返回,逐个写入。 - * createIfMissing=true 时,若该项目的 history 目录尚不存在(IDE 没打开过这个项目), - * 仍返回待创建路径,使其下次打开即可见。 + * 磁盘上有两种布局,都真实存在: + * - //CodeBuddyIDE//history 常规实例(instId 通常与 extId 同名) + * - //CodeBuddyIDE/history default 实例,少一层 instId + * + * 只认第一种会让 default 实例下的会话整体消失(既读不到也写不进), + * 故两种都收集。 */ -export function findIdeHistoryDirs(cwd: string, createIfMissing = false): string[] { +export function listIdeHistoryRoots(): string[] { const base = getUserDataBase(); if (!base || !fs.existsSync(base)) return []; - const hash = hashWorkspace(cwd); - if (!hash) return []; // cwd 不是真实绝对路径,放弃同步 - 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)) continue; - const target = path.join(historyRoot, hash); - if (fs.existsSync(target) || createIfMissing) out.push(target); + if (fs.existsSync(historyRoot)) out.push(historyRoot); } } } catch { - return []; + 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 转换 // --------------------------------------------------------------------------- @@ -558,7 +601,7 @@ export function writeIdeSession(session: Session, cwd: string): IdeSyncResult { return { synced, messageCount: messages.length, - ...(synced === 0 ? { skipped: 'IDE history 目录写入失败' } : {}), + ...(synced > 0 ? { convId } : { skipped: 'IDE history 目录写入失败' }), }; } @@ -569,27 +612,19 @@ export function writeIdeSession(session: Session, cwd: string): IdeSyncResult { * 无法还原真实路径),算不出 workspace hash。而 convId 是全局唯一的, * 故直接全局搜索,不依赖 cwd。 */ -function findIdeConversationDirs(convId: string): Array<{ historyDir: string; convDir: string }> { - const base = getUserDataBase(); - if (!base || !fs.existsSync(base)) return []; - +export function findIdeConversationDirs(convId: string): Array<{ historyDir: string; convDir: string }> { const out: Array<{ historyDir: string; convDir: string }> = []; try { - for (const extId of fs.readdirSync(base)) { - const ideRoot = path.join(base, extId, 'CodeBuddyIDE'); - if (!fs.existsSync(ideRoot)) continue; - for (const instId of fs.readdirSync(ideRoot)) { - const historyRoot = path.join(ideRoot, instId, 'history'); - if (!fs.existsSync(historyRoot)) continue; - for (const wsHash of fs.readdirSync(historyRoot)) { - const historyDir = path.join(historyRoot, wsHash); - const convDir = path.join(historyDir, convId); - if (fs.existsSync(convDir)) out.push({ historyDir, convDir }); - } + 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 []; + return out; } return out; } @@ -639,3 +674,178 @@ export function deleteIdeSession(sessionId: string, cwd?: string): number { 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/migrate.ts b/src/session-flow/migrate.ts index 20f451927..f55e9214d 100644 --- a/src/session-flow/migrate.ts +++ b/src/session-flow/migrate.ts @@ -23,6 +23,7 @@ export const THINKING_SUPPORT: Record = { 'codex-internal': true, tcodex: true, codebuddy: true, + 'codebuddy-ide': true, cursor: false, // Cursor 无 thinking,降级为 text }; @@ -45,6 +46,14 @@ export const NATIVE_TOOLS: Record> = { 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', diff --git a/src/session-flow/session-cmd.ts b/src/session-flow/session-cmd.ts index 6ceae57ee..3e7a2a95e 100644 --- a/src/session-flow/session-cmd.ts +++ b/src/session-flow/session-cmd.ts @@ -19,6 +19,7 @@ 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'; @@ -133,6 +134,27 @@ 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)。 @@ -248,6 +270,9 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { 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); @@ -276,7 +301,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { // 迁移没有确认环节(打完 Preview 就直接执行),不接全局 --dry-run 的话, // 想看保真度和告警就只能真迁一次、不满意再 rollback。 if (isDryRun()) { - console.log(`\n · --dry-run:仅预览,未迁移 ${m.sessionId.slice(0, 8)}...\n`); + console.log(`\n · --dry-run: preview only, not migrated: ${m.sessionId.slice(0, 8)}...\n`); continue; } @@ -292,6 +317,12 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { } 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}`); } @@ -300,34 +331,39 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { // --push: 推送到团队仓 if (opts.push && migrated > 0) { const repoRoot = resolveRepoRoot(opts.repoRoot); - const repoIdentity = resolveRepoIdentity(workCwd); const author = getGitAuthor(workCwd); const targetAdapter = safeGetAdapter(target); - const targetMetas = await targetAdapter.listConversations(opts.targetCwd ?? workCwd); - const recent = targetMetas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, migrated); const syncMgr = new SyncManager(repoRoot); let saved = 0; - for (const m of recent) { - const session = await targetAdapter.readSession(m.sessionId, opts.targetCwd ?? workCwd); - const meta = defaultSyncMeta({ - platform: target, - author, - cwd: opts.targetCwd ?? workCwd, - sessionId: m.sessionId, - repoIdentity, - }); + 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 = 1.0; + meta.migration.fidelityScore = t.fidelityScore; syncMgr.saveSession(session, meta); saved++; } const commitHash = syncMgr.gitCommit(`sync: migrate ${saved} session(s) ${source}→${target}`); - syncMgr.gitPush(); - console.log(`\n ✓ Pushed ${saved} session(s) to team repo`); - console.log(` commit: ${commitHash.slice(0, 8)}`); + if (commitHash) { + syncMgr.gitPush(); + 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( @@ -347,46 +383,83 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { .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)', '5') + .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 ]'); + 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 repoIdentity = resolveRepoIdentity(workCwd); const author = getGitAuthor(workCwd); const adapter = safeGetAdapter(source); - const metas = await adapter.listConversations(workCwd); + // --all:listConversations() 无参即枚举该平台的全部工作区目录(P5), + // 影响面收敛在单一平台(与 status --all 一致);单 cwd 模式保持原行为。 + const metas = opts.all + ? await adapter.listConversations() + : await adapter.listConversations(workCwd); const limit = parseInt(opts.limit, 10) || 5; - const sorted = metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit); + const sorted = metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + const selected = opts.all ? sorted : sorted.slice(0, limit); - if (sorted.length === 0) { + 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 sorted) { - const session = await adapter.readSession(m.sessionId, workCwd); - const meta = defaultSyncMeta({ - platform: source, - author, - cwd: workCwd, - sessionId: m.sessionId, - repoIdentity, - }); + 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++; } - const commitHash = syncMgr.gitCommit(`sync: push ${saved} session(s) from ${source}`); - syncMgr.gitPush(); - console.log(`\n ✓ Pushed ${saved} session(s) from ${source}`); - console.log(` commit: ${commitHash.slice(0, 8)}\n`); + const commitHash = syncMgr.gitCommit(`sync: push ${saved} session(s) from ${source}${opts.all ? ' (all workspaces)' : ''}`); + if (commitHash) { + syncMgr.gitPush(); + 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 ─────────────────────────────────────────── @@ -395,15 +468,26 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { .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 repoIdentity = resolveRepoIdentity(workCwd); const syncMgr = new SyncManager(repoRoot); syncMgr.gitPull(); - const count = syncMgr.rebuildIndex(repoIdentity); - console.log(`\n ✓ Pulled and indexed ${count} session(s)\n`); + 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 ─────────────────────────────────────────── @@ -413,30 +497,49 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { .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); - const sessions = syncMgr.listSessions(repoIdentity, opts.author); + // 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; } - 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}`); + 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`); }); @@ -458,7 +561,19 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { const repoIdentity = resolveRepoIdentity(workCwd); const syncMgr = new SyncManager(repoRoot); - const { session } = syncMgr.loadSession(repoIdentity, sessionName, opts.author); + // 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(); @@ -491,24 +606,17 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { const syncMgr = new SyncManager(repoRoot); const loadedSessions: LoadedSession[] = []; - if (opts.all) { - // 遍历所有 repo - const repos = syncMgr.listRepos(); - for (const repo of repos) { - const entries = syncMgr.listSessions(null); // _unattributed - // listRepos 返回的是编码后的目录名,需要用 null 遍历 _unattributed - } - // 简化:直接遍历 sessions/repos/ 下所有子目录 - const allRepos = syncMgr.listRepos(); - for (const _repo of allRepos) { - // listSessions 需要 repoIdentity(canonical),但 listRepos 返回的是编码名 - // 这里用一个简化方案:遍历 _index.json - } - // _unattributed - const unattributed = syncMgr.listSessions(null); - for (const entry of unattributed) { + // 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(null, entry.sessionName, entry.author); + const { session } = syncMgr.loadSession(identity, entry.sessionName, entry.author); loadedSessions.push({ sessionName: entry.sessionName, author: entry.author, session }); } catch { // skip corrupted @@ -516,17 +624,6 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { } } - // 当前 repo - const entries = syncMgr.listSessions(repoIdentity); - for (const entry of entries) { - try { - const { session } = syncMgr.loadSession(repoIdentity, 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 }); @@ -559,8 +656,8 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { // 回滚是破坏性操作(删 CLI 文件 + 删 IDE 侧边栏条目),先看清楚再删。 if (isDryRun()) { console.log( - `\n · --dry-run:将删除 ${opts.platform}/${sessionId}` + - `${opts.cwd ? ` (仅 ${opts.cwd})` : ' (所有工作区)'}\n`, + `\n · --dry-run: would delete ${opts.platform}/${sessionId}` + + `${opts.cwd ? ` (only ${opts.cwd})` : ' (all workspaces)'}\n`, ); return; } @@ -570,7 +667,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { // 适配器返回 false 表示确认没删到任何东西(会话不存在)。 // 之前无论是否存在都打印 ✓,静默 no-op 却报成功,脚本无法判断是否生效。 if (deleted === false) { - console.log(`\n · 未找到会话 ${opts.platform}/${sessionId},无变更(可能已被删除)\n`); + 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 index 4d6a14d2d..42890e334 100644 --- a/src/session-flow/sync.ts +++ b/src/session-flow/sync.ts @@ -152,20 +152,31 @@ export interface SessionSyncMeta { status: 'active' | 'archived'; } -export function defaultSyncMeta(partial: { - platform: string; - author: string; - cwd: string; - sessionId: string; - repoIdentity?: string | null; -}): SessionSyncMeta { +/** + * 构造默认 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: utcNow(), + createdAt: createdAt ?? utcNow(), sessionId: partial.sessionId, }, migration: { @@ -198,6 +209,14 @@ export interface IndexEntry { createdAt: string; updatedAt: string; status: string; + /** + * 源平台的会话 ID(origin.sessionId)。 + * + * push 去重键(P8)= sessionId + author:重复推送同一会话时更新既有条目, + * 而不是 resolveNameConflict 生成 `xxx_1` 副本。可选项——旧索引/损坏索引 + * 重建前没有该字段,此时去重退化为旧的名冲突行为。 + */ + sessionId?: string; } interface RepoIndex { @@ -315,6 +334,23 @@ export class SyncManager { // 保存 / 加载 // ------------------------------------------------------------------ + /** + * 按源平台 sessionId(+可选 author)在 repo 索引中查找既有条目。 + * + * push 去重键(P8):origin.sessionId + author。命中说明该会话曾推送过, + * 应更新既有条目与文件,而不是再写一个 `_1` 副本。 + */ + private findByOriginSessionId( + repoIdentity: string | null, + sessionId: string, + author?: string, + ): IndexEntry | undefined { + const index = this.readIndex(repoIdentity); + return index.sessions.find( + (s) => s.sessionId === sessionId && (!author || s.author === author), + ); + } + /** * 保存会话到团队仓。 * @@ -327,7 +363,15 @@ export class SyncManager { const author = meta.origin.author; let sessionName = generateSessionName(session.platform, session.title, session.createdAt); - sessionName = this.resolveNameConflict(repoId, author, sessionName); + // P8 去重:同一 origin.sessionId + author 重复推送时,复用原 sessionName + // 覆盖写(upsertIndexEntry 按 sessionName:author 命中既有条目原地更新), + // 而不是 resolveNameConflict 生成 `xxx_1` 副本。 + const existing = this.findByOriginSessionId(repoId, meta.origin.sessionId, author); + 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 }); @@ -352,6 +396,7 @@ export class SyncManager { createdAt: session.createdAt, updatedAt: session.updatedAt, status: meta.status, + sessionId: meta.origin.sessionId, }); return path.relative(this.repoRoot, paths.jsonl); @@ -369,10 +414,10 @@ export class SyncManager { const paths = this.sessionPaths(repoIdentity, resolvedAuthor, sessionName); if (!fs.existsSync(paths.jsonl)) { - throw new Error(`会话文件不存在: ${paths.jsonl}`); + throw new Error(`Session file not found: ${paths.jsonl}`); } if (!fs.existsSync(paths.meta)) { - throw new Error(`元数据文件不存在: ${paths.meta}`); + throw new Error(`Meta file not found: ${paths.meta}`); } // 读 meta @@ -405,7 +450,7 @@ export class SyncManager { /** 在 repo 目录下搜索 sessionName 属于哪个 author */ private findAuthor(repoIdentity: string | null, sessionName: string): string { const dir = this.repoDir(repoIdentity); - if (!fs.existsSync(dir)) throw new Error(`仓库目录不存在: ${dir}`); + 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); @@ -414,7 +459,7 @@ export class SyncManager { return entry; } } - throw new Error(`会话 ${sessionName} 未找到(已搜索所有 author 目录)`); + throw new Error(`Session ${sessionName} not found (searched all author directories)`); } private extractTitleFromSessionName(sessionName: string): string { @@ -456,6 +501,74 @@ export class SyncManager { }); } + /** + * 列出团队仓中**所有** 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); @@ -507,6 +620,7 @@ export class SyncManager { createdAt: meta.origin.createdAt, updatedAt: meta.sync.pushedAt ?? meta.origin.createdAt, status: meta.status, + sessionId: meta.origin.sessionId, }); } catch { // skip corrupted entries @@ -542,9 +656,17 @@ export class SyncManager { } } - /** git add sessions/ && git commit → 返回 commit hash */ - gitCommit(message: string): string { + /** + * 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']); } From bc14a861f8129bdff138a3a092b2759575e46a7d Mon Sep 17 00:00:00 2001 From: lurkacai Date: Wed, 16 Sep 2026 00:20:52 +0800 Subject: [PATCH 04/28] feat(session): split CodeBuddy into cli and ide platforms - new codebuddy-ide adapter: list/read/write/delete for the IDE sidebar history store (previously write-only via the cli adapter's implicit double-write, so IDE sessions could neither be listed nor migrated) - cli adapter no longer writes/deletes the IDE store; read/write symmetry - detect both on-disk history layouts (default instance stores history one level higher) so its conversations are no longer skipped - reuse 32-hex conversation ids as-is instead of hashing twice, fixing rollback reporting success while leaving sessions in place - encode cli project dirs with CodeBuddy's own rule (spaces kept), so workspaces like 'teamai cli' can be listed and read - resolve symlinks before hashing workspaces (/tmp vs /private/tmp) - shared title cleaning: skip -style injected first messages instead of leaking prompt text into listings - user-facing errors and test assertions in English --- CHANGELOG.md | 7 + src/__tests__/codebuddy-ide-adapter.test.ts | 328 ++++++++++++++++++++ src/__tests__/session-title.test.ts | 34 ++ src/session-flow/adapters/codebuddy-ide.ts | 321 +++++++++++++++++++ src/session-flow/ide-history.ts | 2 +- src/session-flow/title.ts | 44 +++ 6 files changed, 735 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/codebuddy-ide-adapter.test.ts create mode 100644 src/__tests__/session-title.test.ts create mode 100644 src/session-flow/adapters/codebuddy-ide.ts create mode 100644 src/session-flow/title.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 600586f1c..a606ce3e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ All notable changes to this project will be documented in this file. See [standa - 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 init --provider ` uses the named provider instead of detecting one, so a member of a team on self-hosted GitLab can join with `--provider git` and their existing Git authentication, without `GITLAB_TOKEN`. The choice is saved in that machine's local config and takes precedence over the team's `teamai.yaml` `provider` for PR/MR creation and for `teamai doctor`'s provider checks; an existing `teamai.yaml` is unchanged, so other members keep the team's provider, and a `teamai.yaml` that `init` creates records the provider `init` would detect without the flag rather than `git`, and stops with a `GITLAB_URL` hint on an unconfigured self-hosted GitLab. `--provider gitlab` on a host that is not the configured `GITLAB_URL` or `TEAMAI_GITLAB_HOST` stops with a hint instead of sending the token to gitlab.com. With `git`, `teamai push` pushes the branch and leaves the merge request to be opened on the Git host, exiting non-zero as it does for a `provider: git` team repo. Re-running `init` without `--provider` returns to auto-detection. The value must be one of `tgit`, `github`, `cnb`, `gitlab`, `gitcode` or `git`, and `--provider` cannot be combined with `--http` (for [#789](https://github.com/Tencent/teamai-cli/issues/789)). +- `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. ### 🐛 Bug Fixes @@ -68,6 +70,11 @@ 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. +- 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. - 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/src/__tests__/codebuddy-ide-adapter.test.ts b/src/__tests__/codebuddy-ide-adapter.test.ts new file mode 100644 index 000000000..871b445cb --- /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__/session-title.test.ts b/src/__tests__/session-title.test.ts new file mode 100644 index 000000000..69573609d --- /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/session-flow/adapters/codebuddy-ide.ts b/src/session-flow/adapters/codebuddy-ide.ts new file mode 100644 index 000000000..aad58eed9 --- /dev/null +++ b/src/session-flow/adapters/codebuddy-ide.ts @@ -0,0 +1,321 @@ +/** + * 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 ''; +} + +// --------------------------------------------------------------------------- +// 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 = + 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 { + messages.push({ role: 'user', content: [irBlock] }); + } + } + 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 = + 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 会静默跳过。静默成功比失败更危险——用户以为迁完了,侧边栏却是空的。 + if (!cwd || !cwd.startsWith('/')) { + 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/ide-history.ts b/src/session-flow/ide-history.ts index 9d30bbb75..11736bc19 100644 --- a/src/session-flow/ide-history.ts +++ b/src/session-flow/ide-history.ts @@ -601,7 +601,7 @@ export function writeIdeSession(session: Session, cwd: string): IdeSyncResult { return { synced, messageCount: messages.length, - ...(synced > 0 ? { convId } : { skipped: 'IDE history 目录写入失败' }), + ...(synced > 0 ? { convId } : { skipped: 'failed to write IDE history directories' }), }; } diff --git a/src/session-flow/title.ts b/src/session-flow/title.ts new file mode 100644 index 000000000..5751db5c1 --- /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)}`; +} From 13b22b700fe22b542816f7d5cca2d571b7888fd5 Mon Sep 17 00:00:00 2001 From: lurkacai Date: Wed, 16 Sep 2026 11:42:40 +0800 Subject: [PATCH 05/28] =?UTF-8?q?test(session)+docs:=20M3=20=E2=80=94=20cr?= =?UTF-8?q?oss-repo=20coverage,=20guides,=20and=20E2E-discovered=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests (29 new cases): - session-sync.test.ts: identity reverse-mapping, cross-repo listing, dedup by origin sessionId, index rebuild round-trip - session-cmd.test.ts: list/pull/search --all, push --all confirmation, native archive key, English output assertions Docs: - usage-guide (en/zh): new Session Sync & Migration section - README (en/zh): capability row and command cheat-sheet entries - CHANGELOG: M1/M2 entries plus the fixes below Fixes found by the real-CLI E2E run: - encode symlink-resolved cwds into project directory names for claude-code / codebuddy / cursor / workbuddy (writing /tmp/x used to create a directory listing from /private/tmp/x could never see) - keep the local commit and print a warning when the remote push fails instead of crashing after a successful save - apply the shared injected-title cleaning to claude-code / workbuddy / cursor (their first 'user message' is often a system-reminder wrapper, which used to become the archived session name) --- CHANGELOG.md | 13 + docs/usage-guide.md | 26 ++ docs/usage-guide.zh-CN.md | 26 ++ src/__tests__/session-cmd.test.ts | 552 +++++++++++++++++++++++ src/__tests__/session-sync.test.ts | 275 +++++++++++ src/session-flow/adapters/claude-code.ts | 16 +- src/session-flow/adapters/cursor.ts | 12 +- src/session-flow/adapters/workbuddy.ts | 16 +- src/session-flow/fs.ts | 25 +- src/session-flow/session-cmd.ts | 20 +- 10 files changed, 961 insertions(+), 20 deletions(-) create mode 100644 src/__tests__/session-cmd.test.ts create mode 100644 src/__tests__/session-sync.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a606ce3e8..ef0f132af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ All notable changes to this project will be documented in this file. See [standa - `teamai init --provider ` uses the named provider instead of detecting one, so a member of a team on self-hosted GitLab can join with `--provider git` and their existing Git authentication, without `GITLAB_TOKEN`. The choice is saved in that machine's local config and takes precedence over the team's `teamai.yaml` `provider` for PR/MR creation and for `teamai doctor`'s provider checks; an existing `teamai.yaml` is unchanged, so other members keep the team's provider, and a `teamai.yaml` that `init` creates records the provider `init` would detect without the flag rather than `git`, and stops with a `GITLAB_URL` hint on an unconfigured self-hosted GitLab. `--provider gitlab` on a host that is not the configured `GITLAB_URL` or `TEAMAI_GITLAB_HOST` stops with a hint instead of sending the token to gitlab.com. With `git`, `teamai push` pushes the branch and leaves the merge request to be opened on the Git host, exiting non-zero as it does for a `provider: git` team repo. Re-running `init` without `--provider` returns to auto-detection. The value must be one of `tgit`, `github`, `cnb`, `gitlab`, `gitcode` or `git`, and `--provider` cannot be combined with `--http` (for [#789](https://github.com/Tencent/teamai-cli/issues/789)). - `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 @@ -74,7 +78,16 @@ All notable changes to this project will be documented in this file. See [standa - 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. +- 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/docs/usage-guide.md b/docs/usage-guide.md index e7cd0c017..eea651028 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1719,6 +1719,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 71232ec19..b95ec910b 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1609,6 +1609,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__/session-cmd.test.ts b/src/__tests__/session-cmd.test.ts new file mode 100644 index 000000000..81e019045 --- /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 000000000..7840051d1 --- /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/session-flow/adapters/claude-code.ts b/src/session-flow/adapters/claude-code.ts index 37f57f9b9..144e0fe7d 100644 --- a/src/session-flow/adapters/claude-code.ts +++ b/src/session-flow/adapters/claude-code.ts @@ -35,6 +35,7 @@ import { scanFiles, removeDirRecursive, } from '../fs.js'; +import { cleanTitleText, fallbackTitle, isInjectedText } from '../title.js'; // --------------------------------------------------------------------------- // 工具名归一化映射 @@ -332,11 +333,13 @@ export class ClaudeCodeAdapter extends AgentAdapter { const msg = record.message as Record | undefined; const content = msg?.content; if (typeof content === 'string') { - firstUserText = content; + 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') { - firstUserText = String((block as Record).text ?? ''); + const text = String((block as Record).text ?? ''); + // 首个文本块常是 system-reminder 等注入,跳过继续找真正的提问 + if (!isInjectedText(text)) firstUserText = text; break; } } @@ -351,7 +354,7 @@ export class ClaudeCodeAdapter extends AgentAdapter { if (!createdAt) createdAt = new Date().toISOString(); if (!updatedAt) updatedAt = createdAt; - title = firstUserText ? firstUserText.slice(0, 50) : `Session ${sessionId.slice(0, 8)}`; + title = cleanTitleText(firstUserText) || fallbackTitle(sessionId); let sizeBytes = 0; try { @@ -407,14 +410,15 @@ export class ClaudeCodeAdapter extends AgentAdapter { if (msg.role === 'user') { for (const block of msg.content) { if (block.type === 'text' && block.text) { - title = block.text.slice(0, 50); - break; + if (isInjectedText(block.text)) continue; // 注入块不当标题 + title = cleanTitleText(block.text); + if (title) break; } } if (title) break; } } - if (!title) title = `Session ${sessionId.slice(0, 8)}`; + if (!title) title = fallbackTitle(sessionId); // 时间戳 let createdAt: string | undefined; diff --git a/src/session-flow/adapters/cursor.ts b/src/session-flow/adapters/cursor.ts index a96fb0e87..6d35143be 100644 --- a/src/session-flow/adapters/cursor.ts +++ b/src/session-flow/adapters/cursor.ts @@ -35,6 +35,7 @@ import { dirExists, removeDirRecursive, } from '../fs.js'; +import { cleanTitleText, fallbackTitle, isInjectedText } from '../title.js'; // --------------------------------------------------------------------------- // 工具名归一化映射 @@ -190,7 +191,9 @@ export class CursorAdapter extends AgentAdapter { if (Array.isArray(content)) { for (const block of content) { if (block && typeof block === 'object' && (block as Record).type === 'text') { - firstUserText = String((block as Record).text ?? ''); + const text = String((block as Record).text ?? ''); + // 首个文本块常是 system-reminder 等注入,跳过继续找真正的提问 + if (!isInjectedText(text)) firstUserText = text; break; } } @@ -201,7 +204,7 @@ export class CursorAdapter extends AgentAdapter { return null; } - title = firstUserText ? firstUserText.slice(0, 50) : `Session ${sessionId.slice(0, 8)}`; + title = cleanTitleText(firstUserText) || fallbackTitle(sessionId); let sizeBytes = 0; try { @@ -274,8 +277,9 @@ export class CursorAdapter extends AgentAdapter { if (msg.role === 'user') { for (const block of msg.content) { if (block.type === 'text' && block.text) { - title = block.text.slice(0, 50); - break; + if (isInjectedText(block.text)) continue; // 注入块不当标题 + title = cleanTitleText(block.text); + if (title) break; } } if (title) break; diff --git a/src/session-flow/adapters/workbuddy.ts b/src/session-flow/adapters/workbuddy.ts index fb6730e95..b6efe368b 100644 --- a/src/session-flow/adapters/workbuddy.ts +++ b/src/session-flow/adapters/workbuddy.ts @@ -36,6 +36,7 @@ import { dirExists, removeDirRecursive, } from '../fs.js'; +import { cleanTitleText, fallbackTitle, isInjectedText } from '../title.js'; // --------------------------------------------------------------------------- // 工具名归一化映射 @@ -221,7 +222,9 @@ export class WorkBuddyAdapter extends AgentAdapter { if (Array.isArray(content)) { for (const block of content) { if (block && typeof block === 'object' && (block as Record).type === 'input_text') { - firstUserText = String((block as Record).text ?? ''); + const text = String((block as Record).text ?? ''); + // 首个文本块常是 system-reminder 等注入,跳过继续找真正的提问 + if (!isInjectedText(text)) firstUserText = text; break; } } @@ -236,7 +239,11 @@ export class WorkBuddyAdapter extends AgentAdapter { if (!createdAt) createdAt = new Date().toISOString(); if (!updatedAt) updatedAt = createdAt; - title = aiTitle || (firstUserText ? firstUserText.slice(0, 50) : `Session ${sessionId.slice(0, 8)}`); + // aiTitle 是 WorkBuddy 自己起的标题,最可靠;注入文本清洗同 codebuddy 适配器 + title = + (aiTitle && !isInjectedText(aiTitle) && aiTitle.slice(0, 60)) || + cleanTitleText(firstUserText) || + fallbackTitle(sessionId); let sizeBytes = 0; try { @@ -384,8 +391,9 @@ export class WorkBuddyAdapter extends AgentAdapter { if (msg.role === 'user') { for (const block of msg.content) { if (block.type === 'text' && block.text) { - title = block.text.slice(0, 50); - break; + if (isInjectedText(block.text)) continue; // 注入块不当标题 + title = cleanTitleText(block.text); + if (title) break; } } if (title) break; diff --git a/src/session-flow/fs.ts b/src/session-flow/fs.ts index f1e494355..b7f9036d3 100644 --- a/src/session-flow/fs.ts +++ b/src/session-flow/fs.ts @@ -61,13 +61,31 @@ export function getCursorProjectsDir(): string { // 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 cwd.replace(/[^a-zA-Z0-9]/g, '-'); + return resolveRealCwd(cwd).replace(/[^a-zA-Z0-9]/g, '-'); } /** @@ -76,7 +94,7 @@ export function encodeCwdClaude(cwd: string): string { * `/Users/foo/my project` → `Users-foo-my-project` */ export function encodeCwdGeneric(cwd: string): string { - return cwd.replace(/[^a-zA-Z0-9]/g, '-').replace(/^-+/, ''); + return resolveRealCwd(cwd).replace(/[^a-zA-Z0-9]/g, '-').replace(/^-+/, ''); } /** @@ -89,8 +107,7 @@ export function encodeCwdGeneric(cwd: string): string { * 而带空格的项目目录很常见。 */ export function encodeCwdCodeBuddy(cwd: string): string { - return path - .resolve(cwd) + return resolveRealCwd(cwd) .replace(/^([a-zA-Z]:)?[\\/]+/, '') // 去掉盘符与根分隔符 .replace(/[\\/]/g, '-'); } diff --git a/src/session-flow/session-cmd.ts b/src/session-flow/session-cmd.ts index 3e7a2a95e..a247041cf 100644 --- a/src/session-flow/session-cmd.ts +++ b/src/session-flow/session-cmd.ts @@ -163,6 +163,22 @@ function resolveRepoRoot(repoRoot?: string): string { return repoRoot ?? process.cwd(); } +/** + * 推送团队仓远端;失败时降级为警告而非崩溃。 + * + * 走到这里时本地 saveSession + gitCommit 已经成功——会话数据没有丢。 + * 远端失败的原因常常与数据无关(无 upstream、只读 HTTP 模式、网络), + * 用堆栈炸掉会把一次成功的归档伪装成彻底失败,用户再跑一次还会造出重复提交。 + */ +function pushToRemote(syncMgr: SyncManager): void { + try { + syncMgr.gitPush(); + } catch (err) { + const reason = err instanceof Error ? err.message.split('\n')[0] : String(err); + console.log(` · Remote push failed (local commit kept): ${reason}`); + } +} + // --------------------------------------------------------------------------- // 命令注册 // --------------------------------------------------------------------------- @@ -358,7 +374,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { } const commitHash = syncMgr.gitCommit(`sync: migrate ${saved} session(s) ${source}→${target}`); if (commitHash) { - syncMgr.gitPush(); + pushToRemote(syncMgr); console.log(`\n ✓ Pushed ${saved} session(s) to team repo`); console.log(` commit: ${commitHash.slice(0, 8)}`); } else { @@ -451,7 +467,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { } const commitHash = syncMgr.gitCommit(`sync: push ${saved} session(s) from ${source}${opts.all ? ' (all workspaces)' : ''}`); if (commitHash) { - syncMgr.gitPush(); + pushToRemote(syncMgr); console.log(`\n ✓ Pushed ${saved} session(s) from ${source}`); console.log(` commit: ${commitHash.slice(0, 8)}\n`); } else { From f8be609f79c0d9de1c980b7f4e94f66b2a1ab268 Mon Sep 17 00:00:00 2001 From: lurkacai Date: Wed, 16 Sep 2026 15:37:15 +0800 Subject: [PATCH 06/28] fix(session): write a summary record when migrating into claude-code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code's /resume picker shows the bare session id (e.g. 824ff784) for sessions without a type:"summary" record, so every migrated session appeared untitled. Carry the IR session title — already cleaned of injected wrappers by the source adapter — into the target JSONL. --- CHANGELOG.md | 1 + src/session-flow/adapters/claude-code.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef0f132af..c76ae42cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ All notable changes to this project will be documented in this file. See [standa - 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`). - 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. diff --git a/src/session-flow/adapters/claude-code.ts b/src/session-flow/adapters/claude-code.ts index 144e0fe7d..c9397452e 100644 --- a/src/session-flow/adapters/claude-code.ts +++ b/src/session-flow/adapters/claude-code.ts @@ -691,6 +691,20 @@ export class ClaudeCodeAdapter extends AgentAdapter { 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; } From 4532759059022bea298c2ae3034fb52990cec5d1 Mon Sep 17 00:00:00 2001 From: lurkacai Date: Wed, 16 Sep 2026 16:22:41 +0800 Subject: [PATCH 07/28] =?UTF-8?q?fix(session):=20QA=20sweep=20=E2=80=94=20?= =?UTF-8?q?crashes,=20fidelity,=20and=20robustness=20across=208=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the three-way QA sweep (adapters / command layer / fidelity): P0 crashes fixed (found by real-CLI execution): - git add/commit/pull in a non-git or missing --repo-root dumped a full stack trace with internal paths; now a one-line error + exit 1 - concurrent pushes hitting git index.lock crashed the same way - pull with no origin remote / nonexistent repo root reported a misleading ENOENT instead of the actual cause Correctness: - session archive dedup key now includes platform: a session pushed as codebuddy and re-archived after migrating to claude-code are two artifacts, not an update of each other - codex keeps per-message timestamps (read response_item.timestamp, stamp records with the message's own time) — roundtrips no longer collapse the timeline - codex session lookup matches whole ids; a 4-char prefix could resolve to someone else's session file - cursor writeSession is idempotent again (malformed UUID regex minted a new id per write, piling up copies) - claude-code readSession honors the type:"summary" record it writes - workbuddy/claude-code/cursor titles skip injected ai-title/name wrappers and tool-output snippets; extractMeta no longer stops at the first injected block (real question after a system-reminder wrapper becomes the title) - codex writeSession survives an invalid session.createdAt instead of crashing with RangeError - a corrupted sessions/**/_index.json warns with the rebuild command instead of silently emptying the dedup key Robustness: - interactive prompts treat EOF like "n" (Cancelled., exit 0) instead of a silent success; --limit rejects non-positive values; a closed output pipe exits cleanly instead of an EPIPE stack - remote push failures report git's actual fatal line, keeping the local commit Docs: - design doc gains a Known limitations section (fidelityScore is a proxy metric; codex splitting; sessionId is platform-native; flattenDag) - src/__tests__/fidelity-sweep.test.ts joins the suite as the fidelity regression tool (roundtrip matrix over 5 platform routes) --- CHANGELOG.md | 10 +- docs/designs/session-user-repo-sync.md | 21 + src/__tests__/fidelity-sweep.test.ts | 570 +++++++++++++++++++++ src/session-flow/adapters/claude-code.ts | 37 +- src/session-flow/adapters/codebuddy-ide.ts | 23 +- src/session-flow/adapters/codex.ts | 60 ++- src/session-flow/adapters/cursor.ts | 13 +- src/session-flow/adapters/workbuddy.ts | 10 +- src/session-flow/ide-history.ts | 5 +- src/session-flow/session-cmd.ts | 81 ++- src/session-flow/sync.ts | 16 +- 11 files changed, 790 insertions(+), 56 deletions(-) create mode 100644 src/__tests__/fidelity-sweep.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c76ae42cf..218edd927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,7 +79,15 @@ All notable changes to this project will be documented in this file. See [standa - 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`). +- 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. diff --git a/docs/designs/session-user-repo-sync.md b/docs/designs/session-user-repo-sync.md index 41e1f6cfd..a38cda5b4 100644 --- a/docs/designs/session-user-repo-sync.md +++ b/docs/designs/session-user-repo-sync.md @@ -104,6 +104,27 @@ M1 and M2 are independent in code but share the same branch; M3 lands last and v - 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` diff --git a/src/__tests__/fidelity-sweep.test.ts b/src/__tests__/fidelity-sweep.test.ts new file mode 100644 index 000000000..5ecc41b46 --- /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/session-flow/adapters/claude-code.ts b/src/session-flow/adapters/claude-code.ts index c9397452e..2ad030950 100644 --- a/src/session-flow/adapters/claude-code.ts +++ b/src/session-flow/adapters/claude-code.ts @@ -339,8 +339,10 @@ export class ClaudeCodeAdapter extends AgentAdapter { 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; + if (!isInjectedText(text)) { + firstUserText = text; + break; + } } } } @@ -387,8 +389,16 @@ export class ClaudeCodeAdapter extends AgentAdapter { // 收集所有消息记录 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(绝对路径)。目录名解码是有损的 @@ -404,18 +414,21 @@ export class ClaudeCodeAdapter extends AgentAdapter { // DAG 拍平 const messages = this.flattenDag(rawRecords); - // 提取标题 - let 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; + // 提取标题:优先 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) break; } } if (!title) title = fallbackTitle(sessionId); diff --git a/src/session-flow/adapters/codebuddy-ide.ts b/src/session-flow/adapters/codebuddy-ide.ts index aad58eed9..cb345012e 100644 --- a/src/session-flow/adapters/codebuddy-ide.ts +++ b/src/session-flow/adapters/codebuddy-ide.ts @@ -71,6 +71,15 @@ function firstUserText(messages: IdeMessageParsed[]): string { return ''; } +/** + * IDE 会把 index.json 里的 conversation.name 原样落盘,偶尔也混入 + * 等注入块原文。不清洗的话会污染整个迁移链路 + * (列表标题、readSession.title、目标侧标题全是提示词原文)。 + */ +function safeConversationName(name: string): string { + return name && !isInjectedText(name) ? name.slice(0, 100) : ''; +} + // --------------------------------------------------------------------------- // CodeBuddyIdeAdapter // --------------------------------------------------------------------------- @@ -122,7 +131,7 @@ export class CodeBuddyIdeAdapter extends AgentAdapter { // 标题兜底只读开头几条:IDE 会话动辄几千条消息,为拿个标题把整会话读一遍 // 会让列一次表耗时十几秒。 const title = - entry.name || + safeConversationName(entry.name) || firstUserText(readIdeConversation(entry.convDir, TITLE_LOOKAHEAD)) || `Session ${entry.id.slice(0, 8)}`; @@ -196,7 +205,9 @@ export class CodeBuddyIdeAdapter extends AgentAdapter { if (last && last.role === 'user') { last.content.push(irBlock); } else { - messages.push({ role: 'user', content: [irBlock] }); + // 带上原始时间戳:缺失时下游 writeSession(如 claude-code)会用 + // 迁移时刻填充,产生「后一条消息早于前一条」的时间倒挂。 + messages.push({ role: 'user', content: [irBlock], timestamp: raw.createdAt }); } } continue; @@ -216,7 +227,9 @@ export class CodeBuddyIdeAdapter extends AgentAdapter { } const title = - entry?.name || firstUserText(rawMessages) || `Session ${convId.slice(0, 8)}`; + 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; @@ -300,7 +313,9 @@ export class CodeBuddyIdeAdapter extends AgentAdapter { // writeIdeSession 依赖 md5(cwd) 定位工作区;cwd 是 `md5:` 这类占位值时 // 算不出 hash 会静默跳过。静默成功比失败更危险——用户以为迁完了,侧边栏却是空的。 - if (!cwd || !cwd.startsWith('/')) { + // 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.`, ); diff --git a/src/session-flow/adapters/codex.ts b/src/session-flow/adapters/codex.ts index d6663fce8..4872fa8f6 100644 --- a/src/session-flow/adapters/codex.ts +++ b/src/session-flow/adapters/codex.ts @@ -23,6 +23,7 @@ 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, @@ -144,7 +145,10 @@ export class CodexAdapter extends AgentAdapter { private findSessionFile(sessionId: string): string | null { for (const f of this.scanJsonlFiles()) { - if (path.basename(f).includes(sessionId)) return f; + // 文件名是 rollout-<时间戳>-,中缀匹配;但前缀只认 ≥8 位, + // 否则 4 位前缀的子串会读到别人的会话 + const base = path.basename(f, '.jsonl'); + if (base === sessionId || (sessionId.length >= 8 && base.endsWith(sessionId))) return f; } return null; } @@ -188,7 +192,10 @@ export class CodexAdapter extends AgentAdapter { const tsRaw = payload.timestamp; if (projectPath) { - if (cwd !== projectPath) continue; + // 不能用精确字符串比较:macOS 上 /tmp 与 /private/tmp 是同一目录的两种拼写 + // (symlink),写入时与列出时的拼写不一致会让会话「列出为空」。 + // 与 encodeCwd*/hashWorkspace 一致,先 realpath 再比较。 + if (resolveRealCwd(cwd) !== resolveRealCwd(projectPath)) continue; } const createdAt = parseCodexTimestamp(tsRaw); @@ -313,7 +320,7 @@ export class CodexAdapter extends AgentAdapter { const irRole = role === 'user' ? 'user' : 'assistant'; const content = this.parseMessageContent(payload); - messages.push({ role: irRole, content }); + 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); @@ -331,7 +338,7 @@ export class CodexAdapter extends AgentAdapter { if (messages.length > 0 && messages[messages.length - 1].role === 'assistant') { messages[messages.length - 1].content.push(block); } else { - messages.push({ role: 'assistant', content: [block] }); + 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 ?? ''); @@ -341,7 +348,7 @@ export class CodexAdapter extends AgentAdapter { if (messages.length > 0 && messages[messages.length - 1].role === 'user') { messages[messages.length - 1].content.push(block); } else { - messages.push({ role: 'user', content: [block] }); + messages.push({ role: 'user', content: [block], timestamp: parseCodexTimestamp(rec.timestamp) }); } } else if (ptype === 'reasoning') { // reasoning → ThinkingBlock @@ -359,7 +366,7 @@ export class CodexAdapter extends AgentAdapter { if (messages.length > 0 && messages[messages.length - 1].role === 'assistant') { messages[messages.length - 1].content.push(block); } else { - messages.push({ role: 'assistant', content: [block] }); + messages.push({ role: 'assistant', content: [block], timestamp: parseCodexTimestamp(rec.timestamp) }); } } } @@ -397,7 +404,10 @@ export class CodexAdapter extends AgentAdapter { sessionId = generateUuidV7(); } - const createdAt = new Date(session.createdAt); + // 损坏输入防御: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); @@ -432,35 +442,43 @@ export class CodexAdapter extends AgentAdapter { // 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: new Date().toISOString(), + timestamp: msgTs, type: 'event_msg', payload: { type: 'task_complete', turn_id: turnId, - completed_at: Math.floor(Date.now() / 1000), + completed_at: Math.floor(new Date(msgTs).getTime() / 1000), }, }); } // 新 turn turnId = generateUuidV7(); records.push({ - timestamp: new Date().toISOString(), + timestamp: msgTs, type: 'event_msg', payload: { type: 'task_started', turn_id: turnId, - started_at: Math.floor(Date.now() / 1000), + started_at: Math.floor(new Date(msgTs).getTime() / 1000), }, }); records.push({ - timestamp: new Date().toISOString(), + timestamp: msgTs, type: 'turn_context', payload: { turn_id: turnId, @@ -473,7 +491,7 @@ export class CodexAdapter extends AgentAdapter { // 写消息的每个 content block for (const block of msg.content) { - const rec = this.blockToResponseItem(msg.role, block); + const rec = this.blockToResponseItem(msg.role, block, msgTs); if (rec) records.push(rec); } } @@ -481,12 +499,12 @@ export class CodexAdapter extends AgentAdapter { // 最后一个 turn 的 task_complete if (turnStarted) { records.push({ - timestamp: new Date().toISOString(), + timestamp: lastTs, type: 'event_msg', payload: { type: 'task_complete', turn_id: turnId, - completed_at: Math.floor(Date.now() / 1000), + completed_at: Math.floor(new Date(lastTs).getTime() / 1000), }, }); } @@ -495,14 +513,14 @@ export class CodexAdapter extends AgentAdapter { return sessionId; } - private blockToResponseItem(role: string, block: ContentBlock): Record | null { - const timestamp = new Date().toISOString(); + 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, + timestamp: ts, type: 'response_item', payload: { type: 'message', @@ -514,7 +532,7 @@ export class CodexAdapter extends AgentAdapter { case 'tool_call': { const codexName = denormalizeToolName(block.toolName); return { - timestamp, + timestamp: ts, type: 'response_item', payload: { type: 'function_call', @@ -526,7 +544,7 @@ export class CodexAdapter extends AgentAdapter { } case 'tool_result': { return { - timestamp, + timestamp: ts, type: 'response_item', payload: { type: 'function_call_output', @@ -538,7 +556,7 @@ export class CodexAdapter extends AgentAdapter { case 'thinking': { // Codex 支持 reasoning,写入为 reasoning response_item return { - timestamp, + timestamp: ts, type: 'response_item', payload: { type: 'reasoning', diff --git a/src/session-flow/adapters/cursor.ts b/src/session-flow/adapters/cursor.ts index 6d35143be..a3d51b34c 100644 --- a/src/session-flow/adapters/cursor.ts +++ b/src/session-flow/adapters/cursor.ts @@ -74,7 +74,10 @@ function denormalizeToolName(irName: string): string { // UUID 工具 // --------------------------------------------------------------------------- -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +// 任意合法 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); @@ -193,8 +196,10 @@ export class CursorAdapter extends AgentAdapter { 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; + if (!isInjectedText(text)) { + firstUserText = text; + break; + } } } } @@ -277,6 +282,8 @@ export class CursorAdapter extends AgentAdapter { 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; diff --git a/src/session-flow/adapters/workbuddy.ts b/src/session-flow/adapters/workbuddy.ts index b6efe368b..470b08118 100644 --- a/src/session-flow/adapters/workbuddy.ts +++ b/src/session-flow/adapters/workbuddy.ts @@ -224,8 +224,10 @@ export class WorkBuddyAdapter extends AgentAdapter { 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; + if (!isInjectedText(text)) { + firstUserText = text; + break; + } } } } @@ -290,7 +292,9 @@ export class WorkBuddyAdapter extends AgentAdapter { const rtype = rec.type as string; if (rtype === 'ai-title') { - title = String(rec.aiTitle ?? ''); + // 与 codebuddy 适配器一致:注入块原文偶尔会被存成 ai-title,照收会污染迁移链路 + const t = String(rec.aiTitle ?? ''); + if (t && !isInjectedText(t)) title = t.slice(0, 100); continue; } diff --git a/src/session-flow/ide-history.ts b/src/session-flow/ide-history.ts index 11736bc19..7c2c7477d 100644 --- a/src/session-flow/ide-history.ts +++ b/src/session-flow/ide-history.ts @@ -84,7 +84,8 @@ function hex32(): string { * 返回 null 让调用方跳过——宁可不同步,也不能用错误 hash 写进无关目录。 */ export function hashWorkspace(cwd: string): string | null { - if (!cwd || !cwd.startsWith('/')) return 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 列出」会算出两个不同的 @@ -531,7 +532,7 @@ export function writeIdeSession(session: Session, cwd: string): IdeSyncResult { return { synced: 0, messageCount: 0, - skipped: 'CodeBuddy IDE 存储未找到(未安装或未初始化),仅写入 CLI 路径', + skipped: 'CodeBuddy IDE storage not found (not installed or initialized); only the CLI path was written', }; } diff --git a/src/session-flow/session-cmd.ts b/src/session-flow/session-cmd.ts index a247041cf..50b37046a 100644 --- a/src/session-flow/session-cmd.ts +++ b/src/session-flow/session-cmd.ts @@ -86,7 +86,21 @@ function ask(question: string): Promise { resolve(lineQueue.shift() as string); return; } - lineResolver = resolve; + // 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); }); } @@ -174,11 +188,30 @@ function pushToRemote(syncMgr: SyncManager): void { try { syncMgr.gitPush(); } catch (err) { - const reason = err instanceof Error ? err.message.split('\n')[0] : String(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); + } +} + // --------------------------------------------------------------------------- // 命令注册 // --------------------------------------------------------------------------- @@ -187,6 +220,13 @@ function pushToRemote(syncMgr: SyncManager): void { * 在 `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),这里保持一致。 @@ -372,7 +412,12 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { syncMgr.saveSession(session, meta); saved++; } - const commitHash = syncMgr.gitCommit(`sync: migrate ${saved} session(s) ${source}→${target}`); + // [已修] 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`); @@ -387,6 +432,16 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { ? `\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(); } @@ -419,7 +474,9 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { const metas = opts.all ? await adapter.listConversations() : await adapter.listConversations(workCwd); - const limit = parseInt(opts.limit, 10) || 5; + 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); @@ -465,7 +522,12 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { syncMgr.saveSession(session, meta); saved++; } - const commitHash = syncMgr.gitCommit(`sync: push ${saved} session(s) from ${source}${opts.all ? ' (all workspaces)' : ''}`); + // [已修] 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}`); @@ -490,7 +552,11 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { const repoRoot = resolveRepoRoot(opts.repoRoot); const syncMgr = new SyncManager(repoRoot); - syncMgr.gitPull(); + // [已修] gitPull 失败(无 remote / repoRoot 不存在)此前裸堆栈崩溃 + runGitStep(() => { + syncMgr.gitPull(); + return null; + }, repoRoot, 'git pull'); if (opts.all) { // P2:对所有 identity(含 _unattributed)逐个幂等重建索引 const identities = syncMgr.listAllRepoIdentities(); @@ -617,7 +683,8 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { const workCwd = opts.cwd ?? process.cwd(); const repoRoot = resolveRepoRoot(opts.repoRoot); const repoIdentity = resolveRepoIdentity(workCwd); - const limit = parseInt(opts.limit, 10) || 10; + const limitRaw = parseInt(opts.limit, 10); + const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 10; const syncMgr = new SyncManager(repoRoot); const loadedSessions: LoadedSession[] = []; diff --git a/src/session-flow/sync.ts b/src/session-flow/sync.ts index 42890e334..4db05469d 100644 --- a/src/session-flow/sync.ts +++ b/src/session-flow/sync.ts @@ -284,7 +284,10 @@ export class SyncManager { return JSON.parse(fs.readFileSync(p, 'utf-8')) as RepoIndex; } } catch { - // corrupted index → rebuild + // 索引损坏(解析失败)时静默重建会让 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: [] }; } @@ -344,10 +347,17 @@ export class SyncManager { 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), + (s) => + s.sessionId === sessionId && + (!author || s.author === author) && + // platform 参与去重键:同一 sessionId 迁移到不同平台是不同的归档物 + // (meta.platform 各自独立),不能互相顶替——否则先 push codebuddy + // 再 migrate --push 到 claude-code 会把前一条归档覆盖掉。 + (!platform || s.platform === platform), ); } @@ -366,7 +376,7 @@ export class SyncManager { // P8 去重:同一 origin.sessionId + author 重复推送时,复用原 sessionName // 覆盖写(upsertIndexEntry 按 sessionName:author 命中既有条目原地更新), // 而不是 resolveNameConflict 生成 `xxx_1` 副本。 - const existing = this.findByOriginSessionId(repoId, meta.origin.sessionId, author); + const existing = this.findByOriginSessionId(repoId, meta.origin.sessionId, author, session.platform); if (existing) { sessionName = existing.sessionName; } else { From 00229f9fc4ed01fc77b66ff41c498a7120c05156 Mon Sep 17 00:00:00 2001 From: lurkacai Date: Tue, 22 Sep 2026 11:11:49 +0800 Subject: [PATCH 08/28] fix(session): make migrated sessions actually visible and migration idempotent Follow-up to the migration work in this PR, driven by real-client verification (Codex Desktop, CodeBuddy IDE, WorkBuddy, Cursor). Every fix below was reproduced against a real client before/after. Visibility: migrated sessions existed on disk but never showed up - codex: sessions are listed from state_5.sqlite, not by scanning rollouts. Write model_provider (buckets the list), keep rollouts legacy so `codex migrate-rollouts` builds the items projection (title/preview/content all come from it), and run it right after writing; if the new file is not indexed yet, start a temporary app-server and call thread/list (the official indexing path). Also match the 0.155 item_completed shape exactly: no client_id on UserMessage (it breaks parsing) and add started/completed_at_ms. - workbuddy: register into workbuddy.db (sessions + workspaces); user_id is discovered from existing rows, connectors/ or app/sessions.json -- never from device-id, which is a different id and would leave the session invisible behind a user filter. - workbuddy read path used encodeCwdGeneric while writes used the space-preserving rule, so listing by cwd returned 0 sessions. - cursor/workbuddy/codebuddy-ide: keep list registration best-effort but never silent -- warn that the session may stay invisible. Idempotency: repeat migrations no longer duplicate sessions - target ids are derived deterministically from (platform, source id) instead of minting a random uuid, for every adapter. Derived ids are v7-shaped so a re-migration of an already-migrated session reuses the id instead of deriving a new one. - rollback deletes the Codex index rows too (threads, items, turns, projection watermark); previously only the rollout file was removed, leaving an entry that was listed but opened blank. Index deletes run statement by statement: one missing table used to roll back the whole transaction and leave threads behind. Workspace isolation - target cwd defaults to the source session's workspace; --target-cwd is the only way to move a session elsewhere. - claude-code records sometimes store the encoded project dir as cwd; decode it back to a real path (verified against disk). - expanding "list all sessions across directories" kept using the shell's cwd to locate sources, so every migration on that path failed. Pass undefined and let adapters search globally. - --push takes the git author from the session's own repo. Fidelity and images - unknown tools are counted as degraded instead of preserved, so the score stops reporting a misleading 100%; workbuddy joins the THINKING_SUPPORT / NATIVE_TOOLS / IMAGE_SUPPORT matrices (review note: it was registered but absent from both tables). - images are a first-class IR block. codebuddy-ide reads assets (codebuddy-asset://, absolute paths, data URIs) and claude-code reads base64/url blocks; claude-code writes native base64 images, codebuddy-ide copies files back into assets/, platforms without image support degrade to a placeholder and say so in the report. Command layer - refuse to migrate into a target that is not installed instead of writing into a directory nobody reads. - exit 1 when any session in a batch fails, so --all is scriptable. - --all migrates everything (it silently capped at 5) with --limit to cap it and a confirmation listing above 10 sessions (-y skips). Titles: extract from content (summary record, then user text with injected wrappers unwrapped) instead of falling back to "Session " or leaking prompt text; share one implementation across adapters. Tests: src/__tests__/migrate-guard.test.ts covers the not-installed guard, the write path, unknown-tool degradation and image accounting. --- src/__tests__/migrate-guard.test.ts | 179 ++++++ src/session-flow/adapters/claude-code.ts | 118 +++- src/session-flow/adapters/codebuddy-ide.ts | 98 ++- src/session-flow/adapters/codebuddy.ts | 129 +++- src/session-flow/adapters/codex.ts | 511 +++++++++++++++- src/session-flow/adapters/cursor.ts | 280 ++++++++- src/session-flow/adapters/workbuddy.ts | 166 +++++- src/session-flow/cursor-store.ts | 656 +++++++++++++++++++++ src/session-flow/fs.ts | 20 + src/session-flow/ide-history.ts | 119 +++- src/session-flow/ids.ts | 32 + src/session-flow/ir.ts | 41 +- src/session-flow/migrate.ts | 88 ++- src/session-flow/session-cmd.ts | 62 +- src/session-flow/sqlite.ts | 32 + src/session-flow/title.ts | 145 ++++- src/session-flow/workbuddy-store.ts | 195 ++++++ 17 files changed, 2709 insertions(+), 162 deletions(-) create mode 100644 src/__tests__/migrate-guard.test.ts create mode 100644 src/session-flow/cursor-store.ts create mode 100644 src/session-flow/ids.ts create mode 100644 src/session-flow/sqlite.ts create mode 100644 src/session-flow/workbuddy-store.ts diff --git a/src/__tests__/migrate-guard.test.ts b/src/__tests__/migrate-guard.test.ts new file mode 100644 index 000000000..08d26f82e --- /dev/null +++ b/src/__tests__/migrate-guard.test.ts @@ -0,0 +1,179 @@ +/** + * migrate.test.ts — 迁移引擎守卫与保真度统计。 + * + * 覆盖三类曾静默出错的行为: + * 1. 目标端未安装时迁移必须失败(此前会照样写目录并报「成功」) + * 2. 未知工具计为 degraded(此前恒记 preserved → 保真度虚高 100%) + * 3. 图片块按目标能力分别计 preserved / degraded + */ + +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import type { AgentAdapter, SessionMeta } from '../session-flow/adapters/base.js'; +import { ADAPTER_REGISTRY, type AdapterFactory } from '../session-flow/adapters/index.js'; +import { MigrationEngine, fidelityFromSession } from '../session-flow/migrate.js'; +import type { Session } from '../session-flow/ir.js'; + +function makeSession(overrides: Partial = {}): Session { + return { + sessionId: '11111111-2222-4333-8444-555555555555', + title: 'test session', + cwd: '/tmp/test-proj', + platform: 'claude-code', + createdAt: '2026-09-20T00:00:00.000Z', + updatedAt: '2026-09-20T00:01:00.000Z', + messages: [ + { + role: 'user', + content: [{ type: 'text', text: 'hello' }], + timestamp: '2026-09-20T00:00:00.000Z', + }, + ], + metadata: {}, + ...overrides, + }; +} + +/** 永不就绪的 mock 适配器。 */ +function notReadyAdapter(): AgentAdapter { + return { + platform: 'mock-uninstalled', + isReady: () => false, + listConversations: async (): Promise => [], + readSession: async () => makeSession(), + writeSession: async () => 'mock-id', + deleteSession: async () => {}, + } as unknown as AgentAdapter; +} + +/** 全就绪的 mock 适配器,记录 writeSession 收到的会话。 */ +function readyAdapter(written: Session[]) { + return { + platform: 'mock-ready', + isReady: () => true, + listConversations: async (): Promise => [], + readSession: async () => makeSession(), + writeSession: async (s: Session) => { + written.push(s); + return 'mock-target-id'; + }, + deleteSession: async () => {}, + } as unknown as AgentAdapter; +} + +describe('MigrationEngine — 目标端安装检查', () => { + const KEY = '__test_uninstalled__'; + let original: AdapterFactory | undefined; + + beforeAll(() => { + original = ADAPTER_REGISTRY[KEY]; + ADAPTER_REGISTRY[KEY] = () => notReadyAdapter(); + }); + afterAll(() => { + if (original) ADAPTER_REGISTRY[KEY] = original; + else delete ADAPTER_REGISTRY[KEY]; + }); + + it('目标端未安装 → success=false 且错误信息可读', async () => { + const engine = new MigrationEngine('claude-code', KEY); + const result = await engine.migrate('11111111-2222-4333-8444-555555555555', '/tmp'); + expect(result.success).toBe(false); + expect(result.error).toContain('not installed'); + expect(result.error).toContain(KEY); + }); +}); + +describe('MigrationEngine — 写入路径', () => { + const SRC = '__test_src__'; + const KEY = '__test_ready__'; + const originals: Record = {}; + const written: Session[] = []; + + beforeAll(() => { + originals[SRC] = ADAPTER_REGISTRY[SRC]; + originals[KEY] = ADAPTER_REGISTRY[KEY]; + ADAPTER_REGISTRY[SRC] = () => readyAdapter(written); // readSession 返回固定会话 + ADAPTER_REGISTRY[KEY] = () => readyAdapter(written); + }); + afterAll(() => { + for (const [k, v] of Object.entries(originals)) { + if (v) ADAPTER_REGISTRY[k] = v; + else delete ADAPTER_REGISTRY[k]; + } + }); + + it('目标端就绪 → 写入成功且 IR 传给 writeSession', async () => { + written.length = 0; + const engine = new MigrationEngine(SRC, KEY); + const result = await engine.migrate('11111111-2222-4333-8444-555555555555', '/tmp'); + expect(result.success).toBe(true); + expect(result.targetSessionId).toBe('mock-target-id'); + expect(written).toHaveLength(1); + expect(written[0].messages[0].content[0]).toMatchObject({ type: 'text', text: 'hello' }); + }); +}); + +describe('fidelityFromSession — 真实统计', () => { + it('未知工具计 degraded,保真度回落', () => { + const session = makeSession({ + messages: [ + { + role: 'assistant', + content: [ + { type: 'text', text: 'ok' }, + { type: 'tool_call', toolName: 'totally_unknown_tool', callId: 'c1', arguments: {} }, + ], + timestamp: '2026-09-20T00:00:30.000Z', + }, + ], + }); + const report = fidelityFromSession(session, 'claude-code'); + expect(report.totalBlocks).toBe(2); + expect(report.degradedBlocks).toBe(1); + expect(report.warnings.some((w) => w.includes('totally_unknown_tool'))).toBe(true); + expect(report.score).toBeLessThan(1); + }); + + it('已登记工具计 preserved', () => { + const session = makeSession({ + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_call', toolName: 'bash', callId: 'c2', arguments: {} }], + timestamp: '2026-09-20T00:00:30.000Z', + }, + ], + }); + const report = fidelityFromSession(session, 'claude-code'); + expect(report.preservedBlocks).toBe(1); + expect(report.score).toBe(1); + }); + + it('图片块:支持图片的目标计 preserved,不支持的计 degraded', () => { + const session = makeSession({ + messages: [ + { + role: 'user', + content: [ + { + type: 'image', + mimeType: 'image/png', + data: 'aGVsbG8=', + label: 'shot.png', + }, + ], + timestamp: '2026-09-20T00:00:10.000Z', + }, + ], + }); + const toCC = fidelityFromSession(session, 'claude-code'); + expect(toCC.preservedBlocks).toBe(1); + expect(toCC.score).toBe(1); + + const toCodex = fidelityFromSession(session, 'codex'); + expect(toCodex.degradedBlocks).toBe(1); + expect(toCodex.degradations.some((d) => d.startsWith('image_blocks_degraded_to_placeholder'))).toBe( + true, + ); + expect(toCodex.score).toBeLessThan(1); + }); +}); diff --git a/src/session-flow/adapters/claude-code.ts b/src/session-flow/adapters/claude-code.ts index 2ad030950..6b4d956f2 100644 --- a/src/session-flow/adapters/claude-code.ts +++ b/src/session-flow/adapters/claude-code.ts @@ -23,9 +23,12 @@ 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 { deriveTargetSessionId } from '../ids.js'; +import { imagePlaceholderText } from '../ir.js'; import { getClaudeCodeProjectsDir, encodeCwdClaude, + bestEffortDecodeCwdClaude, decodeCwdClaude, readJsonl, readJsonlHead, @@ -35,7 +38,13 @@ import { scanFiles, removeDirRecursive, } from '../fs.js'; -import { cleanTitleText, fallbackTitle, isInjectedText } from '../title.js'; +import { + cleanTitleText, + fallbackTitle, + isInjectedText, + titleFromCandidates, + titleFromUserText, +} from '../title.js'; // --------------------------------------------------------------------------- // 工具名归一化映射 @@ -201,11 +210,33 @@ function parseCcContentBlocks(content: unknown): ContentBlock[] { content: rawContent as string, isError: Boolean(b.is_error ?? false), }); + } else if (btype === 'image') { + // 用户贴进输入框的截图:Anthropic 格式是 + // {type:'image', source:{type:'base64', media_type, data}}(也可能 {type:'url', url})。 + // 不解析的话图片既不进 IR 也不写进目标,保真度还照样算 100%(静默漏报)。 + const src = b.source as Record | undefined; + const data = typeof src?.data === 'string' ? src.data : undefined; + const url = typeof b.url === 'string' ? b.url : (typeof src?.url === 'string' ? src.url : undefined); + if (!data && !url) continue; + blocks.push({ + type: 'image', + mimeType: String(src?.media_type ?? 'image/png'), + // base64 直接带;纯 URL 形态只留指针(写入侧按目标能力降级) + ...(data ? { data } : {}), + ...(url ? { filePath: url } : {}), + label: guessImageLabel(String(src?.media_type ?? 'image/png')), + }); } } return blocks; } +/** 内联图片没有文件名,按 mime 给一个可读的占位名(写回/降级占位符用)。 */ +function guessImageLabel(mimeType: string): string { + const ext = mimeType.split('/')[1]?.replace('jpeg', 'jpg') ?? 'png'; + return `image.${ext}`; +} + // --------------------------------------------------------------------------- // content 块序列化(IR → CC) // --------------------------------------------------------------------------- @@ -234,6 +265,23 @@ function irBlockToCc(block: ContentBlock): Record | null { content: block.content, is_error: block.isError, }; + case 'image': { + // CC 原生支持用户消息里的 base64 图片块。data 缺失(源文件读不到)时 + // 从 filePath 现读;再不行降级占位文本,绝不静默丢块。 + let data = block.data; + if (!data && block.filePath) { + try { + data = fs.readFileSync(block.filePath).toString('base64'); + } catch { + data = undefined; + } + } + if (!data) return { type: 'text', text: imagePlaceholderText(block) }; + return { + type: 'image', + source: { type: 'base64', media_type: block.mimeType, data }, + }; + } } } @@ -299,7 +347,7 @@ export class ClaudeCodeAdapter extends AgentAdapter { for (const projDir of projDirs) { if (!dirExists(projDir)) continue; - const cwd = decodeCwdClaude(path.basename(projDir)); + const cwd = bestEffortDecodeCwdClaude(path.basename(projDir)) ?? 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); @@ -315,10 +363,13 @@ export class ClaudeCodeAdapter extends AgentAdapter { let createdAt: string | undefined; let updatedAt: string | undefined; let messageCount = 0; - let firstUserText = ''; + const userTextCandidates: string[] = []; + let summaryTitle = ''; try { - for (const record of readJsonlHead(jsonlPath, 50)) { + // 50 行常常全是注入块(system-reminder / 命令记录 / snapshot),预算不够会让 + // 有真实提问的会话也 fallback 成 "Session ";200 行与 codex/workbuddy 对齐。 + for (const record of readJsonlHead(jsonlPath, 200)) { const rtype = record.type as string; const ts = parseCcTimestamp(record.timestamp as string); @@ -327,24 +378,25 @@ export class ClaudeCodeAdapter extends AgentAdapter { updatedAt = ts; } + if (rtype === 'summary' && !summaryTitle) { + summaryTitle = String(record.summary ?? ''); + } + if (rtype === 'user' || rtype === 'assistant') { messageCount++; - if (rtype === 'user' && !firstUserText) { + if (rtype === 'user' && userTextCandidates.length < 5) { const msg = record.message as Record | undefined; const content = msg?.content; if (typeof content === 'string') { - if (!isInjectedText(content)) firstUserText = content; + userTextCandidates.push(content); } else if (Array.isArray(content)) { + const parts: string[] = []; 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; - } + parts.push(String((block as Record).text ?? '')); } } + if (parts.length) userTextCandidates.push(parts.join(' ')); } } } @@ -356,7 +408,12 @@ export class ClaudeCodeAdapter extends AgentAdapter { if (!createdAt) createdAt = new Date().toISOString(); if (!updatedAt) updatedAt = createdAt; - title = cleanTitleText(firstUserText) || fallbackTitle(sessionId); + // 标题:summary 行(CC /resume 用的就是它)> 用户文本解包 > id 兜底。 + // 不再按「整条是否注入」跳过—— 等注入头后跟真实提问的混合消息 + // 会被整条丢掉;titleFromUserText 能解 包裹并剥元信息。 + title = summaryTitle ? cleanTitleText(summaryTitle) : ''; + if (!title) title = titleFromCandidates(userTextCandidates); + if (!title) title = fallbackTitle(sessionId); let sizeBytes = 0; try { @@ -384,7 +441,9 @@ export class ClaudeCodeAdapter extends AgentAdapter { 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 cwd = + bestEffortDecodeCwdClaude(path.basename(path.dirname(jsonlPath))) ?? + decodeCwdClaude(path.basename(path.dirname(jsonlPath))); // 收集所有消息记录 const rawRecords: Record[] = []; @@ -409,27 +468,26 @@ export class ClaudeCodeAdapter extends AgentAdapter { } rawRecords.push(record); } - const sessionCwd = nativeCwd ?? cwd; + // 记录里的 cwd 有时是编码目录名(`-Users-foo-project`),不是真实路径: + // 反解成真实工作区,迁移才能「保持源会话的工作区」而不是回退到当前目录。 + const sessionCwd = nativeCwd ?? bestEffortDecodeCwdClaude(cwd) ?? cwd; // DAG 拍平 const messages = this.flattenDag(rawRecords); // 提取标题:优先 summary 标题行(写入侧落盘、CC /resume 亦采用), - // 其次首条非注入用户文本,最后退回 id 前缀。 + // 其次用户文本解包(注入头 + 真实提问的混合消息也能解),最后退回 id 前缀。 let title = summaryTitle ? cleanTitleText(summaryTitle) : ''; if (!title) { + const candidates: string[] = []; 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 (msg.role !== 'user') continue; + for (const block of msg.content) { + if (block.type === 'text' && block.text) candidates.push(block.text); } + if (candidates.length >= 5) break; } + title = titleFromCandidates(candidates); } if (!title) title = fallbackTitle(sessionId); @@ -563,11 +621,11 @@ export class ClaudeCodeAdapter extends AgentAdapter { } async writeSession(session: Session, projectPath?: string): Promise { - // 确定 session_id(必须是 UUIDv4) - let sessionId = session.sessionId; - if (!isUuidV4(sessionId)) { - sessionId = uuidV4(); - } + // 确定 session_id(必须是 UUIDv4):已是 v4 则沿用,否则确定性派生—— + // 随机生成会让重复迁移产生 id 不同、内容相同的重复会话。 + const sessionId = isUuidV4(session.sessionId) + ? session.sessionId + : deriveTargetSessionId(this.platform, session.sessionId); // 确定目标目录 const cwd = projectPath ?? session.cwd; diff --git a/src/session-flow/adapters/codebuddy-ide.ts b/src/session-flow/adapters/codebuddy-ide.ts index cb345012e..6d1326512 100644 --- a/src/session-flow/adapters/codebuddy-ide.ts +++ b/src/session-flow/adapters/codebuddy-ide.ts @@ -20,7 +20,7 @@ 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 type { Session, Message, ContentBlock, ImageBlock, ToolResultBlock } from '../ir.js'; import { findIdeHistoryDirs, findIdeConversationDirs, @@ -34,7 +34,7 @@ import { type IdeConversationEntry, type IdeMessageParsed, } from '../ide-history.js'; -import { cleanTitleText, isInjectedText } from '../title.js'; +import { isInjectedText, titleFromCandidates } from '../title.js'; // --------------------------------------------------------------------------- // 工具 @@ -55,20 +55,88 @@ function unknownWorkspace(hash: string): string { return `md5:${hash}`; } -/** 标题兜底时最多看的消息条数(第一条通常就是用户提问)。 */ -const TITLE_LOOKAHEAD = 8; +const MIME_BY_EXT: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + bmp: 'image/bmp', +}; + +/** + * 解析 IDE 的图片块为 IR ImageBlock。 + * + * IDE 侧三种引用形态都处理: + * - `codebuddy-asset://assets/xxx.png`(相对 convDir,主流形态) + * - 绝对路径(某些版本直接落绝对路径) + * - `data:image/...;base64,....`(内联,无需读文件) + * + * 文件可读时带 base64(写 claude-code 等原生平台用),并始终带 filePath + * (写回 IDE 时按文件复制,避免 base64 往返)。读不到文件也返回 ImageBlock: + * 保真度能如实计一块,写入侧自行降级为占位文本。 + */ +function parseImageBlock(block: Record, convDir: string): ImageBlock | null { + const ref = String(block.image ?? block.url ?? block.path ?? '').trim(); + if (!ref) return null; + + // data URI:直接解出 base64 + const dataUri = ref.match(/^data:(image\/[a-z+]+);base64,(.+)$/i); + if (dataUri) { + return { + type: 'image', + mimeType: dataUri[1].toLowerCase(), + data: dataUri[2], + label: 'inline-image', + }; + } + + // 解析文件路径 + let filePath = ''; + if (ref.startsWith('codebuddy-asset://')) { + const rel = ref.slice('codebuddy-asset://'.length).replace(/^\/+/, ''); + filePath = path.join(convDir, rel); + } else if (path.isAbsolute(ref)) { + filePath = ref; + } else { + filePath = path.join(convDir, ref); + } + + const ext = path.extname(filePath).replace('.', '').toLowerCase(); + const mimeType = MIME_BY_EXT[ext] ?? 'image/png'; + const label = path.basename(filePath); + + let data: string | undefined; + try { + data = fs.readFileSync(filePath).toString('base64'); + } catch { + data = undefined; // 文件缺失(被清理/跨机器):保留指针,写入侧降级 + } + + return { type: 'image', mimeType, data, filePath, label }; +} + +/** + * 标题兜底时最多看的消息条数。 + * + * 8 条常常全是注入/命令记录(slash 命令会话、压缩摘要会话),导致标题 fallback + * 成 "Session ";放宽到 30 条与 claude-code/codex 的扫描预算同量级。 + */ +const TITLE_LOOKAHEAD = 30; function firstUserText(messages: IdeMessageParsed[]): string { + // 收集候选后统一解包:整条注入跳过 ≠ 丢弃,slash 命令类混合消息里的真实提问要救回来 + const candidates: 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; + candidates.push(block.text); } + if (candidates.length >= 5) break; } - return ''; + return titleFromCandidates(candidates); } /** @@ -213,7 +281,7 @@ export class CodeBuddyIdeAdapter extends AgentAdapter { continue; } - const content = this.parseContent(raw); + const content = this.parseContent(raw, convDir); if (content.length === 0) continue; const msg: Message = { @@ -246,7 +314,7 @@ export class CodeBuddyIdeAdapter extends AgentAdapter { }; } - private parseContent(raw: IdeMessageParsed): ContentBlock[] { + private parseContent(raw: IdeMessageParsed, convDir: string): ContentBlock[] { const blocks: ContentBlock[] = []; for (const block of raw.content) { @@ -277,8 +345,16 @@ export class CodeBuddyIdeAdapter extends AgentAdapter { if (irBlock) blocks.push(irBlock); break; } + case 'image': { + // 用户拖进输入框的图片:content 里是 `codebuddy-asset://assets/xxx.png` + // 相对引用,实际文件在 /assets/ 下。此前直接跳过——图片既不进 + // IR,保真度也照算 100%,用户直到打开迁移结果才发现图全没了。 + const img = parseImageBlock(block, convDir); + if (img) blocks.push(img); + break; + } default: - // image / 文件引用等 IDE 特有块:IR 无对应类型,跳过(保真度统计会体现) + // 其他 IDE 特有块(文件引用等):IR 无对应类型,跳过 break; } } diff --git a/src/session-flow/adapters/codebuddy.ts b/src/session-flow/adapters/codebuddy.ts index 1de10e343..dc0e9dcc3 100644 --- a/src/session-flow/adapters/codebuddy.ts +++ b/src/session-flow/adapters/codebuddy.ts @@ -26,6 +26,8 @@ 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 { imagePlaceholderText } from '../ir.js'; +import { deriveTargetSessionId } from '../ids.js'; import { getCodeBuddyProjectsDir, encodeCwdCodeBuddy, @@ -37,7 +39,7 @@ import { dirExists, removeDirRecursive, } from '../fs.js'; -import { cleanTitleText, isInjectedText, fallbackTitle } from '../title.js'; +import { isInjectedText, fallbackTitle, titleFromCandidates } from '../title.js'; // --------------------------------------------------------------------------- // 工具名归一化映射 @@ -54,9 +56,40 @@ const CB_TO_IR_TOOL: Record = { todo_write: 'todo_write', }; -const IR_TO_CB_TOOL: Record = Object.fromEntries( - Object.entries(CB_TO_IR_TOOL).map(([k, v]) => [v, k]), -); +/** + * IR → CodeBuddy 工具名。 + * + * CodeBuddy / WorkBuddy 客户端按「UI 工具名」查表渲染图标与折叠标题,实测原生行里是 + * `Bash` / `Read` / `Write` / `Edit` / `Grep` / `Glob` / `Task` / `TodoWrite` 这类驼峰名。 + * 直接把 IR 的 `read_file` / `bash` 写进去,客户端查不到 → 工具调用显示为空白。 + * 同时把源平台别名(execute_command / replace_in_file / search_file …)收口到这里, + * 否则它们会原样落到目标文件里。 + */ +const IR_TO_CB_TOOL: Record = { + read_file: 'Read', + write_file: 'Write', + edit_file: 'Edit', + bash: 'Bash', + grep: 'Grep', + glob: 'Glob', + task: 'Task', + todo_write: 'TodoWrite', + delete_file: 'DeleteFile', + web_fetch: 'WebFetch', + web_search: 'WebSearch', + semantic_search: 'SemanticSearch', + // 源平台别名 → CodeBuddy 语义工具 + execute_command: 'Bash', + run_command: 'Bash', + write_to_file: 'Write', + replace_in_file: 'Edit', + multi_edit: 'Edit', + search_file: 'Glob', + search_content: 'Grep', + list_dir: 'Glob', + codebase_search: 'SemanticSearch', + read_lints: 'LSP', +}; function normalizeToolName(cbName: string): string { return CB_TO_IR_TOOL[cbName] ?? cbName; @@ -66,6 +99,23 @@ function denormalizeToolName(irName: string): string { return IR_TO_CB_TOOL[irName] ?? irName; } +/** 工具调用在折叠态显示的摘要文本(原生 providerData.argumentsDisplayText)。 */ +function argumentsDisplayText(name: string, args: Record | undefined): string { + if (!args) return name; + const preferred = + args.command ?? args.path ?? args.pattern ?? args.glob_pattern ?? args.target_directory ?? + args.target_file ?? args.query ?? args.url ?? args.filePath; + if (typeof preferred === 'string' && preferred.trim()) { + return preferred.length > 160 ? `${preferred.slice(0, 160)}…` : preferred; + } + try { + const s = JSON.stringify(args); + return s.length > 160 ? `${s.slice(0, 160)}…` : s; + } catch { + return name; + } +} + // --------------------------------------------------------------------------- // UUID / 时间戳工具 // --------------------------------------------------------------------------- @@ -182,7 +232,7 @@ export class CodeBuddyAdapter extends AgentAdapter { let createdAt: string | undefined; let updatedAt: string | undefined; let messageCount = 0; - let firstUserText = ''; + const userTextCandidates: string[] = []; let aiTitle = ''; try { @@ -204,19 +254,16 @@ export class CodeBuddyAdapter extends AgentAdapter { if (rtype === 'message') { messageCount++; const role = record.role as string; - if (role === 'user' && !firstUserText) { + if (role === 'user' && userTextCandidates.length < 5) { const content = record.content; if (Array.isArray(content)) { + const parts: string[] = []; 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; - } + parts.push(String((block as Record).text ?? '')); } } + if (parts.length) userTextCandidates.push(parts.join(' ')); } } } @@ -234,7 +281,7 @@ export class CodeBuddyAdapter extends AgentAdapter { if (aiTitle && !isInjectedText(aiTitle)) { title = aiTitle.slice(0, 60); } else { - title = cleanTitleText(firstUserText) || fallbackTitle(sessionId); + title = titleFromCandidates(userTextCandidates) || fallbackTitle(sessionId); } let sizeBytes = 0; @@ -386,18 +433,16 @@ export class CodeBuddyAdapter extends AgentAdapter { } if (!title) { + // 与 claude-code 同策略:候选文本统一解包(注入头 + 真实提问的混合消息也能救回) + const candidates: string[] = []; 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 (msg.role !== 'user') continue; + for (const block of msg.content) { + if (block.type === 'text' && block.text) candidates.push(block.text); } + if (candidates.length >= 5) break; } + title = titleFromCandidates(candidates); } if (!title) title = fallbackTitle(sessionId); @@ -441,10 +486,10 @@ export class CodeBuddyAdapter extends AgentAdapter { } async writeSession(session: Session, projectPath?: string): Promise { - let sessionId = session.sessionId; - if (!isUuid(sessionId)) { - sessionId = uuidV4(); - } + // 非 UUID 源 id 用确定性派生(同一源会话反复迁移命中同一个 id → 不产生重复会话) + const sessionId = isUuid(session.sessionId) + ? session.sessionId + : deriveTargetSessionId('codebuddy', session.sessionId); const cwd = projectPath ?? session.cwd; const projDir = path.join(getCodeBuddyProjectsDir(), encodeCwdCodeBuddy(cwd)); @@ -452,6 +497,17 @@ export class CodeBuddyAdapter extends AgentAdapter { const records: Record[] = []; + // callId → 工具名:让 function_call_result 行带上真实工具名(而不是一律 'Agent') + const toolNamesByCallId = new Map(); + for (const m of session.messages) { + for (const b of m.content) { + if (b.type === 'tool_call' && b.callId) { + toolNamesByCallId.set(b.callId, denormalizeToolName(b.toolName)); + } + } + } + const resultToolName = (callId: string): string | undefined => toolNamesByCallId.get(callId); + // 1. ai-title 行 records.push({ timestamp: toUnixMs(session.createdAt), @@ -498,6 +554,13 @@ export class CodeBuddyAdapter extends AgentAdapter { text: block.text, }); hasText = true; + } else if (block.type === 'image') { + // CLI 存储不含图片,降级为占位文本(保真度计 degraded) + cbContent.push({ + type: msg.role === 'user' ? 'input_text' : 'output_text', + text: imagePlaceholderText(block), + }); + hasText = true; } } @@ -519,18 +582,24 @@ export class CodeBuddyAdapter extends AgentAdapter { } // function_call 行(tool_call blocks) + // 原生结构要点:顶层 `arguments` 是 JSON 字符串(客户端展开参数面板读它)、 + // providerData.argumentsDisplayText 是折叠态摘要、callId 必须非空(与结果配对)。 for (const block of otherBlocks) { if (block.type === 'tool_call') { const fcId = block.callId || uuidV4(); + const callId = block.callId || `call_${uuidV4()}`; + const name = denormalizeToolName(block.toolName); records.push({ id: fcId, parentId, timestamp: toUnixMs(msg.timestamp), type: 'function_call', - name: denormalizeToolName(block.toolName), - callId: block.callId, + name, + callId, + arguments: JSON.stringify(block.arguments ?? {}), providerData: { arguments: block.arguments, + argumentsDisplayText: argumentsDisplayText(name, block.arguments), ...(msg.metadata?.model ? { model: msg.metadata.model } : {}), }, sessionId, @@ -541,15 +610,17 @@ export class CodeBuddyAdapter extends AgentAdapter { } // function_call_result 行(tool_result blocks) + // name 用真实工具名:写死 'Agent' 会让所有工具结果都显示成 "Agent"。 for (const block of otherBlocks) { if (block.type === 'tool_result') { const fcrId = uuidV4(); + const name = resultToolName(block.callId) ?? 'Agent'; records.push({ id: fcrId, parentId, timestamp: toUnixMs(msg.timestamp), type: 'function_call_result', - name: 'Agent', + name, callId: block.callId, status: block.isError ? 'failed' : 'completed', output: { type: 'text', text: block.content }, diff --git a/src/session-flow/adapters/codex.ts b/src/session-flow/adapters/codex.ts index 4872fa8f6..47092c590 100644 --- a/src/session-flow/adapters/codex.ts +++ b/src/session-flow/adapters/codex.ts @@ -14,13 +14,33 @@ * - 写入时生成 event_msg:task_started + task_complete * - reasoning 块写入为 response_item:reasoning(而非跳过) * - 支持 custom_tool_call / custom_tool_call_output + * + * 写入的 rollout 必须能被 Codex 直接索引(否则会话不会出现在 Codex Desktop 历史列表): + * - session_meta.payload.model_provider:Codex 按 provider 分桶展示会话,只有与 + * ~/.codex/config.toml 当前 model_provider 一致的会话才会出现在列表里(见 + * https://github.com/farion1231/cc-switch/issues/4710)。缺失/写错 → 会话静默消失。 + * - 不声明 history_mode:Codex 0.155+ 把无声明的 rollout 当 legacy,由 + * `codex migrate-rollouts --apply` 转成分页历史并建立 items 投影;自己声明 'paginated' + * 会被当成 already-paginated 跳过迁移 → 无投影 → 列表无预览、打开空白。 + * - 每行顶层 ordinal:分页游标依赖它,缺失时 thread/items/list 返回空。 + * - 至少一条 event_msg:item_completed 的 UserMessage:标题与列表预览取自第一条用户 + * item;元信息块(/包裹的时间戳头等)会被整条丢弃, + * 导致没有标题/预览 → 不显示。 + * - 写完后主动调用 codex CLI 建投影(paginateRollout),保证迁移完立刻可见。 */ import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { homedir } from 'node:os'; import { AgentAdapter, type SessionMeta } from './base.js'; import type { Session, Message, ContentBlock, TextBlock, ThinkingBlock, ToolCallBlock, ToolResultBlock } from '../ir.js'; +import { imagePlaceholderText } from '../ir.js'; +import { titleFromUserText, visibleUserText } from '../title.js'; +import { deriveTargetSessionId } from '../ids.js'; +import { findSqlite3 } from '../sqlite.js'; import { getCodexSessionsDir, resolveRealCwd, @@ -61,8 +81,12 @@ function denormalizeToolName(irName: string): string { function generateUuidV7(): string { const timestampMs = Date.now(); - // 前 48 位时间戳左移 80 位 - let uuidInt = BigInt(timestampMs & 0xffffffffffff) << 80n; + // 前 48 位时间戳左移 80 位。 + // 注意:必须用 BigInt 按位与(0xffffffffffffn)。 + // Number 的 `&` 运算符是 32 位有符号按位与,时间戳超过 2^31 会变成负数, + // 导致后续 BigInt 为负、toString(16) 输出带负号的非法 UUID, + // 使 Codex 端 Uuid 反序列化失败、整个会话被忽略(迁移后 Codex 里看不到)。 + let uuidInt = (BigInt(timestampMs) & 0xffffffffffffn) << 80n; // 版本位 7(位 76-79) uuidInt |= 7n << 76n; // 随机位(低 62 位) @@ -109,6 +133,148 @@ function formatFilenameTimestamp(isoStr: string): string { return isoStr.replace(/\.\d{3}Z$/, '').replace(/:/g, '-'); } +// --------------------------------------------------------------------------- +// Codex 配置探测 / 元信息清理 / CLI 探测 +// --------------------------------------------------------------------------- + +/** + * 读取 Codex 当前生效的 model_provider(config.toml 顶层 `model_provider = "..."`)。 + * + * Codex Desktop 的会话列表按 provider 分桶:只有与当前配置一致的会话才会展示, + * 切换 provider 后旧会话“消失”就是这个机制。迁移写入的 rollout 必须带上当前值。 + */ +function readCodexModelProvider(configPath: string): string { + try { + if (!fileExists(configPath)) return 'openai'; + const raw = fs.readFileSync(configPath, 'utf-8'); + const m = raw.match(/^[ \t]*model_provider[ \t]*=[ \t]*["']([^"']+)["']/m); + return m?.[1]?.trim() || 'openai'; + } catch { + return 'openai'; + } +} + +/** + * 源平台注入的“纯元信息”块。它们不是真实用户输入,而 Codex 用第一条 UserMessage item + * 生成标题与列表预览,首条消息若是元信息会被整条丢弃 → 会话没有 title/preview → + * 不出现在历史列表。 + * + * 注意:1) 只列纯元信息标签, 之类包裹真实提问的标签由 extractUserText + * 单独处理;2) 不锚定行首——前一块剥离后剩余文本常以 \n\n 开头,行首锚定会导致 + * 后续块匹配失败;3) system_reminder 同时覆盖下划线(CodeBuddy)与连字符(Claude Code)。 + */ +const META_BLOCK_RE = + /<(user_info|rules|environment_context|system-reminder|system_reminder|system_instructions|available_skills|agent_request|local-command-caveat|uploaded_documents|additional_data|timestamp)[^>]*>[\s\S]*?<\/\1>[ \t]*\r?\n?/gi; + +function stripMetaBlocks(text: string): string { + let out = text; + for (let i = 0; i < 10; i++) { + const next = out.replace(META_BLOCK_RE, ''); + if (next === out) break; + out = next; + } + return out.trim(); +} + +/** + * 从一条用户消息里提取真实用户输入。 + * CodeBuddy / Cursor 会把真实提问包在 ... 里(外层还挂着大段 + * / 元信息),直接取包裹内容最干净;没有该包裹的平台走元信息剥离。 + */ +function extractUserText(text: string): string { + const qm = text.match(/]*>([\s\S]*?)<\/user_query>/i); + return stripMetaBlocks(qm ? qm[1] : text); +} + +/** + * 文本是否值得生成 ThreadItem。 + * 源平台会把水平线/围栏/空列表项序列化成独立文本块("-" / "---" / "*" / "```" 等), + * 它们作为消息渲染出来就是一颗颗空 bullet。这类纯 markdown 修饰符块跳过不发 item + * (response_item 仍保留原文,不影响保真度)。 + */ +function isRenderableText(text: string): boolean { + return /[^\s\-*•·>#`|~_+=()[\]!.,;:?"'\\/0-9—–‘’“”…]/.test(text); +} + +interface ItemCompletedArgs { + timestamp: string; + sessionId: string; + turnId: string; + itemId: string; + itemType: 'UserMessage' | 'AgentMessage'; + text: string; +} + +/** + * event_msg:item_completed —— Codex Desktop 真正渲染(并用于生成标题/预览)的 ThreadItem。 + * UserMessage 与 AgentMessage 的 content type 大小写不一致(text / Text),照抄原生格式。 + * 对齐 0.155 原生格式: + * - payload 必须带 started_at_ms / completed_at_ms(缺失导致反序列化失败、item 被丢弃) + * - UserMessage item 不写 client_id(原生无此字段,写入会导致 UserMessage 解析失败, + * 表现为 thread/items/list 投影为空、会话打开后一片空白) + */ +function buildItemCompletedRecord(args: ItemCompletedArgs): Record { + const isUser = args.itemType === 'UserMessage'; + const item: Record = { + type: args.itemType, + id: args.itemId, + content: isUser + ? [{ type: 'text', text: args.text, text_elements: [] }] + : [{ type: 'Text', text: args.text }], + }; + const ms = new Date(args.timestamp).getTime(); + return { + timestamp: args.timestamp, + type: 'event_msg', + payload: { + type: 'item_completed', + thread_id: args.sessionId, + turn_id: args.turnId, + item, + started_at_ms: ms, + completed_at_ms: ms, + }, + }; +} + +function pushItemCompleted(records: Record[], args: ItemCompletedArgs): void { + records.push(buildItemCompletedRecord(args)); +} + +const execFileAsync = promisify(execFile); + +/** Codex Desktop (ChatGPT.app) 自带的 codex CLI 位置(macOS)。 */ +const DESKTOP_CODEX_CANDIDATES = [ + '/Applications/ChatGPT.app/Contents/Resources/codex', + `${homedir()}/Applications/ChatGPT.app/Contents/Resources/codex`, + '/Applications/Codex.app/Contents/Resources/codex', +]; + +function findCodexCli(): string | null { + // 1) PATH 上的 codex(npm / brew 安装) + const pathEnv = process.env.PATH ?? ''; + for (const dir of pathEnv.split(path.delimiter)) { + if (!dir) continue; + const p = path.join(dir, 'codex'); + try { + fs.accessSync(p, fs.constants.X_OK); + return p; + } catch { + // continue + } + } + // 2) Codex Desktop 自带的 CLI + for (const p of DESKTOP_CODEX_CANDIDATES) { + try { + fs.accessSync(p, fs.constants.X_OK); + return p; + } catch { + // continue + } + } + return null; +} + // --------------------------------------------------------------------------- // CodexAdapter // --------------------------------------------------------------------------- @@ -214,22 +380,40 @@ export class CodexAdapter extends AgentAdapter { // ignore } - // 统计消息数(快速扫描 response_item:message) + // 单次有限扫描:统计消息数 + 提取内容标题。 + // 标题取首条真实用户文本(item_completed 的 UserMessage 或 response_item 的 + // user message,后者覆盖无 item_completed 的老 legacy rollout)——此前只从 + // 文件名生成 `Session <时间戳>`,列表里一整排时间戳没法辨认。 let messageCount = 0; + let contentTitle = ''; try { for (const rec of readJsonlHead(f, 200)) { - if (rec.type === 'response_item') { - const payload = (rec.payload as Record) ?? {}; - if (payload.type === 'message') messageCount++; + const recPayload = (rec.payload as Record) ?? {}; + if (rec.type === 'response_item' && recPayload.type === 'message') messageCount++; + + if (contentTitle) continue; + let text = ''; + if (rec.type === 'event_msg' && recPayload.type === 'item_completed') { + const item = recPayload.item as Record | undefined; + if (item?.type !== 'UserMessage') continue; + const content = item.content as Array> | undefined; + text = (content ?? []).map((c) => String(c.text ?? '')).join(' '); + } else if (rec.type === 'response_item' && recPayload.type === 'message' && recPayload.role === 'user') { + const content = recPayload.content as Array> | undefined; + text = (content ?? []).map((c) => String(c.text ?? '')).join(' '); } + if (!text) continue; + const cleaned = titleFromUserText(visibleUserText(text)); + if (cleaned) contentTitle = cleaned; } } catch { // ignore } + const resolvedTitle = contentTitle || title; metas.push({ sessionId, - title, + title: resolvedTitle, cwd, platform: this.platform, createdAt, @@ -398,18 +582,18 @@ export class CodexAdapter extends AgentAdapter { } async writeSession(session: Session, projectPath?: string): Promise { - // session_id: 确保是 UUIDv7 - let sessionId = session.sessionId; - if (!isUuidV7(sessionId)) { - sessionId = generateUuidV7(); - } + // session_id: 已是 UUIDv7 则沿用;否则**确定性派生**而非随机生成—— + // 随机会让同一源会话每次迁移都产出新的目标 id,Codex 里出现内容完全重复的 + // 第二个线程(threads 行数翻倍)。派生后重迁移=覆盖,天然幂等。 + const sessionId = isUuidV7(session.sessionId) + ? session.sessionId + : deriveTargetSessionId(this.platform, session.sessionId); // 损坏输入防御: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); // 文件路径 @@ -426,16 +610,30 @@ export class CodexAdapter extends AgentAdapter { const records: Record[] = []; // 1. session_meta + // 注意:Codex 端 SessionMeta.payload.timestamp 必须是 RFC3339 字符串(非 epoch 毫秒数字), + // source 必须是 SessionSource 合法枚举值('cli'/'vscode'/...)。 + // 非交互来源(自定义字符串)会被 INTERACTIVE_SESSION_SOURCES 过滤, + // 导致会话不在 Codex 列表中显示。 + // model_provider 必须跟随 ~/.codex/config.toml(按 provider 分桶展示,见文件头注释)。 + // 不声明 history_mode:0.155+ 会把 rollout 当 legacy 并由 `codex migrate-rollouts --apply` + // 转成分页历史 + 建立 items 投影(标题/预览/内容都来自这次投影)。自己声明 'paginated' + // 反而会跳过迁移——线程没有投影,列表无预览、打开空白。 + const modelProvider = readCodexModelProvider( + path.join(path.dirname(this.storageRoot), 'config.toml'), + ); records.push({ timestamp: tsIso, type: 'session_meta', payload: { id: sessionId, - timestamp: tsMs, + session_id: sessionId, + timestamp: tsIso, cwd: projectPath ?? session.cwd, - originator: 'sessionflow', + originator: 'codex_cli_rs', cli_version: '0.1.0', - source: 'migration', + source: 'cli', + thread_source: 'user', + model_provider: modelProvider, }, }); @@ -445,6 +643,23 @@ export class CodexAdapter extends AgentAdapter { // 消息原生时间戳优先——全部用迁移时刻会让时间线塌缩成一点, // 经 claude-code 中转后甚至无法恢复先后顺序 let lastTs = tsIso; + // Codex Desktop 的会话界面渲染的是 event_msg:item_completed 里的 ThreadItem + // (UserMessage / AgentMessage),只写 response_item 会导致会话能打开但内容空白。 + let itemCount = 0; + let lastAgentMessage: string | undefined; + let userItemEmitted = false; + // 第一条 UserMessage item 决定会话标题与列表预览。若整个会话里没有一句真实用户输入 + // (全部是源平台注入的元信息),兜底写一条迁移说明,否则会话没有 preview 而不可见。 + const fallbackUserText = session.messages.some( + (m) => + m.role === 'user' && + m.content.some((b) => b.type === 'text' && isRenderableText(visibleUserText(b.text))), + ) + ? null + : `Migrated session from ${session.platform || 'external agent'}`; + // 兜底 item 的插入位置(第一个 turn 的 turn_context 之后) + let firstTurnInsertAt = -1; + let firstTurnId = ''; for (const msg of session.messages) { const parsedTs = msg.timestamp ? new Date(msg.timestamp) : null; @@ -462,10 +677,12 @@ export class CodexAdapter extends AgentAdapter { payload: { type: 'task_complete', turn_id: turnId, + ...(lastAgentMessage ? { last_agent_message: lastAgentMessage } : {}), completed_at: Math.floor(new Date(msgTs).getTime() / 1000), }, }); } + lastAgentMessage = undefined; // 新 turn turnId = generateUuidV7(); records.push({ @@ -484,18 +701,75 @@ export class CodexAdapter extends AgentAdapter { turn_id: turnId, cwd: projectPath ?? session.cwd, workspace_roots: [projectPath ?? session.cwd], + // Codex 端 TurnContextItem 的 approval_policy / sandbox_policy 为必填字段, + // 缺失会导致整行反序列化失败 + approval_policy: 'on-request', + sandbox_policy: { + type: 'workspace-write', + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, }, }); turnStarted = true; + if (firstTurnInsertAt < 0) { + firstTurnInsertAt = records.length; + firstTurnId = turnId; + } } // 写消息的每个 content block for (const block of msg.content) { const rec = this.blockToResponseItem(msg.role, block, msgTs); if (rec) records.push(rec); + + // 同步生成 UI 渲染用的 item_completed 事件(对齐 0.155 原生格式,见 + // buildItemCompletedRecord 注释)。 + if (block.type !== 'text') continue; + + if (msg.role === 'user') { + // 源平台注入的元信息(///…)与附件路径 + // (@image:/path)都不是真实用户输入,剥掉后剩下的才是标题/预览要用的文本。 + // 整块都是元信息则跳过。 + const userText = visibleUserText(block.text); + if (!isRenderableText(userText)) continue; + userItemEmitted = true; + pushItemCompleted(records, { + timestamp: msgTs, + sessionId, + turnId, + itemId: `item-${++itemCount}`, + itemType: 'UserMessage', + text: userText, + }); + } else if (isRenderableText(block.text)) { + lastAgentMessage = block.text; + pushItemCompleted(records, { + timestamp: msgTs, + sessionId, + turnId, + itemId: `item-${++itemCount}`, + itemType: 'AgentMessage', + text: block.text, + }); + } } } + // 没有任何真实用户输入时补一条兜底 UserMessage,保证会话有标题/预览。 + if (!userItemEmitted && fallbackUserText && firstTurnInsertAt >= 0) { + const fallbackRecord = buildItemCompletedRecord({ + timestamp: tsIso, + sessionId, + turnId: firstTurnId, + itemId: 'item-0', + itemType: 'UserMessage', + text: fallbackUserText, + }); + records.splice(firstTurnInsertAt, 0, fallbackRecord); + } + // 最后一个 turn 的 task_complete if (turnStarted) { records.push({ @@ -504,15 +778,153 @@ export class CodexAdapter extends AgentAdapter { payload: { type: 'task_complete', turn_id: turnId, + ...(lastAgentMessage ? { last_agent_message: lastAgentMessage } : {}), completed_at: Math.floor(new Date(lastTs).getTime() / 1000), }, }); } - writeJsonl(filePath, records); + // 每行补 ordinal:新版 Codex 用它做 rollout 行序号与 items 游标分页, + // 缺失时 thread/items/list 返回空,会话打开后一片空白。 + // 字段顺序与原生 rollout 保持一致(timestamp, ordinal, type, payload)。 + const ordered = records.map((rec, idx) => ({ + timestamp: rec.timestamp, + ordinal: idx, + type: rec.type, + payload: rec.payload, + })); + + writeJsonl(filePath, ordered); + + // 3. 让 Codex CLI 把 legacy rollout 转成分页历史并建立 items 投影(标题/预览/内容)。 + await this.paginateRollout(sessionId, path.dirname(this.storageRoot)); return sessionId; } + /** + * 触发 `codex migrate-rollouts --apply --thread `,把刚写入的 legacy rollout 转成 + * 分页历史并建立 items 投影。 + * + * 不跑这一步,会话在 Codex Desktop 里:列表无标题/预览(不可见),打开后内容空白 + * (items 投影只有在 legacy→paginated 迁移时才会建立)。 + * + * 新写入的 rollout 还没进 state_5.sqlite 时,定向迁移会报 missing_sqlite_metadata; + * 此时起一个临时 app-server 调一次 thread/list(官方索引入口,会把新 rollout 登记 + * 进 threads 表并算出标题/预览),再重试定向迁移。所有步骤均为 best-effort:找不到 + * codex CLI 或仍失败时保持 legacy 原样,由 Codex 自身启动迁移兜底,不算迁移失败。 + */ + private async paginateRollout(sessionId: string, codexHome: string): Promise { + const bin = findCodexCli(); + if (!bin) return; + const env = { ...process.env, CODEX_HOME: codexHome }; + const opts = { env, timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }; + + const runApply = async (): Promise => { + let stdout = ''; + try { + const r = await execFileAsync( + bin, + ['migrate-rollouts', '--apply', '--thread', sessionId, '--json'], + opts, + ); + stdout = r.stdout ?? ''; + } catch (e) { + // 退出码非 0(如预存损坏 rollout 导致 "one or more rollout migrations failed") + // 时 stdout 仍带完整 JSON 报告,取出来判断本线程的结果。 + stdout = (e as { stdout?: string }).stdout ?? ''; + } + try { + const report = JSON.parse(stdout) as { + outcomes?: { thread_id: string; status: string }[]; + }; + return report.outcomes?.find((o) => o.thread_id === sessionId)?.status; + } catch { + return undefined; + } + }; + + let status = await runApply(); + if (status === 'migrated' || status === 'already_paginated') return; + + // 未索引(missing_sqlite_metadata 等)→ 让 app-server 的 thread/list 登记新文件,重试 + await this.indexThreadViaAppServer(bin, codexHome); + await runApply(); + } + + /** + * 起一个临时 `codex app-server`,initialize + thread/list(官方索引入口:会扫描 + * sessions 目录、把新 rollout upsert 进 state_5.threads 并计算标题/预览),拿到 + * thread/list 响应后立即退出。任何异常都静默结束(best-effort)。 + */ + private indexThreadViaAppServer(bin: string, codexHome: string): Promise { + return new Promise((resolve) => { + let child: ReturnType; + try { + child = spawn(bin, ['app-server'], { + env: { ...process.env, CODEX_HOME: codexHome, RUST_LOG: 'error' }, + stdio: ['pipe', 'pipe', 'ignore'], + }); + } catch { + resolve(); + return; + } + + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + child.stdin?.end(); + } catch { + // ignore + } + try { + child.kill(); + } catch { + // ignore + } + resolve(); + }; + const timer = setTimeout(finish, 30_000); + + const send = (obj: unknown) => { + try { + child.stdin?.write(JSON.stringify(obj) + '\n'); + } catch { + // ignore + } + }; + + let buffer = ''; + child.stdout?.on('data', (chunk: Buffer) => { + buffer += chunk.toString(); + let idx: number; + while ((idx = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + if (line.includes('"id":1')) { + send({ jsonrpc: '2.0', method: 'initialized', params: {} }); + send({ jsonrpc: '2.0', id: 2, method: 'thread/list', params: { limit: 50 } }); + } else if (line.includes('"id":2')) { + finish(); + return; + } + } + }); + child.on('error', finish); + child.on('exit', finish); + + send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { clientInfo: { name: 'teamai', title: 'teamai', version: '0.0.0' } }, + }); + }); + } + private blockToResponseItem(role: string, block: ContentBlock, timestamp?: string): Record | null { const ts = timestamp ?? new Date().toISOString(); @@ -524,11 +936,28 @@ export class CodexAdapter extends AgentAdapter { type: 'response_item', payload: { type: 'message', + // 当前版本 Codex 的 ResponseItem::Message 要求必填 id(msg_ 格式), + // 缺失时整行反序列化失败,resume 重放产出 0 个 item,UI 显示空白 + id: `msg_${generateUuidV7()}`, role, content: [{ type: contentType, text: block.text }], }, }; } + case 'image': { + // rollout 的消息只支持 text,图片降级为占位文本(保真度计 degraded) + const contentType = role === 'user' ? 'input_text' : 'output_text'; + return { + timestamp: ts, + type: 'response_item', + payload: { + type: 'message', + id: `msg_${generateUuidV7()}`, + role, + content: [{ type: contentType, text: imagePlaceholderText(block) }], + }, + }; + } case 'tool_call': { const codexName = denormalizeToolName(block.toolName); return { @@ -536,6 +965,7 @@ export class CodexAdapter extends AgentAdapter { type: 'response_item', payload: { type: 'function_call', + id: `fc_${generateUuidV7()}`, name: codexName, arguments: JSON.stringify(block.arguments), call_id: block.callId, @@ -548,6 +978,7 @@ export class CodexAdapter extends AgentAdapter { type: 'response_item', payload: { type: 'function_call_output', + id: `fcoutput_${generateUuidV7()}`, call_id: block.callId, output: block.content, }, @@ -560,6 +991,7 @@ export class CodexAdapter extends AgentAdapter { type: 'response_item', payload: { type: 'reasoning', + id: `rs_${generateUuidV7()}`, content: [], rawContent: [{ type: 'reasoning_text', text: block.text }], }, @@ -569,6 +1001,10 @@ export class CodexAdapter extends AgentAdapter { } async deleteSession(sessionId: string, projectPath?: string): Promise { + // 先摘索引,再删正文。只删 rollout 会让 Codex 列表里留下一条 title/preview 都在 + // 但点开空白的孤儿会话(threads 行与 items 投影仍在),回滚等于没回滚。 + await this.unregisterThread(sessionId); + const f = this.findSessionFile(sessionId); if (f && fileExists(f)) { try { @@ -578,4 +1014,45 @@ export class CodexAdapter extends AgentAdapter { } } } + + /** + * 删除 Codex 两库里的会话痕迹:state_5.threads(列表项)+ thread_history_1 的 + * items/turns/投影水位。best-effort:CLI 缺失或加锁失败都不影响 rollout 删除。 + */ + private async unregisterThread(sessionId: string): Promise { + const bin = findSqlite3(); + if (!bin) return; + const home = path.dirname(this.storageRoot); // ~/.codex + const stmts: Array<[string, string[]]> = [ + [ + path.join(home, 'state_5.sqlite'), + [ + `DELETE FROM threads WHERE id='${sessionId}';`, + `DELETE FROM thread_history_projection_state WHERE thread_id='${sessionId}';`, + ], + ], + [ + path.join(home, 'thread_history_1.sqlite'), + [ + `DELETE FROM thread_items WHERE thread_id='${sessionId}';`, + `DELETE FROM thread_turns WHERE thread_id='${sessionId}';`, + ], + ], + ]; + // 逐条执行、不用事务:不同 Codex 版本的表结构不一致(如无 projection_state 表), + // 放进同一事务会因一条报错整体回滚,连 threads 都删不掉。 + for (const [db, sqls] of stmts) { + if (!fileExists(db)) continue; + for (const sql of sqls) { + try { + await execFileAsync(bin, [db, `PRAGMA busy_timeout=5000; ${sql}`], { + timeout: 30_000, + maxBuffer: 16 * 1024 * 1024, + }); + } catch { + // 表不存在 / 加锁失败:跳过,不影响其它清理 + } + } + } + } } diff --git a/src/session-flow/adapters/cursor.ts b/src/session-flow/adapters/cursor.ts index a3d51b34c..437c8c6f0 100644 --- a/src/session-flow/adapters/cursor.ts +++ b/src/session-flow/adapters/cursor.ts @@ -24,6 +24,7 @@ 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 { imagePlaceholderText } from '../ir.js'; import { getCursorProjectsDir, encodeCwdGeneric, @@ -35,7 +36,9 @@ import { dirExists, removeDirRecursive, } from '../fs.js'; -import { cleanTitleText, fallbackTitle, isInjectedText } from '../title.js'; +import { cleanTitleText, fallbackTitle, isInjectedText, titleFromCandidates, titleFromUserText, extractUserText, isRenderableText, visibleUserText } from '../title.js'; +import { registerCursorComposer, unregisterCursorComposer, type CursorComposerMessage, type CursorComposerTool } from '../cursor-store.js'; +import { log } from '../../utils/logger.js'; // --------------------------------------------------------------------------- // 工具名归一化映射 @@ -57,9 +60,44 @@ const CURSOR_TO_IR_TOOL: Record = { SemanticSearch: 'semantic_search', }; -const IR_TO_CURSOR_TOOL: Record = Object.fromEntries( - Object.entries(CURSOR_TO_IR_TOOL).map(([k, v]) => [v, k]), -); +/** + * IR → Cursor 工具名。 + * + * 不能由 CURSOR_TO_IR_TOOL 反转得到:反转时同键后者覆盖前者,短别名(Read/Write/Edit) + * 会盖掉完整名(ReadFile/WriteFile/EditFile),写进 Cursor 的名字与预期相反。 + * 另外源平台(CodeBuddy / Claude Code)的别名也要在这里收口,否则会原样透传成 + * execute_command / replace_in_file 之类的「Cursor 认不出的工具」。 + */ +const IR_TO_CURSOR_TOOL: Record = { + read_file: 'ReadFile', + write_file: 'WriteFile', + edit_file: 'EditFile', + bash: 'Shell', + grep: 'Grep', + glob: 'Glob', + delete_file: 'DeleteFile', + web_fetch: 'WebFetch', + web_search: 'WebSearch', + semantic_search: 'SemanticSearch', + // 源平台别名 → Cursor 语义工具 + execute_command: 'Shell', + run_command: 'Shell', + write_to_file: 'WriteFile', + replace_in_file: 'EditFile', + multi_edit: 'EditFile', + search_file: 'Glob', + search_content: 'Grep', + list_dir: 'Glob', + codebase_search: 'SemanticSearch', +}; + +/** + * 工具结果与 thinking 不进 DB 气泡正文: + * - thinking:引擎把 ThinkingBlock 降级成 `…` 文本块(Cursor 不支持 + * thinking)。这层包裹留在正文里会让 Cursor 按 HTML 块渲染,markdown 与换行全部失效。 + * - 工具结果:原生存在 assistant 的 tool 气泡 `toolFormerData.result` 里,不单独成消息。 + */ +const THINKING_WRAP_RE = /\s*[\s\S]*?\s*<\/thinking>/gi; function normalizeToolName(cursorName: string): string { return CURSOR_TO_IR_TOOL[cursorName] ?? cursorName; @@ -87,6 +125,25 @@ function uuidV4(): string { return crypto.randomUUID(); } +/** + * 由源会话 id 确定性派生一个 Cursor composerId(UUID v8 形状)。 + * + * 非 UUID 的源 id(如 codebuddy 的 60062279ff104372bc110594720a8016)若每次随机生成, + * 同一会话反复迁移会各留一份副本:transcript 与 composerHeaders 都堆积重复条目, + * 而且无法按源 id 回滚。派生后同一源会话永远命中同一个 composerId(重迁移=覆盖)。 + */ +function deriveCursorId(sourcePlatform: string, sourceId: string): string { + const hex = crypto.createHash('sha256').update(`teamai:cursor:${sourcePlatform}:${sourceId}`).digest('hex'); + const variant = ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + `8${hex.slice(13, 16)}`, + `${variant}${hex.slice(17, 20)}`, + hex.slice(20, 32), + ].join('-'); +} + // --------------------------------------------------------------------------- // CursorAdapter // --------------------------------------------------------------------------- @@ -168,7 +225,7 @@ export class CursorAdapter extends AgentAdapter { let createdAt: string | undefined; let updatedAt: string | undefined; let messageCount = 0; - let firstUserText = ''; + const userTextCandidates: string[] = []; try { const stat = fs.statSync(jsonlPath); @@ -180,7 +237,8 @@ export class CursorAdapter extends AgentAdapter { } try { - for (const record of readJsonlHead(jsonlPath, 50)) { + // 50 行常全是注入块,预算不够会让有真实提问的会话也 fallback 成 "Session " + for (const record of readJsonlHead(jsonlPath, 200)) { // 消息行没有 type 字段 if (record.type === 'turn_ended') continue; @@ -188,20 +246,17 @@ export class CursorAdapter extends AgentAdapter { if (role !== 'user' && role !== 'assistant') continue; messageCount++; - if (role === 'user' && !firstUserText) { + if (role === 'user' && userTextCandidates.length < 5) { const msg = record.message as Record | undefined; const content = msg?.content; if (Array.isArray(content)) { + const parts: string[] = []; 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; - } + parts.push(String((block as Record).text ?? '')); } } + if (parts.length) userTextCandidates.push(parts.join(' ')); } } } @@ -209,7 +264,7 @@ export class CursorAdapter extends AgentAdapter { return null; } - title = cleanTitleText(firstUserText) || fallbackTitle(sessionId); + title = titleFromCandidates(userTextCandidates) || fallbackTitle(sessionId); let sizeBytes = 0; try { @@ -348,10 +403,10 @@ export class CursorAdapter extends AgentAdapter { } async writeSession(session: Session, projectPath?: string): Promise { - let sessionId = session.sessionId; - if (!isUuid(sessionId)) { - sessionId = uuidV4(); - } + // 确定性 id:同一源会话反复迁移命中同一个 composerId(不再每次生成副本) + const sessionId = isUuid(session.sessionId) + ? session.sessionId + : deriveCursorId(session.platform || 'unknown', session.sessionId); const cwd = projectPath ?? session.cwd; const projDir = path.join(getCursorProjectsDir(), encodeCwdGeneric(cwd)); @@ -359,10 +414,64 @@ export class CursorAdapter extends AgentAdapter { const jsonlPath = path.join(transcriptDir, `${sessionId}.jsonl`); const records: Record[] = []; + // 原生 transcript 的 turn 语义:1 条 user + N 条连续 assistant + 1 条 turn_ended。 + // 之前按「每条 assistant 后都写 turn_ended」,把一次工具轮次切成了几十个 turn。 + let lastAssistantRecord: Record | null = null; + let lastUserRecord: Record | null = null; + let turnHasAssistant = false; + + const endTurn = () => { + if (turnHasAssistant) { + records.push({ type: 'turn_ended', status: 'success' }); + turnHasAssistant = false; + lastAssistantRecord = null; + lastUserRecord = null; + } + }; for (const msg of session.messages) { - const cursorContent: Record[] = []; + if (msg.role === 'user') { + // 源平台把工具结果放在 user 消息里:它属于上一个 assistant 的工具调用, + // 所以挂回上一条 assistant 记录(作为文本块),而不是变成一条「假 user 消息」。 + const toolResults: string[] = []; + const cursorContent: Record[] = []; + for (const block of msg.content) { + if (block.type === 'tool_result') { + toolResults.push(`[tool_result${block.isError ? ' (error)' : ''}]\n${block.content}`); + } else if (block.type === 'text') { + cursorContent.push({ type: 'text', text: block.text }); + } else if (block.type === 'thinking') { + cursorContent.push({ type: 'text', text: `\n${block.text}\n` }); + } + } + if (toolResults.length > 0 && lastAssistantRecord) { + const content = lastAssistantRecord.message as { content: Record[] }; + for (const t of toolResults) content.content.push({ type: 'text', text: t }); + } + + // 真实用户提问:同一 turn 内连续的用户消息合并进同一条(原生不会出现连续 user) + if (cursorContent.length > 0) { + if (lastUserRecord && !turnHasAssistant) { + const prev = lastUserRecord.message as { content: Record[] }; + prev.content.push(...cursorContent); + } else { + endTurn(); + const rec: Record = { + role: 'user', + // 写入 cwd 使 readSession 能恢复真实路径(归档键派生依赖它) + cwd, + message: { content: cursorContent }, + }; + records.push(rec); + lastUserRecord = rec; + } + } + continue; + } + + if (msg.role !== 'assistant') continue; + const cursorContent: Record[] = []; for (const block of msg.content) { switch (block.type) { case 'text': @@ -375,40 +484,153 @@ export class CursorAdapter extends AgentAdapter { case 'tool_call': cursorContent.push({ type: 'tool_use', + // id 让 readSession 能把 tool_use 与 tool_result 配对(读端就认它) + id: block.callId || `tool_${cursorContent.length}`, 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; + case 'image': + // Cursor 存储不含图片,降级为占位文本(保真度计 degraded) + cursorContent.push({ type: 'text', text: imagePlaceholderText(block) }); + break; } } if (cursorContent.length > 0) { - records.push({ - role: msg.role, - // 写入 cwd 使 readSession 能恢复真实路径(归档键派生依赖它) + const rec: Record = { + role: 'assistant', cwd, message: { content: cursorContent }, - }); - } - - // 每个 assistant turn 后加 turn_ended - if (msg.role === 'assistant') { - records.push({ type: 'turn_ended', status: 'success' }); + }; + records.push(rec); + lastAssistantRecord = rec; + turnHasAssistant = true; } } + // 收尾最后一个 turn + endTurn(); + writeJsonl(jsonlPath, records); + + // transcript 只是 Cursor 的**导出**产物:Agents Window 的列表来自 state.vscdb 的 + // composerHeaders、正文来自 cursorDiskKV 的 composerData/bubbleId。不注册这一步, + // 会话在 Cursor 里「迁移成功但完全看不见」。注册是 best-effort:失败只影响可见性。 + try { + const title = this.buildComposerTitle(session, sessionId); + const reg = registerCursorComposer({ + cwd, + composerId: sessionId, + title, + messages: this.toComposerMessages(session), + }); + if (!reg.ok) { + // 不静默:transcript 已落盘但列表注册失败,用户在 Cursor 里会「看不到」。 + log.debug(`cursor register failed: composer=${sessionId} reason=${reg.reason ?? 'unknown'}`); + log.warn( + `Cursor session list registration failed (transcript written, session may be invisible in Cursor): ${reg.reason ?? 'unknown'}`, + ); + } + } catch (e) { + log.warn(`Cursor session list registration error: ${(e as Error).message}`); + } return sessionId; } + /** 会话标题:首条真实用户提问 > 源会话标题清洗 > Session 。 */ + private buildComposerTitle(session: Session, sessionId: string): string { + for (const msg of session.messages) { + if (msg.role !== 'user') continue; + for (const block of msg.content) { + if (block.type !== 'text') continue; + const t = titleFromUserText(block.text); + if (t) return t; + } + } + return cleanTitleText(session.title) || fallbackTitle(sessionId); + } + + /** IR 消息 → Cursor composer 的 bubble 素材(文本 + 工具调用/结果)。 */ + private toComposerMessages(session: Session): CursorComposerMessage[] { + // 先按 callId 收集工具结果:原生的工具结果挂在 assistant 的 tool 气泡里, + // 不单独成为用户消息(否则 UI 里会冒出成百上千个 `[tool_result] {json}` 气泡)。 + const toolResults = new Map(); + for (const msg of session.messages) { + for (const block of msg.content) { + if (block.type === 'tool_result' && block.callId) { + toolResults.set(block.callId, { content: block.content ?? '', isError: Boolean(block.isError) }); + } + } + } + + const out: CursorComposerMessage[] = []; + const fallbackTs = (() => { + const d = new Date(session.createdAt); + return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString(); + })(); + let lastTs = fallbackTs; + + for (const msg of session.messages) { + if (msg.role !== 'user' && msg.role !== 'assistant') continue; + const parsed = msg.timestamp ? new Date(msg.timestamp) : null; + const createdAt = + parsed && !Number.isNaN(parsed.getTime()) ? parsed.toISOString() : lastTs; + lastTs = createdAt; + + const textParts: string[] = []; + const tools: CursorComposerTool[] = []; + for (const block of msg.content) { + if (block.type === 'text') { + // MigrationEngine 会把 Cursor 不支持的 ThinkingBlock 降级成 `…` + // 包裹的文本块(migrate.ts degradeThinkingBlocks)。这类包裹留在正文里会让 Cursor + // 按 HTML 块渲染整段(markdown 失效、换行被吞),所以进 DB 气泡前剥掉;原文仍留在 + // transcript 里。 + const stripped = block.text.replace(THINKING_WRAP_RE, '').trim(); + // 用户气泡只显示真实提问:注入块(///…)与附件 + // 路径都是噪音(Cursor 原生把它们渲染成 chip,我们渲染不出来)。 + const visible = msg.role === 'user' ? visibleUserText(stripped) : stripped; + if (visible && isRenderableText(visible)) textParts.push(visible); + } else if (block.type === 'tool_call') { + const res = toolResults.get(block.callId); + tools.push({ + name: denormalizeToolName(block.toolName), + args: block.arguments ?? {}, + result: res?.content, + isError: res?.isError, + }); + } + // thinking:不写进 bubble 正文(原生 Cursor 不存 thinking;一旦以 `` 开头, + // 整段会被当 HTML 块,markdown 与换行失效)。原文仍保留在 transcript 里。 + // tool_result:已配对进上面的 tool 气泡,不单独成消息。 + } + const text = textParts.join('\n\n'); + if (!text.trim() && tools.length === 0) continue; + out.push({ + role: msg.role, + text, + tools, + createdAt, + modelName: msg.metadata?.model, + }); + } + return out; + } + async deleteSession(sessionId: string, projectPath?: string): Promise { + // 先摘掉 DB 注册(否则删了 transcript,Agents 列表里还留着一条点不开的会话) + try { + unregisterCursorComposer(sessionId); + } catch { + // best-effort:注册残留只影响列表显示,不影响数据安全 + } + const jsonlPath = this.findSessionFile(sessionId, projectPath); if (!jsonlPath) return; diff --git a/src/session-flow/adapters/workbuddy.ts b/src/session-flow/adapters/workbuddy.ts index 470b08118..06f009168 100644 --- a/src/session-flow/adapters/workbuddy.ts +++ b/src/session-flow/adapters/workbuddy.ts @@ -25,9 +25,11 @@ 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 { imagePlaceholderText } from '../ir.js'; import { getWorkBuddyProjectsDir, encodeCwdGeneric, + encodeCwdCodeBuddy, decodeCwdGeneric, readJsonl, readJsonlHead, @@ -36,7 +38,10 @@ import { dirExists, removeDirRecursive, } from '../fs.js'; -import { cleanTitleText, fallbackTitle, isInjectedText } from '../title.js'; +import { cleanTitleText, fallbackTitle, isInjectedText, titleFromCandidates, titleFromUserText } from '../title.js'; +import { deriveTargetSessionId } from '../ids.js'; +import { registerWorkBuddySession, unregisterWorkBuddySession } from '../workbuddy-store.js'; +import { log } from '../../utils/logger.js'; // --------------------------------------------------------------------------- // 工具名归一化映射 @@ -53,9 +58,37 @@ const WB_TO_IR_TOOL: Record = { todo_write: 'todo_write', }; -const IR_TO_WB_TOOL: Record = Object.fromEntries( - Object.entries(WB_TO_IR_TOOL).map(([k, v]) => [v, k]), -); +/** + * IR → WorkBuddy 工具名。 + * + * 与 CodeBuddy 同构:客户端按驼峰 UI 名(Bash / Read / Write / Edit / Grep / Glob / + * Task / TodoWrite …)查表渲染图标与折叠标题;直接写 IR 的 `read_file`/`bash` 会导致 + * 工具调用显示为空白。源平台别名也在这里收口,避免原样透传。 + */ +const IR_TO_WB_TOOL: Record = { + read_file: 'Read', + write_file: 'Write', + edit_file: 'Edit', + bash: 'Bash', + grep: 'Grep', + glob: 'Glob', + task: 'Task', + todo_write: 'TodoWrite', + delete_file: 'DeleteFile', + web_fetch: 'WebFetch', + web_search: 'WebSearch', + semantic_search: 'SemanticSearch', + execute_command: 'Bash', + run_command: 'Bash', + write_to_file: 'Write', + replace_in_file: 'Edit', + multi_edit: 'Edit', + search_file: 'Glob', + search_content: 'Grep', + list_dir: 'Glob', + codebase_search: 'SemanticSearch', + read_lints: 'LSP', +}; function normalizeToolName(name: string): string { return WB_TO_IR_TOOL[name] ?? name; @@ -65,6 +98,36 @@ function denormalizeToolName(irName: string): string { return IR_TO_WB_TOOL[irName] ?? irName; } +/** 会话标题:首条真实用户提问 > 源标题清洗 > Session (与 codebuddy-ide 写入侧同策略)。 */ +function resolveSessionTitle(session: Session, sessionId: string): string { + for (const m of session.messages) { + if (m.role !== 'user') continue; + for (const b of m.content) { + if (b.type !== 'text') continue; + const t = titleFromUserText(b.text); + if (t) return t; + } + } + return cleanTitleText(session.title) || fallbackTitle(sessionId); +} + +/** 工具调用折叠态显示的摘要文本(原生 providerData.argumentsDisplayText)。 */ +function argumentsDisplayText(name: string, args: Record | undefined): string { + if (!args) return name; + const preferred = + args.command ?? args.path ?? args.pattern ?? args.glob_pattern ?? args.target_directory ?? + args.target_file ?? args.query ?? args.url ?? args.filePath; + if (typeof preferred === 'string' && preferred.trim()) { + return preferred.length > 160 ? `${preferred.slice(0, 160)}…` : preferred; + } + try { + const s = JSON.stringify(args); + return s.length > 160 ? `${s.slice(0, 160)}…` : s; + } catch { + return name; + } +} + // --------------------------------------------------------------------------- // UUID / 时间戳工具 // --------------------------------------------------------------------------- @@ -147,7 +210,10 @@ export class WorkBuddyAdapter extends AgentAdapter { private resolveProjectDir(projectPath?: string): string { const root = getWorkBuddyProjectsDir(); if (projectPath) { - return path.join(root, encodeCwdGeneric(projectPath)); + // 必须与 writeSession 用同一个编码(encodeCwdCodeBuddy,保留空格): + // 原生工作区目录名是保留空格的,用 encodeCwdGeneric(空格→'-')会指到空目录, + // 表现为「按 cwd 列出 = 0 条」——读得到全量、按工作区过滤却漏光。 + return path.join(root, encodeCwdCodeBuddy(projectPath)); } return root; } @@ -194,7 +260,7 @@ export class WorkBuddyAdapter extends AgentAdapter { let createdAt = metaInfo.createdAt !== undefined ? fromUnixMs(metaInfo.createdAt) : undefined; let updatedAt = metaInfo.updatedAt !== undefined ? fromUnixMs(metaInfo.updatedAt) : undefined; let messageCount = 0; - let firstUserText = ''; + const userTextCandidates: string[] = []; let aiTitle = ''; try { @@ -217,19 +283,16 @@ export class WorkBuddyAdapter extends AgentAdapter { if (rtype === 'message') { messageCount++; const role = record.role as string; - if (role === 'user' && !firstUserText) { + if (role === 'user' && userTextCandidates.length < 5) { const content = record.content; if (Array.isArray(content)) { + const parts: string[] = []; 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; - } + parts.push(String((block as Record).text ?? '')); } } + if (parts.length) userTextCandidates.push(parts.join(' ')); } } } @@ -244,7 +307,7 @@ export class WorkBuddyAdapter extends AgentAdapter { // aiTitle 是 WorkBuddy 自己起的标题,最可靠;注入文本清洗同 codebuddy 适配器 title = (aiTitle && !isInjectedText(aiTitle) && aiTitle.slice(0, 60)) || - cleanTitleText(firstUserText) || + titleFromCandidates(userTextCandidates) || fallbackTitle(sessionId); let sizeBytes = 0; @@ -446,17 +509,31 @@ export class WorkBuddyAdapter extends AgentAdapter { } async writeSession(session: Session, projectPath?: string): Promise { - let sessionId = session.sessionId; - if (!isUuidV4(sessionId)) { - sessionId = uuidV4(); - } + // 非 UUID 源 id 用确定性派生(同一源会话反复迁移命中同一个 id → 不产生重复会话) + const sessionId = isUuidV4(session.sessionId) + ? session.sessionId + : deriveTargetSessionId('workbuddy', session.sessionId); const cwd = projectPath ?? session.cwd; - const projDir = path.join(getWorkBuddyProjectsDir(), encodeCwdGeneric(cwd)); + // WorkBuddy 与 CodeBuddy 同构:项目目录名**保留空格**(实测 CodeBuddy 落盘为 + // `Users-caiwenzhe-Desktop-Code-teamai cli`)。用 encodeCwdGeneric 会把空格也换成 + // `-`,目录名与客户端按当前 cwd 算出的不一致 → 会话不出现在该项目列表里。 + const projDir = path.join(getWorkBuddyProjectsDir(), encodeCwdCodeBuddy(cwd)); const jsonlPath = path.join(projDir, `${sessionId}.jsonl`); const records: Record[] = []; + // callId → 工具名:让 function_call_result 行带上真实工具名(而不是一律 'Agent') + const toolNamesByCallId = new Map(); + for (const m of session.messages) { + for (const b of m.content) { + if (b.type === 'tool_call' && b.callId) { + toolNamesByCallId.set(b.callId, denormalizeToolName(b.toolName)); + } + } + } + const resultToolName = (callId: string): string | undefined => toolNamesByCallId.get(callId); + // 1. ai-title 行 records.push({ timestamp: toUnixMs(session.createdAt), @@ -502,6 +579,13 @@ export class WorkBuddyAdapter extends AgentAdapter { text: block.text, }); hasText = true; + } else if (block.type === 'image') { + // WorkBuddy 消息体不存图片,降级为占位文本(保真度计 degraded) + wbContent.push({ + type: msg.role === 'user' ? 'input_text' : 'output_text', + text: imagePlaceholderText(block), + }); + hasText = true; } } @@ -523,18 +607,24 @@ export class WorkBuddyAdapter extends AgentAdapter { } // function_call 行 + // 与 CodeBuddy/原生一致:顶层 arguments 是 JSON 字符串、折叠摘要放 + // argumentsDisplayText、callId 必须非空(否则与结果无法配对)。 for (const block of otherBlocks) { if (block.type === 'tool_call') { const fcId = block.callId || uuidV4(); + const callId = block.callId || `call_${uuidV4()}`; + const name = denormalizeToolName(block.toolName); records.push({ id: fcId, parentId, timestamp: toUnixMs(msg.timestamp), type: 'function_call', - name: denormalizeToolName(block.toolName), - callId: block.callId, + name, + callId, + arguments: JSON.stringify(block.arguments ?? {}), providerData: { arguments: block.arguments, + argumentsDisplayText: argumentsDisplayText(name, block.arguments), ...(msg.metadata?.model ? { model: msg.metadata.model } : {}), }, sessionId, @@ -548,12 +638,13 @@ export class WorkBuddyAdapter extends AgentAdapter { for (const block of otherBlocks) { if (block.type === 'tool_result') { const fcrId = uuidV4(); + const name = resultToolName(block.callId) ?? 'Agent'; records.push({ id: fcrId, parentId, timestamp: toUnixMs(msg.timestamp), type: 'function_call_result', - name: 'Agent', + name, callId: block.callId, status: block.isError ? 'failed' : 'completed', output: { type: 'text', text: block.content }, @@ -578,6 +669,8 @@ export class WorkBuddyAdapter extends AgentAdapter { updatedAt: toUnixMs(session.updatedAt), cwd, sourceConversationId: sessionId, + // 与 DB 的 is_playground 保持一致:0 / false → 归入「空间」列表 + isPlayground: false, }, null, 2, @@ -588,10 +681,39 @@ export class WorkBuddyAdapter extends AgentAdapter { // meta.json 写入失败不影响主流程 } + // 注册进 workbuddy.db —— WorkBuddy 的列表查的是 sessions 表,不注册则完全不可见 + // (jsonl 只是会话正文,列表项/空间归属都在 DB 里)。best-effort。 + try { + const reg = registerWorkBuddySession({ + cwd, + sessionId, + title: resolveSessionTitle(session, sessionId), + createdAtMs: toUnixMs(session.createdAt), + // 最近活动 = 迁移时刻:列表按 updated_at 排序,保留源时间会埋进旧日期分组 + updatedAtMs: Date.now(), + model: session.metadata?.model, + }); + if (!reg.ok) { + log.debug(`workbuddy register failed: session=${sessionId} reason=${reg.reason ?? 'unknown'}`); + log.warn( + `WorkBuddy session list registration failed (transcript written, session may be invisible in WorkBuddy): ${reg.reason ?? 'unknown'}`, + ); + } + } catch (e) { + log.warn(`WorkBuddy session list registration error: ${(e as Error).message}`); + } + return sessionId; } async deleteSession(sessionId: string, projectPath?: string): Promise { + // 先摘掉 DB 注册(否则删了 jsonl,WorkBuddy 列表里还留着一条点不开的会话) + try { + unregisterWorkBuddySession(sessionId); + } catch { + // best-effort + } + const jsonlPath = this.findSessionFile(sessionId, projectPath); if (!jsonlPath) return; diff --git a/src/session-flow/cursor-store.ts b/src/session-flow/cursor-store.ts new file mode 100644 index 000000000..9994cc395 --- /dev/null +++ b/src/session-flow/cursor-store.ts @@ -0,0 +1,656 @@ +/** + * cursor-store.ts — 把迁移出来的会话注册进 Cursor 的本地数据库。 + * + * 背景:Cursor 的 Agents Window **不是**扫 `~/.cursor/projects//agent-transcripts/` + * 列会话的 —— transcript jsonl 是 Cursor 从自己的库单向 `flushTranscriptForConversation` + * 导出的产物。UI 的列表来自 `state.vscdb` 的 `composerHeaders` 表,正文来自 + * `cursorDiskKV` 的 `composerData:` 与 `bubbleId::`。 + * 因此只写 transcript 文件,会话在 Cursor 里完全不可见(迁移「成功」但看不到)。 + * + * 这里做三件事(best-effort,任何一步失败都不影响 transcript 已写入): + * 1. 从 `User/workspaceStorage//workspace.json` 反查 cwd 对应的 workspaceId + * 2. 按原生结构构造 head / composerData / bubbles + * 3. 用 sqlite3 以单事务 INSERT OR REPLACE 落库 + * + * 只新增/覆盖自己这个 composerId 的行,不动其他会话;回滚 = 删掉这三类 key。 + */ + +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 { spawnSync } from 'node:child_process'; + +// --------------------------------------------------------------------------- +// 路径与工具 +// --------------------------------------------------------------------------- + +/** Cursor 用户数据目录(macOS / Linux;其他平台返回 null 表示不支持注册)。 */ +export function getCursorStateRoot(): string | null { + const home = os.homedir(); + if (process.platform === 'darwin') { + return path.join(home, 'Library', 'Application Support', 'Cursor'); + } + if (process.platform === 'linux') { + return path.join(home, '.config', 'Cursor'); + } + return null; // Windows: %APPDATA%/Cursor —— 暂不支持(sqlite3 CLI 不保证存在) +} + +/** Cursor 的 state.vscdb 路径。 */ +export function getCursorStateDbPath(): string | null { + const root = getCursorStateRoot(); + return root ? path.join(root, 'User', 'globalStorage', 'state.vscdb') : null; +} + +/** 找 sqlite3 CLI:PATH → 常见安装位置。 */ +function findSqlite3(): string | null { + const candidates = [ + ...(process.env.PATH ?? '').split(path.delimiter).filter(Boolean).map((d) => path.join(d, 'sqlite3')), + '/usr/bin/sqlite3', + '/opt/homebrew/bin/sqlite3', + '/usr/local/bin/sqlite3', + ]; + for (const p of candidates) { + try { + fs.accessSync(p, fs.constants.X_OK); + return p; + } catch { + // continue + } + } + return null; +} + +/** cwd → Cursor workspaceId(由 workspaceStorage//workspace.json 的 folder 反查)。 */ +export function resolveCursorWorkspaceId(cwd: string): { id: string; uri: CursorUri } | null { + const root = getCursorStateRoot(); + if (!root) return null; + const wsRoot = path.join(root, 'User', 'workspaceStorage'); + let entries: string[]; + try { + entries = fs.readdirSync(wsRoot); + } catch { + return null; + } + + const target = realpathOr(cwd); + for (const hash of entries) { + const wj = path.join(wsRoot, hash, 'workspace.json'); + let raw: string; + try { + raw = fs.readFileSync(wj, 'utf-8'); + } catch { + continue; + } + let parsed: { folder?: string }; + try { + parsed = JSON.parse(raw) as { folder?: string }; + } catch { + continue; + } + const folder = parsed.folder; + if (!folder) continue; + const fsPath = decodeURIComponent(folder.replace(/^file:\/\//, '')); + if (realpathOr(fsPath) !== target) continue; + return { id: hash, uri: makeUri(folder, fsPath) }; + } + return null; +} + +function realpathOr(p: string): string { + try { + return fs.realpathSync(p); + } catch { + return p; + } +} + +interface CursorUri { + $mid: number; + fsPath: string; + external: string; + path: string; + scheme: string; +} + +function makeUri(external: string, fsPath: string): CursorUri { + return { $mid: 1, fsPath, external, path: fsPath, scheme: 'file' }; +} + +// --------------------------------------------------------------------------- +// 模板(字段集取自 Cursor 0.155 附近版本的原生记录) +// --------------------------------------------------------------------------- + +/** Lexical 富文本:Cursor 的 composer/bubble 用它渲染编辑器内容。 */ +function lexical(text: string): string { + const paragraph = text + ? [ + { + children: [{ detail: 0, format: 0, mode: 'normal', style: '', text, type: 'text', version: 1 }], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1, + }, + ] + : []; + return JSON.stringify({ + root: { children: paragraph, direction: 'ltr', format: '', indent: 0, type: 'root', version: 1 }, + }); +} + +interface BubbleTemplate { + [k: string]: unknown; +} + +/** 工具输出:原生 result 是 JSON 字符串(对象),裸文本要包成对象,否则 UI 解析不出来。 */ +function encodeToolResult(raw?: string): string { + if (!raw) return ''; + const s = raw.trim(); + if (s.startsWith('{') || s.startsWith('[')) return s; + return JSON.stringify({ output: raw }); +} + +/** 原生 composerData.context / bubble.context 的空形态。 */ +function emptyContext(): Record { + return { + composers: [], + selectedCommits: [], + selectedPullRequests: [], + selectedImages: [], + selectedDocuments: [], + selectedVideos: [], + folderSelections: [], + fileSelections: [], + mentions: {}, + uiElementSelections: [], + consoleLogs: [], + ideState: {}, + selections: [], + terminalSelections: [], + selectedDocs: [], + }; +} + +/** bubble 默认值(原生 bubble 的字段全量铺开,避免 UI 解析时缺字段)。 */ +function emptyBubble(): BubbleTemplate { + return { + _v: 3, + type: 1, + approximateLintErrors: [], + lints: [], + codebaseContextChunks: [], + commits: [], + pullRequests: [], + attachedCodeChunks: [], + assistantSuggestedDiffs: [], + gitDiffs: [], + interpreterResults: [], + images: [], + attachedFolders: [], + attachedFoldersNew: [], + bubbleId: '', + userResponsesToSuggestedCodeBlocks: [], + suggestedCodeBlocks: [], + diffsForCompressingFiles: [], + relevantFiles: [], + toolResults: [], + notepads: [], + capabilities: [], + multiFileLinterErrors: [], + diffHistories: [], + recentLocationsHistory: [], + recentlyViewedFiles: [], + isAgentic: false, + fileDiffTrajectories: [], + existedSubsequentTerminalCommand: false, + existedPreviousTerminalCommand: false, + docsReferences: [], + webReferences: [], + aiWebSearchResults: [], + requestId: '', + attachedFoldersListDirResults: [], + humanChanges: [], + attachedHumanChanges: false, + summarizedComposers: [], + cursorRules: [], + cursorCommands: [], + cursorCommandsExplicitlySet: false, + pastChats: [], + pastChatsExplicitlySet: false, + contextPieces: [], + editTrailContexts: [], + allThinkingBlocks: [], + diffsSinceLastApply: [], + deletedFiles: [], + supportedTools: [], + tokenCount: { inputTokens: 0, outputTokens: 0 }, + attachedFileCodeChunksMetadataOnly: [], + consoleLogs: [], + uiElementPicked: [], + isRefunded: false, + knowledgeItems: [], + documentationSelections: [], + externalLinks: [], + projectLayouts: [], + unifiedMode: 2, + capabilityContexts: [], + todos: [], + createdAt: '', + mcpDescriptors: [], + workspaceUris: [], + conversationState: '~', + text: '', + }; +} + +// --------------------------------------------------------------------------- +// 构造 head / composerData / bubbles +// --------------------------------------------------------------------------- + +export interface CursorComposerTool { + name: string; + args: Record; + /** + * 工具输出。原生把它放在 assistant 的 tool 气泡 `toolFormerData.result` 里, + * **不会**单独成为一条消息 —— 所以工具结果必须挂在这里,否则 UI 里会冒出一堆 + * `[tool_result] {json}` 的用户气泡。 + */ + result?: string; + /** 失败的工具调用(原生 status: failed)。 */ + isError?: boolean; +} + +export interface CursorComposerMessage { + role: 'user' | 'assistant'; + /** + * 纯文本正文:只放真实叙述文本。 + * 不要把 thinking 包成 `` 塞进来 —— 以 HTML 标签开头的正文会被 Cursor 当 + * HTML 块处理,markdown(粗体/列表/代码块)与换行全部失效,整段显示成一行。 + */ + text: string; + /** 该消息里的工具调用(含结果)。 */ + tools: CursorComposerTool[]; + createdAt: string; // ISO8601 + /** assistant 消息的模型名(可选)。 */ + modelName?: string; +} + +export interface RegisterCursorComposerArgs { + cwd: string; + composerId: string; + title: string; + messages: CursorComposerMessage[]; +} + +interface BubbleRecord { + key: string; + value: string; +} + +function buildBubbleRecords( + composerId: string, + messages: CursorComposerMessage[], +): { records: BubbleRecord[]; headers: Record[] } { + const records: BubbleRecord[] = []; + const headers: Record[] = []; + const uuid = (): string => cryptoRandomUuid(); + + for (const msg of messages) { + const isUser = msg.role === 'user'; + if (msg.text.trim()) { + const bid = uuid(); + const bubble = emptyBubble(); + bubble.type = isUser ? 1 : 2; + bubble.bubbleId = bid; + bubble.createdAt = msg.createdAt; + bubble.text = msg.text; + if (isUser) { + bubble.richText = lexical(msg.text); + bubble.requestId = uuid(); + bubble.checkpointId = uuid(); + // 原生 user bubble 还带这三项,缺失会让 UI 少渲染上下文/模型标签 + bubble.context = emptyContext(); + bubble.modelInfo = { modelName: msg.modelName ?? 'default' }; + bubble.isPlanExecution = false; + } else { + bubble.modelInfo = { modelName: msg.modelName ?? 'default' }; + bubble.turnDurationMs = 0; + // 原生 assistant 气泡带 codeBlocks(哪怕为空);缺失时正文可能按纯文本渲染, + // markdown 不生效 + bubble.codeBlocks = []; + } + records.push({ key: `bubbleId:${composerId}:${bid}`, value: JSON.stringify(bubble) }); + headers.push({ + bubbleId: bid, + type: isUser ? 1 : 2, + grouping: isUser + ? { + isRenderable: true, + hasText: true, + // 原生按文本长度决定,写死 true 会让长提问被当短文本渲染 + isShortPlainText: msg.text.length <= 120, + textPreview: msg.text.slice(0, 80), + toolDisplayComputed: true, + } + : { isRenderable: true, hasText: true, toolDisplayComputed: true }, + contentHeightHint: 42, + createdAt: msg.createdAt, + }); + } + + // 工具调用:原生是「无正文的 type 2 气泡 + toolFormerData」。 + // tool / toolCallBinary 是 Cursor 内部 protobuf,无法还原,省略(仅影响工具图标的 + // 精细展示,不影响会话可见性与正文)。 + for (const [i, tool] of msg.tools.entries()) { + const bid = uuid(); + const callId = `tool_${uuid()}`; + const argsJson = JSON.stringify(tool.args ?? {}); + const bubble = emptyBubble(); + bubble.type = 2; + bubble.bubbleId = bid; + bubble.createdAt = msg.createdAt; + bubble.codeBlocks = []; + bubble.turnDurationMs = 0; + bubble.toolFormerData = { + toolCallId: callId, + toolIndex: i, + modelCallId: callId, + status: tool.isError ? 'failed' : 'completed', + name: tool.name, + rawArgs: argsJson, + params: argsJson, + // 工具输出挂在这里(原生位置),不是一个独立的用户气泡。 + // 原生 result 是「JSON 字符串(对象)」,UI 会 JSON.parse 后取字段, + // 所以裸文本要包成对象,否则工具输出显示不出来。 + result: encodeToolResult(tool.result), + }; + records.push({ key: `bubbleId:${composerId}:${bid}`, value: JSON.stringify(bubble) }); + headers.push({ + bubbleId: bid, + type: 2, + grouping: { isRenderable: false, toolDisplayComputed: true }, + createdAt: msg.createdAt, + }); + } + } + return { records, headers }; +} + +function buildComposerData( + composerId: string, + title: string, + subtitle: string, + createdMs: number, + lastMs: number, + ws: { id: string; uri: CursorUri }, + headers: Record[], +): Record { + return { + _v: 18, + composerId, + richText: lexical(''), + hasLoaded: true, + text: '', + fullConversationHeadersOnly: headers, + conversationMap: {}, + status: 'completed', + context: emptyContext(), + generatingBubbleIds: [], + codeBlockData: {}, + originalFileStates: {}, + newlyCreatedFiles: [], + newlyCreatedFolders: [], + lastUpdatedAt: lastMs, + createdAt: createdMs, + hasChangedContext: false, + // 原生是固定三项能力描述;留空会让部分工具/能力面板 UI 缺内容 + capabilities: [ + { type: 15, data: { bubbleDataMap: '{}' } }, + { type: 19, data: {} }, + { type: 33, data: {} }, + ], + name: title, + subtitle, + isFileListExpanded: false, + canvasPillCollapsed: false, + browserChipManuallyDisabled: false, + browserChipManuallyEnabled: false, + unifiedMode: 'agent', + activeCustomMode: null, + committedCustomMode: null, + pendingExitedCustomMode: null, + forceMode: 'edit', + usageData: {}, + allAttachedFileCodeChunksUris: [], + modelConfig: { + modelName: 'default', + maxMode: false, + selectedModels: [{ modelId: 'default', parameters: [] }], + }, + subComposerIds: [], + subagentComposerIds: [], + capabilityContexts: [], + todos: [], + isQueueExpanded: true, + hasUnreadMessages: false, + gitHubPromptDismissed: false, + totalLinesAdded: 0, + totalLinesRemoved: 0, + addedFiles: 0, + removedFiles: 0, + isDraft: false, + isCreatingWorktree: false, + isApplyingWorktree: false, + isUndoingWorktree: false, + applied: false, + pendingCreateWorktree: false, + worktreeStartedReadOnly: false, + isBestOfNSubcomposer: false, + isBestOfNParent: false, + isSpec: false, + isProject: false, + isSpecSubagentDone: false, + isContinuationInProgress: false, + stopHookLoopCount: 0, + trackedGitRepos: [], + isNAL: true, + planModeSuggestionUsed: false, + debugModeSuggestionUsed: false, + conversationState: '~', + queueItems: [], + isAgentic: true, + filesChangedCount: 0, + workspaceIdentifier: { id: ws.id, uri: ws.uri }, + blobEncryptionKey: randomBase64Key(), + speculativeSummarizationEncryptionKey: randomBase64Key(), + latestChatGenerationUUID: cryptoRandomUuid(), + }; +} + +function buildHead( + composerId: string, + title: string, + subtitle: string, + createdMs: number, + lastMs: number, + ws: { id: string; uri: CursorUri }, +): Record { + return { + type: 'head', + composerId, + createdAt: createdMs, + lastUpdatedAt: lastMs, + conversationCheckpointLastUpdatedAt: lastMs, + name: title, + subtitle, + unifiedMode: 'agent', + forceMode: 'edit', + hasUnreadMessages: false, + hasBlockingPendingActions: false, + hasPendingPlan: false, + isArchived: false, + isDraft: false, + isWorktree: false, + worktreeStartedReadOnly: false, + isSpec: false, + isProject: false, + isBestOfNSubcomposer: false, + numSubComposers: 0, + referencedPlans: [], + trackedGitRepos: [], + totalLinesAdded: 0, + totalLinesRemoved: 0, + filesChangedCount: 0, + workspaceIdentifier: { id: ws.id, uri: ws.uri }, + }; +} + +function randomBase64Key(): string { + // 32 字节随机 key(原生是 base64)。 + return crypto.randomBytes(32).toString('base64'); +} + +function cryptoRandomUuid(): string { + return crypto.randomUUID(); +} + +// --------------------------------------------------------------------------- +// 落库 +// --------------------------------------------------------------------------- + +function esc(value: string): string { + return value.replace(/'/g, "''"); +} + +export interface RegisterResult { + ok: boolean; + /** 失败原因(ok=false 时给 CLI 记录 debug 用)。 */ + reason?: string; + bubbleCount?: number; +} + +/** + * 将会话注册进 Cursor 的 Agents 列表。 + * + * 失败一律返回 `{ok:false, reason}`,调用方不应把它当迁移失败 —— transcript 已落盘, + * 注册失败只是「列表里看不到」,不会损坏任何数据。 + */ +export function registerCursorComposer(args: RegisterCursorComposerArgs): RegisterResult { + const dbPath = getCursorStateDbPath(); + if (!dbPath) return { ok: false, reason: 'unsupported platform' }; + if (!fs.existsSync(dbPath)) return { ok: false, reason: `state db not found: ${dbPath}` }; + + const sqlite3 = findSqlite3(); + if (!sqlite3) return { ok: false, reason: 'sqlite3 CLI not found' }; + + const ws = resolveCursorWorkspaceId(args.cwd); + if (!ws) return { ok: false, reason: `cursor workspace not found for ${args.cwd}` }; + + const { records, headers } = buildBubbleRecords(args.composerId, args.messages); + if (records.length === 0) return { ok: false, reason: 'no renderable messages' }; + + const times = args.messages + .map((m) => Date.parse(m.createdAt)) + .filter((n) => Number.isFinite(n)); + const createdMs = times.length ? Math.min(...times) : Date.now(); + const lastMs = times.length ? Math.max(...times) : createdMs; + // 列表排序字段(lastUpdatedAt/recency)用迁移时刻:保留源时间会把迁移会话 + // 埋进「N 天前」分组,用户迁完在顶部找不到。会话内容时间轴(composerData + // 内的 lastMs)保持源时间不变。 + const recencyMs = Math.max(lastMs, Date.now()); + const subtitle = args.messages.find((m) => m.role === 'user' && m.text.trim())?.text.slice(0, 30) ?? ''; + + const composer = buildComposerData(args.composerId, args.title, subtitle, createdMs, lastMs, ws, headers); + const head = buildHead(args.composerId, args.title, subtitle, createdMs, lastMs, ws); + + const stmts: string[] = [ + // Cursor 运行时会持有写锁:给一个有限的 busy 超时,避免 CLI 永久挂起 + 'PRAGMA busy_timeout=5000;', + 'BEGIN IMMEDIATE;', + // OR REPLACE 依赖唯一索引,先显式删一次,避免重迁移出现重复行 + `DELETE FROM composerHeaders WHERE composerId='${esc(args.composerId)}';`, + ]; + stmts.push( + 'INSERT OR REPLACE INTO composerHeaders ' + + '(composerId, workspaceId, createdAt, lastUpdatedAt, isArchived, isSubagent, recency, checkpointAt, value, subagentTypeName) ' + + `VALUES ('${esc(args.composerId)}','${esc(ws.id)}',${createdMs},${recencyMs},0,0,${recencyMs},NULL,'${esc(JSON.stringify(head))}',NULL);`, + ); + // 重迁移同一会话时先清掉旧的 bubble,避免残留 + stmts.push(`DELETE FROM cursorDiskKV WHERE key LIKE 'bubbleId:${esc(args.composerId)}:%';`); + stmts.push( + 'INSERT OR REPLACE INTO cursorDiskKV (key, value) VALUES ' + + `('composerData:${esc(args.composerId)}','${esc(JSON.stringify(composer))}');`, + ); + for (const rec of records) { + stmts.push( + 'INSERT OR REPLACE INTO cursorDiskKV (key, value) VALUES ' + + `('${esc(rec.key)}','${esc(rec.value)}');`, + ); + } + stmts.push('COMMIT;'); + + const sqlPath = path.join(os.tmpdir(), `teamai-cursor-${process.pid}-${Date.now()}.sql`); + try { + fs.writeFileSync(sqlPath, stmts.join('\n'), 'utf-8'); + const r = spawnSync(sqlite3, [dbPath], { + input: fs.readFileSync(sqlPath), + maxBuffer: 32 * 1024 * 1024, + timeout: 30_000, + }); + if (r.status !== 0) { + return { ok: false, reason: (r.stderr?.toString() ?? '').trim().slice(0, 300) || `sqlite3 exit ${r.status}` }; + } + } catch (e) { + return { ok: false, reason: (e as Error).message }; + } finally { + try { + fs.unlinkSync(sqlPath); + } catch { + // ignore + } + } + + return { ok: true, bubbleCount: records.length }; +} + +/** + * 从 Cursor 的 Agents 列表里移除该会话(迁移回滚 / 删除会话时调用)。 + * 只删自己这个 composerId 的行,best-effort。 + */ +export function unregisterCursorComposer(composerId: string): RegisterResult { + const dbPath = getCursorStateDbPath(); + if (!dbPath || !fs.existsSync(dbPath)) return { ok: false, reason: 'state db not found' }; + const sqlite3 = findSqlite3(); + if (!sqlite3) return { ok: false, reason: 'sqlite3 CLI not found' }; + + const sql = + 'BEGIN IMMEDIATE;\n' + + `DELETE FROM composerHeaders WHERE composerId='${esc(composerId)}';\n` + + `DELETE FROM cursorDiskKV WHERE key='composerData:${esc(composerId)}' OR key LIKE 'bubbleId:${esc(composerId)}:%';\n` + + 'COMMIT;'; + + const sqlPath = path.join(os.tmpdir(), `teamai-cursor-del-${process.pid}-${Date.now()}.sql`); + try { + fs.writeFileSync(sqlPath, sql, 'utf-8'); + const r = spawnSync(sqlite3, [dbPath], { + input: fs.readFileSync(sqlPath), + maxBuffer: 32 * 1024 * 1024, + timeout: 30_000, + }); + if (r.status !== 0) { + return { ok: false, reason: (r.stderr?.toString() ?? '').trim().slice(0, 300) || `sqlite3 exit ${r.status}` }; + } + } catch (e) { + return { ok: false, reason: (e as Error).message }; + } finally { + try { + fs.unlinkSync(sqlPath); + } catch { + // ignore + } + } + return { ok: true }; +} diff --git a/src/session-flow/fs.ts b/src/session-flow/fs.ts index b7f9036d3..57b52bec3 100644 --- a/src/session-flow/fs.ts +++ b/src/session-flow/fs.ts @@ -122,6 +122,26 @@ export function decodeCwdClaude(encoded: string): string { return encoded; } +/** + * 最佳努力反解 Claude Code 的项目目录名 → 真实工作区路径。 + * + * 部分版本的 Claude Code 会把编码后的目录名(`-Users-foo-project`)直接写进记录里的 + * cwd 字段,导致迁移时拿不到真实工作区:目标 cwd 只能回退到「命令运行的目录」, + * 会话就被搬到了错误的项目下。这里按编码规则还原(前导 `-` → `/`,其余 `-` → `/`) + * 并用磁盘存在性校验;路径本身含 `-` 或空格时还原结果会不存在,直接放弃(返回 undefined)。 + */ +export function bestEffortDecodeCwdClaude(encoded: string): string | undefined { + if (!encoded.startsWith('-')) return undefined; + const candidate = '/' + encoded.slice(1).replace(/-/g, '/'); + try { + if (!fs.existsSync(candidate)) return undefined; + if (!fs.statSync(candidate).isDirectory()) return undefined; + return fs.realpathSync(candidate); + } catch { + return undefined; + } +} + /** * CodeBuddy / Cursor 的 cwd 解码: 同上,无法精确还原。 */ diff --git a/src/session-flow/ide-history.ts b/src/session-flow/ide-history.ts index 7c2c7477d..a9b8ec089 100644 --- a/src/session-flow/ide-history.ts +++ b/src/session-flow/ide-history.ts @@ -24,7 +24,9 @@ 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'; +import type { ContentBlock, Session } from './ir.js'; +import { imagePlaceholderText } from './ir.js'; +import { isInjectedText, titleFromUserText } from './title.js'; // --------------------------------------------------------------------------- // 类型 @@ -282,8 +284,20 @@ function resolveTimestamps(session: Session): string[] { return ts.map((t) => new Date(Number.isFinite(t) ? t : base).toISOString()); } -function irToIdeMessages(session: Session): IdeMessageFile[] { +/** 待落盘的会话资源(图片)。data 优先,其次从 sourcePath 复制。 */ +export interface IdeAsset { + /** assets/ 下的文件名(沿用原生命名 image.. 风格) */ + name: string; + /** 源端文件绝对路径(有它优先复制,避免 base64 往返) */ + sourcePath?: string; + /** base64 内容(filePath 不可读时的兜底) */ + data?: string; +} + +function irToIdeMessages(session: Session): { messages: IdeMessageFile[]; assets: IdeAsset[] } { const out: IdeMessageFile[] = []; + const assets: IdeAsset[] = []; + const usedNames = new Set(); const model = pickModel(session); const timestamps = resolveTimestamps(session); @@ -295,6 +309,24 @@ function irToIdeMessages(session: Session): IdeMessageFile[] { } } + // IR 图片块 → assets/ + codebuddy-asset:// 引用(与原生存储一致) + const assetRef = (b: Extract): string | null => { + const base = b.label || path.basename(b.filePath ?? 'image.png') || 'image.png'; + const ext = path.extname(base) || `.${(b.mimeType.split('/')[1] ?? 'png').replace('jpeg', 'jpg')}`; + const stem = base.slice(0, base.length - ext.length) || 'image'; + let name = `${stem}${ext}`; + for (let i = 1; usedNames.has(name); i++) name = `${stem}-${i}${ext}`; + usedNames.add(name); + if (b.filePath && fs.existsSync(b.filePath)) { + assets.push({ name, sourcePath: b.filePath }); + } else if (b.data) { + assets.push({ name, data: b.data }); + } else { + return null; // 无内容可用:调用方降级为占位文本 + } + return `codebuddy-asset://assets/${name}`; + }; + session.messages.forEach((msg, msgIdx) => { const ts = timestamps[msgIdx]; const msgModel = msg.metadata?.model ?? model; @@ -305,13 +337,20 @@ function irToIdeMessages(session: Session): IdeMessageFile[] { isHelperMessage: false, }); - // 1) thinking + text + tool_call → 一条 + // 1) thinking + text + image + 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 === 'image') { + const ref = assetRef(b); + if (ref) { + content.push({ type: 'image', image: ref }); + } else { + content.push({ type: 'text', text: imagePlaceholderText(b) }); + } } else if (b.type === 'tool_call') { content.push({ type: 'tool-call', @@ -376,7 +415,7 @@ function irToIdeMessages(session: Session): IdeMessageFile[] { } }); - return out; + return { messages: out, assets }; } // --------------------------------------------------------------------------- @@ -429,6 +468,9 @@ function upsertConversation(historyDir: string, conv: IdeConversation): void { if (i >= 0) convs[i] = conv; else convs.push(conv); data.conversations = convs; + // 原生 index.json 顶层必有 current(指向当前会话)。新建工作区时不会天然存在, + // 缺失可能让 IDE 打开该工作区时没有选中项 → 补上刚写入的这条。 + if (!data.current) data.current = conv.id; writeIndex(historyDir, data); } @@ -457,21 +499,31 @@ function removeDirRecursive(dir: string): void { * 源平台注入的系统前缀。首条「用户消息」常常是这类包装文本, * 直接当标题会把提示词原文泄漏到 IDE 侧边栏历史列表里。 */ -const SYSTEM_INJECTED_TITLE = /^\s*<(local-command-caveat|system-reminder|system|command-name|command-message|command-args|timestamp)\b/i; +const SYSTEM_INJECTED_TITLE = /^\s*<(local-command-caveat|local-command-stdout|system-reminder|system|command-name|command-message|command-args|command-contents|timestamp)\b/i; /** * 会话标题:清洗系统注入文本,拿不到有效标题时退回首条真实用户文本。 */ +/** 源适配器给不出标题时的占位名(如 "Session 2bf4d3be")——不能拿它当会话标题。 */ +const PLACEHOLDER_TITLE = /^session\s+[0-9a-f]{8}$/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); + if (raw && !PLACEHOLDER_TITLE.test(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; + // 源平台的首条用户消息常被 // 与附件路径包裹, + // 直接用原文当标题会整段被判定为注入文本 → 退回 "Session xxxxxxxx"。 + // 先走清洗(解 包裹 + 剥元信息 + 去附件路径)再取标题。 + const cleaned = titleFromUserText(b.text); + if (cleaned) return cleaned.slice(0, 100); const t = b.text.replace(/\s+/g, ' ').trim(); - if (t && !SYSTEM_INJECTED_TITLE.test(t)) return t.slice(0, 100); + // isInjectedText 是 title.ts 维护的完整注入标签头清单(与 META_BLOCK_RE 同源演进), + // SYSTEM_INJECTED_TITLE 只保留作双保险——单一来源,避免再加标签时两边漏同步。 + if (t && !isInjectedText(t) && !SYSTEM_INJECTED_TITLE.test(t)) return t.slice(0, 100); } } return `Session ${convId.slice(0, 8)}`; @@ -537,7 +589,7 @@ export function writeIdeSession(session: Session, cwd: string): IdeSyncResult { } const convId = toIdeConvId(session.sessionId); - const messages = irToIdeMessages(session); + const { messages, assets } = irToIdeMessages(session); const model = pickModel(session); const requests = buildIdeRequests(session, messages); @@ -546,7 +598,10 @@ export function writeIdeSession(session: Session, cwd: string): IdeSyncResult { type: 'craft', name: cleanTitle(session, convId), createdAt: session.createdAt, - lastMessageAt: session.updatedAt, + // lastMessageAt 用迁移时刻而非源会话时间:IDE 列表按最近活动排序分组, + // 保留源时间会把迁移会话埋进「N 天前」分组,用户迁完在顶部找不到。 + // 源时间轴保留在 createdAt(列表详情)与消息时间戳(打开会话后)里。 + lastMessageAt: new Date().toISOString(), ...(model ? { modelMap: { ask: model, craft: model, plan: model } } : {}), }; @@ -566,6 +621,52 @@ export function writeIdeSession(session: Session, cwd: string): IdeSyncResult { } fs.mkdirSync(msgDir, { recursive: true }); + // 落盘图片资源:与原生存储一致放 /assets/,消息里用 + // codebuddy-asset://assets/ 相对引用。某个资源失败只降级该图片 + // (替换成占位文本),不阻断整个会话写入。 + if (assets.length > 0) { + const assetsDir = path.join(convDir, 'assets'); + try { + fs.mkdirSync(assetsDir, { recursive: true }); + } catch { + // 建不了目录时下方逐个写入会失败并走占位降级 + } + for (const asset of assets) { + let ok = false; + try { + const dest = path.join(assetsDir, asset.name); + if (asset.sourcePath && fs.existsSync(asset.sourcePath)) { + fs.copyFileSync(asset.sourcePath, dest); + ok = true; + } else if (asset.data) { + fs.writeFileSync(dest, Buffer.from(asset.data, 'base64')); + ok = true; + } + } catch { + ok = false; + } + if (!ok) { + const ref = `codebuddy-asset://assets/${asset.name}`; + for (const m of messages) { + let body: { role?: string; content?: Array> }; + try { + body = JSON.parse(m.message); + } catch { + continue; + } + if (!Array.isArray(body.content)) continue; + const idx = body.content.findIndex( + (c) => c.type === 'image' && c.image === ref, + ); + if (idx >= 0) { + body.content[idx] = { type: 'text', text: `[image: ${asset.name} (asset write failed)]` }; + m.message = JSON.stringify(body); + } + } + } + } + } + // 写每条消息,同时收集顺序索引。 // conversation 级 index.json 是**消息顺序索引**——IDE 靠它决定显示顺序, // 只写 messages/*.json 而没有它的话,会话打开会是空白。 diff --git a/src/session-flow/ids.ts b/src/session-flow/ids.ts new file mode 100644 index 000000000..c1c13061a --- /dev/null +++ b/src/session-flow/ids.ts @@ -0,0 +1,32 @@ +/** + * ids.ts — 目标平台会话 id 的确定性派生。 + * + * 源会话 id 常常不是 UUID(如 CodeBuddy IDE 的 32 位 hex `60062279ff104372bc110594720a8016`)。 + * 目标适配器若在这种情况下 `randomUUID()`,同一会话每次迁移都会生成一个新副本: + * 目标客户端里出现多条重复会话,且无法按源 id 回滚。 + * + * 这里用 sha256(platform + sourceId) 派生出稳定的 UUID v8 形状 id: + * 同一 (平台, 源会话) 永远得到同一个目标 id → 重迁移 = 覆盖,天然幂等。 + */ + +import * as crypto from 'node:crypto'; + +/** 由源会话 id 确定性派生目标平台 session id(UUID v8 形状)。 */ +export function deriveTargetSessionId(targetPlatform: string, sourceId: string): string { + const hex = crypto + .createHash('sha256') + .update(`teamai:${targetPlatform}:${sourceId}`) + .digest('hex'); + const variant = ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16); + // version nibble 用 **7**:目标端(如 Codex 的 isUuidV7)据此判定"已是本平台的 id" + // 并直接沿用。若用 8,已迁移会话做二次迁移(codex→X→codex)会再派生出一个新 id, + // 幂等性跨链路失效。时间位仍是 hash(非真实时间戳),但只影响形状不影响排序字段 + // (recency/updated_at 都取自会话时间戳)。 + return [ + hex.slice(0, 8), + hex.slice(8, 12), + `7${hex.slice(13, 16)}`, + `${variant}${hex.slice(17, 20)}`, + hex.slice(20, 32), + ].join('-'); +} diff --git a/src/session-flow/ir.ts b/src/session-flow/ir.ts index b31e91bc6..eed387223 100644 --- a/src/session-flow/ir.ts +++ b/src/session-flow/ir.ts @@ -39,7 +39,30 @@ export interface ToolResultBlock { isError: boolean; } -export type ContentBlock = TextBlock | ThinkingBlock | ToolCallBlock | ToolResultBlock; +/** + * 消息内嵌图片块。 + * + * - `data`:图片内容的 base64(优先使用;claude-code 等原生支持 base64 图片的平台直接写回) + * - `filePath`:源端文件绝对路径(codebuddy-ide 的 assets;写回 IDE / 落地到支持文件引用的平台用) + * - 两者至少有一个,读取侧保证;都没有时写入侧降级为占位文本 + */ +export interface ImageBlock { + type: 'image'; + mimeType: string; // image/png | image/jpeg | image/gif | image/webp + data?: string; // base64,不带 data: 前缀 + filePath?: string; // 源端绝对路径 + /** 展示名(占位符用),如 image.b9f683a0a9.png */ + label?: string; +} + +/** 目标平台不支持图片时的占位文本(保真度计 degraded,而非静默丢弃)。 */ +export function imagePlaceholderText(block: ImageBlock): string { + const sizeKb = block.data ? Math.round((block.data.length * 3) / 4 / 1024) : null; + const sizePart = sizeKb !== null ? `, ${sizeKb}KB` : ''; + return `[image: ${block.label ?? 'image'}${sizePart}]`; +} + +export type ContentBlock = TextBlock | ThinkingBlock | ToolCallBlock | ToolResultBlock | ImageBlock; export function blockToDict(block: ContentBlock): Record { switch (block.type) { @@ -51,6 +74,14 @@ export function blockToDict(block: ContentBlock): Record { 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 }; + case 'image': + return { + type: 'image', + mimeType: block.mimeType, + ...(block.data ? { data: block.data } : {}), + ...(block.filePath ? { filePath: block.filePath } : {}), + ...(block.label ? { label: block.label } : {}), + }; } } @@ -75,6 +106,14 @@ export function blockFromDict(data: Record): ContentBlock { content: String(data.content ?? ''), isError: Boolean(data.isError ?? false), }; + case 'image': + return { + type: 'image', + mimeType: String(data.mimeType ?? 'image/png'), + ...(typeof data.data === 'string' ? { data: data.data } : {}), + ...(typeof data.filePath === 'string' ? { filePath: data.filePath } : {}), + ...(typeof data.label === 'string' ? { label: data.label } : {}), + }; default: throw new Error(`Unknown content block type: ${t}`); } diff --git a/src/session-flow/migrate.ts b/src/session-flow/migrate.ts index f55e9214d..08f4c2834 100644 --- a/src/session-flow/migrate.ts +++ b/src/session-flow/migrate.ts @@ -24,6 +24,7 @@ export const THINKING_SUPPORT: Record = { tcodex: true, codebuddy: true, 'codebuddy-ide': true, + workbuddy: true, cursor: false, // Cursor 无 thinking,降级为 text }; @@ -54,12 +55,34 @@ export const NATIVE_TOOLS: Record> = { 'web_search', 'web_fetch', 'preview_url', 'lsp', 'task', 'todo_write', 'use_skill', 'update_memory', 'image_gen', ]), + // workbuddy 与 codebuddy 同构,但此前完全没登记:保真度会把未知工具算成 + // preserved(100%)且**一条警告都不产生**,用户完全看不到工具不兼容。 + workbuddy: new Set([ + 'read_file', 'write_file', 'edit_file', 'bash', 'grep', 'glob', 'task', 'todo_write', + ]), cursor: new Set([ 'read_file', 'write_file', 'edit_file', 'bash', 'grep', 'glob', 'delete_file', 'web_fetch', 'web_search', 'semantic_search', ]), }; +/** + * 图片支持矩阵。目标平台能原生表示消息内嵌图片(用户消息 content 里的 image 块) + * 才算 true;false 时图片降级为占位文本(保真度计 degraded,见 fidelityFromSession)。 + */ +export const IMAGE_SUPPORT: Record = { + 'claude-code': true, + 'claude-internal': true, + tclaude: true, + 'codebuddy-ide': true, // 写回 assets/ + codebuddy-asset:// 引用,完整还原 + codex: false, // rollout UserMessage 只支持 text + 'codex-internal': false, + tcodex: false, + codebuddy: false, + workbuddy: false, + cursor: false, +}; + // --------------------------------------------------------------------------- // FidelityReport // --------------------------------------------------------------------------- @@ -89,9 +112,11 @@ export function fidelityFromSession(session: Session, targetPlatform: string): F const warnings: string[] = []; const platformSpecificLosses: string[] = []; let thinkingCount = 0; + let imageCount = 0; const unknownTools = new Set(); const targetSupportsThinking = THINKING_SUPPORT[targetPlatform] ?? true; + const targetSupportsImage = IMAGE_SUPPORT[targetPlatform] ?? false; const nativeTools = NATIVE_TOOLS[targetPlatform] ?? new Set(); for (const msg of session.messages) { @@ -116,9 +141,21 @@ export function fidelityFromSession(session: Session, targetPlatform: string): F preservedBlocks++; } } else if (block.type === 'tool_call') { - preservedBlocks++; + // 未知工具不再计满分:工具本身会写进去,但目标端不认识 → 执行语义丢失, + // 与 thinking 降级同类,计 degraded(0.7 权重),让虚高的 100% 真实回落。 if (nativeTools.size > 0 && !nativeTools.has(block.toolName)) { unknownTools.add(block.toolName); + degradedBlocks++; + } else { + preservedBlocks++; + } + } else if (block.type === 'image') { + imageCount++; + if (targetSupportsImage) { + preservedBlocks++; + } else { + // 降级为占位文本(保留文件名/大小的指针,视觉内容丢失) + degradedBlocks++; } } } @@ -127,12 +164,15 @@ export function fidelityFromSession(session: Session, targetPlatform: string): F if (thinkingCount > 0 && !targetSupportsThinking) { degradations.push('thinking_blocks_degraded_to_text'); } + if (imageCount > 0 && !targetSupportsImage) { + degradations.push(`image_blocks_degraded_to_placeholder (${imageCount})`); + } for (const toolName of [...unknownTools].sort()) { warnings.push(`tool_not_in_target: ${toolName}`); } - const score = totalBlocks === 0 ? 1.0 : (preservedBlocks + 0.7 * degradedBlocks) / totalBlocks; + const score = totalBlocks === 0 ? 1.0 : (preservedBlocks + 0.7 * degradedBlocks + 0 * lostBlocks) / totalBlocks; return { score, @@ -190,12 +230,38 @@ export interface MigrationResult { preview: MigrationPreview; success: boolean; targetSessionId?: string; + /** 实际写入的目标工作区(默认 = 源会话 cwd,显式 --target-cwd 时为其值)。 */ + targetCwd?: string; targetFilePath?: string; error?: string; startedAt: string; completedAt?: string; } +/** cwd 是否是可写入的真实绝对路径(排除 md5: 这类不可逆占位)。 */ +function isUsableCwd(cwd: string | undefined): cwd is string { + if (!cwd) return false; + // Windows 盘符路径(C:\... / C:/...)同样是合法绝对路径 + return cwd.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(cwd); +} + +/** + * 解析目标工作区:**默认保持源会话的工作区**。 + * + * 优先级:显式指定 > 源会话 cwd > 源端定位用的 projectPath。 + * 源会话 cwd 可能是 `md5:` 占位(codebuddy-ide 未传工作区时),不是可写路径, + * 此时回退到 projectPath(通常是命令运行的目录),让 writeSession 有确定的落点。 + */ +export function resolveTargetCwd( + explicit: string | undefined, + sourceSessionCwd: string | undefined, + fallback: string | undefined, +): string | undefined { + if (explicit) return explicit; + if (isUsableCwd(sourceSessionCwd)) return sourceSessionCwd; + return fallback; +} + // --------------------------------------------------------------------------- // MigrationEngine // --------------------------------------------------------------------------- @@ -230,20 +296,33 @@ export class MigrationEngine { const source = getAdapter(this.sourcePlatform); const target = getAdapter(this.targetPlatform); + // 目标端安装检查:没装客户端时写入只会落到一个无人读取的目录, + // 之前会照样报「迁移成功」。这里提前失败并给出明确原因。 + if (!target.isReady()) { + throw new Error( + `Target platform "${this.targetPlatform}" is not installed or its storage directory was not found. ` + + `Install the client first, or pick another target (available: ${listInstalledPlatforms().join(', ') || 'none'}).`, + ); + } + 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); + // 目标工作区语义:**默认保持源会话的工作区**。 + // 迁移是「把 thpc 的会话搬到 Codex/WorkBuddy」,而不是「搬到我当前所在的目录」; + // 只有显式指定 targetProjectPath(--target-cwd)才搬走。 + const targetCwd = resolveTargetCwd(targetProjectPath, session.cwd, projectPath); + targetSid = await target.writeSession(enhancedSession, targetCwd); // 尝试定位目标文件路径 let targetFilePath: string | undefined; try { const targetAdapter = getAdapter(this.targetPlatform); // 通过 list 查找刚写入的会话 - const metas = await targetAdapter.listConversations(targetProjectPath); + const metas = await targetAdapter.listConversations(targetCwd); const found = metas.find((m) => m.sessionId === targetSid); if (found) targetFilePath = found.filePath; } catch { @@ -264,6 +343,7 @@ export class MigrationEngine { }, success: true, targetSessionId: targetSid, + targetCwd, targetFilePath, startedAt, completedAt, diff --git a/src/session-flow/session-cmd.ts b/src/session-flow/session-cmd.ts index 50b37046a..17391ef2e 100644 --- a/src/session-flow/session-cmd.ts +++ b/src/session-flow/session-cmd.ts @@ -47,6 +47,9 @@ let lineResolver: ((line: string) => void) | null = null; let lineReaderStarted = false; let sharedRl: readline.Interface | null = null; +/** --all 批量迁移时,超过这个条数先列清单要求确认(-y 跳过)。 */ +const BATCH_CONFIRM_THRESHOLD = 10; + function startLineReader(): void { if (lineReaderStarted) return; @@ -258,7 +261,8 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { .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('--all', 'Migrate every session from source (not just the 5 most recent)') + .option('--limit ', 'Max sessions to migrate (only caps --all; ignored otherwise)') .option('-y, --yes', 'Skip confirmation prompt') .action(async (sessionId, opts) => { try { @@ -279,17 +283,25 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { let metas = await sourceAdapter.listConversations(workCwd); // 当前 cwd 无会话时,交互式提示列出全部目录的会话 + // 展开后这些会话**不属于 workCwd**,源端定位必须传 undefined 让适配器全局按 id 查找 + // (各适配器 findSessionFile 都有该兜底)。此前仍把 workCwd 传给源适配器, + // claude-code 只在 encodeCwdClaude(workCwd) 一个目录里找 → 这条路径 100% 失败。 + let crossDirExpanded = false; 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}.`); + console.log(`Tip: pass --cwd to migrate from a specific workspace (non-interactive runs need it).`); const ans = await ask('List all? (y/N): '); if (ans.toLowerCase() === 'y' || ans.toLowerCase() === 'yes') { metas = allMetas; + crossDirExpanded = true; } } } + /** 源端定位用的工作区:跨目录展开时会话不归属 workCwd,交给适配器全局查找。 */ + const sourceProjectPath = crossDirExpanded ? undefined : workCwd; if (metas.length === 0) { console.log('No sessions found on source platform.'); @@ -299,7 +311,28 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { // 选择会话 let targets: typeof metas; if (opts.all) { - targets = metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 5); + // --all 名副其实:迁移全部会话,不再静默截断到 5 条。 + // 此前 slice(0, 5) 且无任何提示,用户会以为「全部迁完了」。 + // 需要限量时用 --limit(与 session push 的语义一致)。 + const limitRaw = parseInt(opts.limit, 10); + const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 0; + const sorted = metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + targets = limit > 0 ? sorted.slice(0, limit) : sorted; + + // 大批量确认:--all 现在会迁全部(不再截断到 5 条),会话多时先列清单要求确认, + // -y 跳过。避免一次误迁几十上百条、回滚成本高。 + if (targets.length > BATCH_CONFIRM_THRESHOLD && !opts.yes) { + console.log(`\nAbout to migrate ${targets.length} session(s) from ${source}:`); + for (const m of targets) { + 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('\nMigrate all of the above? (y/N): '); + if (ans.toLowerCase() !== 'y' && ans.toLowerCase() !== 'yes') { + console.log('Cancelled.'); + return; + } + } } else if (sessionId) { targets = metas.filter((m) => m.sessionId === sessionId || m.sessionId.startsWith(sessionId)); if (targets.length === 0) { @@ -326,12 +359,13 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { const engine = new MigrationEngine(source, target); let migrated = 0; + let failed = 0; // 记录每次成功迁移产出的目标会话 ID + 真实保真度, // --push 时精确回读这些 ID(而非"目标平台最近 N 条",避免推错,见 P4/P7)。 - const migratedTargets: { sessionId: string; fidelityScore: number }[] = []; + const migratedTargets: { sessionId: string; fidelityScore: number; cwd?: string }[] = []; for (const m of targets) { - const preview = await engine.preview(m.sessionId, workCwd); + const preview = await engine.preview(m.sessionId, sourceProjectPath); console.log(`\n Migration Preview`); console.log(` ─────────────────────────────────`); @@ -339,6 +373,8 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { console.log(` Target: ${preview.targetPlatform}`); console.log(` Session: ${preview.sessionTitle} (${preview.sessionId.slice(0, 8)}...)`); console.log(` CWD: ${preview.cwd}`); + // 目标工作区默认保持源会话的工作区,只有 --target-cwd 才搬走 + console.log(` Target CWD:${opts.targetCwd ? ' ' + opts.targetCwd : ' (same as source)'}`); console.log(` Messages: ${preview.messageCount}`); console.log(` ─────────────────────────────────`); console.log(` Fidelity: ${(preview.fidelity.score * 100).toFixed(1)}% (Mode ${preview.fidelity.mode})`); @@ -364,10 +400,13 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { // 目标 cwd 默认为当前工作目录(真实绝对路径)。 // 不传的话 writeSession 会回退到 session.cwd——那可能是源平台存的 // encoded 形式(如 `-Users-foo-project`),无法还原真实路径。 - const result = await engine.migrate(m.sessionId, workCwd, opts.targetCwd ?? workCwd); + // 目标工作区默认 = 源会话工作区(保持目录一致); + // 只有显式 --target-cwd 才把会话搬到别的工作区。 + const result = await engine.migrate(m.sessionId, sourceProjectPath, opts.targetCwd); if (result.success) { console.log(`\n ✓ Migration successful`); console.log(` Target session ID: ${result.targetSessionId}`); + if (result.targetCwd) console.log(` Target CWD: ${result.targetCwd}`); if (result.targetFilePath) { console.log(` Target file: ${result.targetFilePath}`); } @@ -377,23 +416,32 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { migratedTargets.push({ sessionId: result.targetSessionId, fidelityScore: result.preview.fidelity.score, + cwd: result.targetCwd, }); } } else { console.error(`\n ✗ Migration failed: ${result.error}`); + failed++; } } + // 脚本化语义:任何一条失败都以非 0 退出(--all 批量时不中断其余会话)。 + if (failed > 0 && !isDryRun()) { + process.exitCode = 1; + } + // --push: 推送到团队仓 if (opts.push && migrated > 0) { const repoRoot = resolveRepoRoot(opts.repoRoot); - const author = getGitAuthor(workCwd); + // 跨目录展开时会话不属于 workCwd,author 应取自会话真实所在的仓库 + const authorCwd = migratedTargets.find((t) => t.cwd)?.cwd ?? workCwd; + const author = getGitAuthor(authorCwd); 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); + const session = await targetAdapter.readSession(t.sessionId, t.cwd ?? opts.targetCwd ?? workCwd); // P3:归档键按会话原生 cwd 派生,而非 migrate 运行目录(见 deriveArchiveIdentity) const meta = defaultSyncMeta( { diff --git a/src/session-flow/sqlite.ts b/src/session-flow/sqlite.ts new file mode 100644 index 000000000..1a3b7e6d9 --- /dev/null +++ b/src/session-flow/sqlite.ts @@ -0,0 +1,32 @@ +/** + * sqlite.ts — 客户端本地库(Cursor/Codex/WorkBuddy 的索引库)访问的公共部分。 + * + * 这些库都由各自客户端进程持有,TeamAI 只在迁移/回滚时做极小的 upsert / delete。 + * 统一用 sqlite3 CLI 而不是 node 驱动:无需额外依赖,且能天然复用 macOS 自带的 + * sqlite3(支持 WAL 与 busy_timeout)。 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** 定位 sqlite3 CLI:PATH → 常见安装位置。找不到时调用方应降级为「不写索引」。 */ +export function findSqlite3(): string | null { + const candidates = [ + ...(process.env.PATH ?? '') + .split(path.delimiter) + .filter(Boolean) + .map((d) => path.join(d, 'sqlite3')), + '/usr/bin/sqlite3', + '/opt/homebrew/bin/sqlite3', + '/usr/local/bin/sqlite3', + ]; + for (const p of candidates) { + try { + fs.accessSync(p, fs.constants.X_OK); + return p; + } catch { + // continue + } + } + return null; +} diff --git a/src/session-flow/title.ts b/src/session-flow/title.ts index 5751db5c1..712e5f93c 100644 --- a/src/session-flow/title.ts +++ b/src/session-flow/title.ts @@ -12,14 +12,49 @@ 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; + /^\s*<(memories|system-reminder|system|additional_data|local-command-caveat|local-command-stdout|command-name|command-message|command-args|command-contents|agent_requestable_workspace_rules|agent_requestable_user_rules|project_context|project_guidance|teammate-message|user_query|rules)\b/i; + +/** 以已知元信息标签开头(用于「剥完仍剩标签」判据)。 */ +const META_HEAD_RE = + /^\s*<(user_info|rules|environment_context|system-reminder|system_reminder|system_instructions|available_skills|agent_request|local-command-caveat|local-command-stdout|uploaded_documents|additional_data|timestamp|command-name|command-message|command-args|command-contents|user_query|memories)\b/i; const INJECTED_PAIR = /<[a-zA-Z][\w-]*(?:\s[^>]*)?>[\s\S]*?<\/[\w-]+>/g; const INJECTED_TAG = /<\/?[a-zA-Z][\w-]*(?:\s[^>]*)?\/?>/g; -/** 文本是否整段由平台注入构成。 */ +/** + * 文本是否整段由平台注入构成。 + * + * 除了开头标签白名单,还补一条本质判据:剥掉已知元信息块后没剩下内容即视为注入。 + * 否则只维护一份标签清单,`…`、`…` + * 这类不在 HEAD 白名单里的整段元信息会被当真实提问,标题就成了提示词原文。 + */ export function isInjectedText(text: string): boolean { - return INJECTED_HEAD.test(text); + if (!text || !text.trim()) return true; + if (INJECTED_HEAD.test(text)) return true; + const rest = stripMetaBlocks(text).trim(); + if (!rest) return true; + // 剥完还剩一堆标签:闭合标签被截断(原生数据里就存在 `…]|<\/?[a-zA-Z][\w-]*\b[^>]*$/.test(rest)) return true; + return false; +} + +/** + * 从用户文本候选里解出会话标题(各平台列表/读取侧共用)。 + * + * 纯文本走标签清洗;注入头开头的整条(`…`、`…` 等)不能直接 + * 丢弃——slash 命令消息是「注入头 + 真实提问」的混合体,走 titleFromUserText 解 + * `` 包裹并剥元信息后,真实提问能救回来。取不到就返回空串,由调用方兜底。 + */ +export function titleFromCandidates(candidates: string[]): string { + for (const text of candidates) { + if (!text) continue; + const cleaned = isInjectedText(text) + ? titleFromUserText(text) + : cleanTitleText(text) || titleFromUserText(text); + if (cleaned) return cleaned; + } + return ''; } /** @@ -42,3 +77,107 @@ export function cleanTitleText(text: string, maxLen = TITLE_MAX): string { export function fallbackTitle(sessionId: string): string { return `Session ${sessionId.slice(0, 8)}`; } + +// --------------------------------------------------------------------------- +// 首条用户消息 → 真实提问(标题 / 列表预览用) +// --------------------------------------------------------------------------- + +/** + * “纯元信息”块:源平台注入的上下文,不是用户输入。 + * + * 只列纯元信息标签;`` 之类包裹真实提问的标签由 extractUserText 单独解包。 + * 不锚定行首:前一块剥离后剩余文本常以 \n\n 开头,行首锚定会让后续块匹配失败。 + * system_reminder 同时覆盖下划线(CodeBuddy)与连字符(Claude Code)两种写法。 + * command-* / local-command-* 是 Claude Code 的 slash 命令记录(/model 等), + * 不剥的话「切了个模型」的命令会话标题就成了 `/model`。 + */ +const META_BLOCK_RE = + /<(user_info|rules|environment_context|system-reminder|system_reminder|system_instructions|available_skills|agent_request|local-command-caveat|local-command-stdout|uploaded_documents|additional_data|timestamp|command-name|command-message|command-args|command-contents)[^>]*>[\s\S]*?<\/\1>[ \t]*\r?\n?/gi; + +export function stripMetaBlocks(text: string): string { + let out = text; + for (let i = 0; i < 10; i++) { + const next = out.replace(META_BLOCK_RE, ''); + if (next === out) break; + out = next; + } + return out.trim(); +} + +/** + * 从一条用户消息里提取真实用户输入。 + * CodeBuddy / Cursor 会把真实提问包在 ... 里(外层还挂着大段 + * / 元信息),直接取包裹内容最干净;没有该包裹的平台走元信息剥离。 + */ +export function extractUserText(text: string): string { + const m = text.match(/]*>([\s\S]*?)<\/user_query>/i); + return stripMetaBlocks(m ? m[1] : text); +} + +/** 附件引用(@image:/path、@file:/path)与工具结果占位不是标题素材。 */ +const ATTACH_INLINE_RE = /@[A-Za-z_]+:[^\s]+/g; + +/** + * 用户气泡里该显示的正文:元信息块 + 附件引用都不显示,只留真实提问。 + * + * 各 IDE 会把 `` / `` / `` / `` 等注入块 + * 和附件路径(`@image:/abs/path`)拼进用户消息,真话则在 `` 里。附件路径可能 + * 含空格(如 "Application Support"),而它与正文之间用 2+ 空格分隔,所以按 2+ 空格切段后 + * 丢掉 `@tag:` 开头或绝对路径开头的段。 + */ +export function visibleUserText(text: string): string { + const lines = extractUserText(text).split('\n'); + const kept: string[] = []; + for (const line of lines) { + const segs = line + .split(/\s{2,}/) + .map((s) => s.trim()) + .filter((s) => { + if (!s) return false; + if (ATTACH_SEG_RE.test(s) || PATH_SEG_RE.test(s)) return false; + // 半截附件路径(按空格切断后剩下的片段) + if (/\.(png|jpe?g|gif|webp|pdf|md|txt|json|jsonl|log)\b/i.test(s) && !/[??。!!]/.test(s.replace(/\.\w+$/, ''))) { + return false; + } + return true; + }); + if (segs.length) kept.push(segs.join(' ')); + } + return kept.join('\n').replace(/[ \t]{2,}/g, ' ').trim(); +} +/** 只看「看起来就是路径」的片段:@tag: 开头或绝对路径开头(正文里的 http URL 不算)。 */ +const ATTACH_SEG_RE = /^@[A-Za-z_]+:/; +const PATH_SEG_RE = /^(?:[A-Za-z]:)?[/\\]/; +const HYGIENE_RE = /^\[tool_result/i; + +/** + * 由首条用户消息得到「像人话」的标题。 + * + * Cursor 会把附件引用与正文用多空格拼在同一行(路径还可能含空格),所以按 2+ 空格切段、 + * 丢掉「肯定是路径/附件引用」的段(@tag: 开头、绝对路径开头),取第一段正常文本;顺手 + * 跳过工具结果占位。含 URL 的正常句子保留(很多提问正文本身带链接)。 + * 取不到返回空串,由调用方走 fallbackTitle。 + */ +export function titleFromUserText(text: string, maxLen = TITLE_MAX): string { + const cleaned = extractUserText(text).replace(ATTACH_INLINE_RE, ' '); + for (const line of cleaned.split('\n')) { + for (const seg of line.split(/\s{2,}/)) { + const s = seg.replace(/\s+/g, ' ').replace(/^[\s\-:,,。.]+|[\s\-:,,。.]+$/g, ''); + if (!s || s.length < 2 || HYGIENE_RE.test(s)) continue; + if (ATTACH_SEG_RE.test(s) || PATH_SEG_RE.test(s)) continue; + return s.slice(0, maxLen); + } + } + return ''; +} + +/** + * 文本是否值得作为消息渲染。 + * + * 源平台会把水平线/围栏/空列表项序列化成独立文本块("-" / "---" / "*" / "```" 等), + * 它们渲染出来就是一颗颗空 bullet。这类纯 markdown 修饰符块跳过(原文仍保留在 + * response_item / transcript 里,不影响保真度)。 + */ +export function isRenderableText(text: string): boolean { + return /[^\s\-*•·>#`|~_+=()[\]!.,;:?"'\\/0-9—–‘’“”…]/.test(text); +} diff --git a/src/session-flow/workbuddy-store.ts b/src/session-flow/workbuddy-store.ts new file mode 100644 index 000000000..010c953be --- /dev/null +++ b/src/session-flow/workbuddy-store.ts @@ -0,0 +1,195 @@ +/** + * workbuddy-store.ts — 把迁移出来的会话注册进 WorkBuddy 的本地数据库。 + * + * 背景:WorkBuddy 的「任务 / 空间」列表**不是**扫 `~/.workbuddy/projects//*.jsonl` + * 列出来的,而是查 `~/.workbuddy/workbuddy.db` 的 `sessions` 表(Drizzle + WAL): + * - 列表项 = sessions 行(title / updated_at / cwd / is_playground …) + * - 空间分组 = workspaces 表(path + last_opened_at) + * 只写 jsonl 的话会话在 WorkBuddy 里完全不可见(迁移「成功」但看不到), + * 与 Cursor 的 composerHeaders / Codex 的 state_5.threads 是同一类问题。 + * + * 这里 best-effort 做三件事: + * 1. 确保 workspaces 里有该 cwd(否则会话不属于任何「空间」) + * 2. upsert 一条 sessions 行(user_id 沿用库内既有值——它是账号标识,不能编造) + * 3. 失败一律返回 {ok:false, reason},由调用方决定是否提示;不影响 jsonl 已落盘 + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { getWorkBuddyProjectsDir } from './fs.js'; +import { findSqlite3 } from './sqlite.js'; + +/** WorkBuddy 数据根目录(`~/.workbuddy`,projects/db 都在其下)。 */ +export function getWorkBuddyHome(): string { + return path.dirname(getWorkBuddyProjectsDir()); +} + +export function getWorkBuddyDbPath(): string { + return path.join(getWorkBuddyHome(), 'workbuddy.db'); +} + +function esc(value: string): string { + return value.replace(/'/g, "''"); +} + +/** 用 sqlite3 CLI 执行一段 SQL(临时文件 mode 0600,执行完删除)。 */ +function runSql(dbPath: string, sql: string, timeoutMs = 30_000): { ok: boolean; reason?: string } { + const sqlite3 = findSqlite3(); + if (!sqlite3) return { ok: false, reason: 'sqlite3 CLI not found' }; + + const sqlPath = path.join(os.tmpdir(), `teamai-workbuddy-${process.pid}-${Date.now()}.sql`); + try { + fs.writeFileSync(sqlPath, sql, { encoding: 'utf-8', mode: 0o600 }); + const r = spawnSync(sqlite3, [dbPath], { + input: fs.readFileSync(sqlPath), + maxBuffer: 32 * 1024 * 1024, + timeout: timeoutMs, + }); + if (r.status !== 0) { + const stderr = (r.stderr?.toString() ?? '').trim(); + return { ok: false, reason: stderr.slice(0, 300) || `sqlite3 exit ${r.status}` }; + } + } catch (e) { + return { ok: false, reason: (e as Error).message }; + } finally { + try { + fs.unlinkSync(sqlPath); + } catch { + // ignore + } + } + return { ok: true }; +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * 取得本机的 WorkBuddy 账号标识 user_id(sessions.user_id 是 NOT NULL,且客户端按它过滤列表)。 + * + * 多源探测,按可靠度降序: + * 1. workbuddy.db 里既有会话行的 user_id —— 最权威(就是客户端自己写的) + * 2. ~/.workbuddy/connectors// 的目录名 —— 客户端按账号分的目录,实测与 user_id 同值 + * 3. ~/.workbuddy/app/sessions.json 里出现的 uuid —— 兜底 + * + * 注意:**不要用 ~/.workbuddy/device-id 兜底**。实测 device-id(76345293-…) ≠ user_id(b2778798-…), + * 它是设备标识不是账号标识,写进去客户端仍按 user_id 过滤 → 会话照样不可见,还留一条脏数据。 + * 三个来源都拿不到(真·全新未登录)时返回 null,由调用方跳过注册并告警。 + */ +function readUserId(dbPath: string): string | null { + const sqlite3 = findSqlite3(); + + // 1) 库内既有会话 + if (sqlite3 && fs.existsSync(dbPath)) { + try { + const r = spawnSync( + sqlite3, + ['-readonly', dbPath, "select user_id from sessions where user_id is not null and user_id <> '' limit 1;"], + { encoding: 'utf-8', timeout: 10_000 }, + ); + const v = (r.stdout ?? '').trim(); + if (UUID_RE.test(v)) return v; + } catch { + // 落到下一来源 + } + } + + const home = getWorkBuddyHome(); + + // 2) connectors/ 目录名 + try { + const connectorsDir = path.join(home, 'connectors'); + for (const name of fs.readdirSync(connectorsDir)) { + if (UUID_RE.test(name)) return name; + } + } catch { + // 落到下一来源 + } + + // 3) app/sessions.json 里的 uuid + try { + const raw = fs.readFileSync(path.join(home, 'app', 'sessions.json'), 'utf-8'); + for (const m of raw.matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi)) { + return m[0]; + } + } catch { + // 拿不到就跳过注册 + } + + return null; +} + +export interface RegisterWorkBuddySessionArgs { + /** 会话工作目录(绝对路径,决定归属哪个「空间」)。 */ + cwd: string; + sessionId: string; + title: string; + /** epoch 毫秒。 */ + createdAtMs: number; + updatedAtMs: number; + /** 源会话模型名(可空,原生常见 'auto')。 */ + model?: string; +} + +export interface RegisterWorkBuddyResult { + ok: boolean; + reason?: string; +} + +/** + * 把会话注册进 WorkBuddy 的 sessions 表(并按需补 workspaces 行)。 + * + * 幂等:同一 sessionId 重复迁移走 ON CONFLICT DO UPDATE,不会产生重复项。 + */ +export function registerWorkBuddySession(args: RegisterWorkBuddySessionArgs): RegisterWorkBuddyResult { + const dbPath = getWorkBuddyDbPath(); + if (!fs.existsSync(dbPath)) { + return { ok: false, reason: `workbuddy.db not found: ${dbPath}` }; + } + + // user_id 是账号标识,编造会导致列表按用户过滤时看不到 → 没有既有行就不写 + const userId = readUserId(dbPath); + if (!userId) { + return { ok: false, reason: 'no existing session row to derive user_id from' }; + } + + const created = Number.isFinite(args.createdAtMs) ? args.createdAtMs : Date.now(); + const updated = Number.isFinite(args.updatedAtMs) ? args.updatedAtMs : created; + const model = args.model && args.model.trim() ? args.model.trim() : 'auto'; + + const sql = [ + // WorkBuddy 运行时持有写锁:给有限 busy 超时,避免 CLI 挂起 + 'PRAGMA busy_timeout=5000;', + 'BEGIN IMMEDIATE;', + // 1) 空间(workspaces)——没有这行会话不属于任何空间,界面里无处显示 + 'INSERT INTO workspaces (path, last_opened_at) VALUES ' + + `('${esc(args.cwd)}', ${updated}) ` + + `ON CONFLICT(path) DO UPDATE SET last_opened_at = MAX(last_opened_at, ${updated});`, + // 2) 会话行。is_playground=0 → 归入「空间」列表(=0 与原生在项目里开的会话一致); + // custom_title 留空,让 title 生效。 + 'INSERT INTO sessions ' + + '(id, cwd, user_id, title, custom_title, status, created_at, updated_at, deleted_at, ' + + 'is_playground, source_mode, model, last_activity_at) VALUES (' + + `'${esc(args.sessionId)}','${esc(args.cwd)}','${esc(userId)}','${esc(args.title)}','',` + + `'completed',${created},${updated},NULL,0,NULL,'${esc(model)}',${updated}) ` + + 'ON CONFLICT(id) DO UPDATE SET ' + + 'cwd=excluded.cwd, title=excluded.title, status=excluded.status, ' + + 'updated_at=excluded.updated_at, last_activity_at=excluded.last_activity_at;', + 'COMMIT;', + ].join('\n'); + + return runSql(dbPath, sql); +} + +/** 从 WorkBuddy 列表里移除该会话(迁移回滚 / 删除会话时调用)。 */ +export function unregisterWorkBuddySession(sessionId: string): RegisterWorkBuddyResult { + const dbPath = getWorkBuddyDbPath(); + if (!fs.existsSync(dbPath)) return { ok: false, reason: 'workbuddy.db not found' }; + const sql = + 'PRAGMA busy_timeout=5000;\n' + + 'BEGIN IMMEDIATE;\n' + + `DELETE FROM sessions WHERE id='${esc(sessionId)}';\n` + + 'COMMIT;'; + return runSql(dbPath, sql); +} From 9c0a2a3724441959fc28df15431cfdbb2eab92de Mon Sep 17 00:00:00 2001 From: lurkacai Date: Tue, 22 Sep 2026 11:34:42 +0800 Subject: [PATCH 09/28] feat(session): redact secrets during migration (--scrub) Migrated sessions are raw transcripts: whatever was pasted into the conversation -- tokens, keys, passwords, internal hosts -- travels with it into the target agent's store, and from there into anything archived later. `session save` already redacts; migration had no equivalent. `session migrate --scrub` runs the whole IR through the existing `utils/redact` (the same rules `session save` uses, plus secrets found in the current environment) before writing: - text and thinking blocks - tool call arguments (serialized, redacted as a whole, then parsed back so the structure stays an object) - tool results - the session title, since it is what shows up in the target's list The report says how many values were replaced, and reminds that redact is best-effort (pattern matching, not a guarantee) -- same caveat as `session save`. Off by default: a local migration should stay lossless unless asked otherwise. --- src/__tests__/scrub-session.test.ts | 88 ++++++++++++++++++++++ src/session-flow/migrate.ts | 26 ++++++- src/session-flow/scrub.ts | 109 ++++++++++++++++++++++++++++ src/session-flow/session-cmd.ts | 8 +- 4 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/scrub-session.test.ts create mode 100644 src/session-flow/scrub.ts diff --git a/src/__tests__/scrub-session.test.ts b/src/__tests__/scrub-session.test.ts new file mode 100644 index 000000000..fbe5e3f00 --- /dev/null +++ b/src/__tests__/scrub-session.test.ts @@ -0,0 +1,88 @@ +/** + * scrub.test.ts — 迁移脱敏(--scrub)。 + */ +import { describe, expect, it } from 'vitest'; +import { scrubSession } from '../session-flow/scrub.js'; +import type { Session } from '../session-flow/ir.js'; + +function makeSession(): Session { + return { + sessionId: 'cccc1111-dddd-4eee-8fff-000011112222', + title: '调接口: sk-ABCDEFGH1234567890abcdefghij', + cwd: '/tmp/proj', + platform: 'claude-code', + createdAt: '2026-09-21T10:00:00.000Z', + updatedAt: '2026-09-21T10:00:06.000Z', + messages: [ + { + role: 'user', + content: [{ type: 'text', text: 'token 是 sk-ABCDEFGH1234567890abcdefghij' }], + timestamp: '2026-09-21T10:00:00.000Z', + }, + { + role: 'assistant', + content: [ + { type: 'text', text: '好的' }, + { + type: 'tool_call', + toolName: 'bash', + callId: 'toolu_1', + arguments: { command: 'curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abcdefghijklmnop" https://x' }, + }, + ], + timestamp: '2026-09-21T10:00:05.000Z', + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + callId: 'toolu_1', + content: 'password=hunter2SecretValue db=postgres://u:pw123456@10.0.0.5:5432/app', + isError: false, + }, + ], + timestamp: '2026-09-21T10:00:06.000Z', + }, + ], + metadata: {}, + }; +} + +describe('scrubSession', () => { + it('文本 / 工具参数 / 工具结果 / 标题都被脱敏', () => { + const result = scrubSession(makeSession()); + const dump = JSON.stringify(result.session); + + expect(dump).not.toContain('sk-ABCDEFGH1234567890abcdefghij'); + expect(dump).not.toContain('hunter2SecretValue'); + expect(dump).not.toContain('pw123456'); + expect(dump).toContain(' { + const before = makeSession(); + const after = scrubSession(before).session; + expect(after.messages).toHaveLength(before.messages.length); + expect(after.messages[1].content.map((b) => b.type)).toEqual( + before.messages[1].content.map((b) => b.type), + ); + // 工具参数仍是对象,不能退化成字符串 + const call = after.messages[1].content.find((b) => b.type === 'tool_call'); + expect(call).toBeTruthy(); + expect(typeof (call as { arguments: unknown }).arguments).toBe('object'); + }); + + it('无敏感内容时原样返回、计数为 0', () => { + const clean = makeSession(); + clean.title = '普通提问'; + clean.messages = [ + { role: 'user', content: [{ type: 'text', text: '帮我看下这个报错' }], timestamp: clean.createdAt }, + ]; + const result = scrubSession(clean); + expect(result.redactedCount).toBe(0); + expect(result.session.messages[0].content[0]).toEqual({ type: 'text', text: '帮我看下这个报错' }); + }); +}); diff --git a/src/session-flow/migrate.ts b/src/session-flow/migrate.ts index 08f4c2834..8c370b4ca 100644 --- a/src/session-flow/migrate.ts +++ b/src/session-flow/migrate.ts @@ -10,6 +10,7 @@ import type { Session, ContentBlock } from './ir.js'; import { getAdapter, listAvailablePlatforms, listInstalledPlatforms } from './adapters/index.js'; +import { scrubSession } from './scrub.js'; // --------------------------------------------------------------------------- // 各平台能力矩阵 @@ -232,6 +233,8 @@ export interface MigrationResult { targetSessionId?: string; /** 实际写入的目标工作区(默认 = 源会话 cwd,显式 --target-cwd 时为其值)。 */ targetCwd?: string; + /** --scrub 时被替换掉的疑似密钥数量(未脱敏时无此字段)。 */ + redactedCount?: number; targetFilePath?: string; error?: string; startedAt: string; @@ -288,7 +291,12 @@ export class MigrationEngine { }; } - async migrate(sessionId: string, projectPath?: string, targetProjectPath?: string): Promise { + async migrate( + sessionId: string, + projectPath?: string, + targetProjectPath?: string, + scrub = false, + ): Promise { const startedAt = new Date().toISOString(); let targetSid: string | undefined; @@ -309,7 +317,11 @@ export class MigrationEngine { const fidelity = fidelityFromSession(session, this.targetPlatform); // 降级 ThinkingBlock - const enhancedSession = degradeThinkingBlocks(session, this.targetPlatform); + // 脱敏在 thinking 降级之后、写入之前:--scrub 时整份 IR 过一遍 redact, + // 让敏感内容不会随会话扩散到目标端(以及后续可能的团队归档)。 + const degraded = degradeThinkingBlocks(session, this.targetPlatform); + const scrubbed = scrub ? scrubSession(degraded) : null; + const enhancedSession = scrubbed ? scrubbed.session : degraded; // 目标工作区语义:**默认保持源会话的工作区**。 // 迁移是「把 thpc 的会话搬到 Codex/WorkBuddy」,而不是「搬到我当前所在的目录」; @@ -344,6 +356,7 @@ export class MigrationEngine { success: true, targetSessionId: targetSid, targetCwd, + ...(scrubbed ? { redactedCount: scrubbed.redactedCount } : {}), targetFilePath, startedAt, completedAt, @@ -390,10 +403,15 @@ export class MigrationEngine { } } - async migrateBatch(sessionIds: string[], projectPath?: string, targetProjectPath?: string): Promise { + async migrateBatch( + sessionIds: string[], + projectPath?: string, + targetProjectPath?: string, + scrub = false, + ): Promise { const results: MigrationResult[] = []; for (const sid of sessionIds) { - results.push(await this.migrate(sid, projectPath, targetProjectPath)); + results.push(await this.migrate(sid, projectPath, targetProjectPath, scrub)); } return results; } diff --git a/src/session-flow/scrub.ts b/src/session-flow/scrub.ts new file mode 100644 index 000000000..35d0019b1 --- /dev/null +++ b/src/session-flow/scrub.ts @@ -0,0 +1,109 @@ +/** + * scrub.ts — 迁移前的会话脱敏。 + * + * `session migrate --scrub` 会把整份 IR 过一遍 `utils/redact`: + * 文本、思考块、工具调用参数、工具结果全部覆盖,密钥形状与当前环境变量里的 + * 疑似密钥都会被替换成 `` 占位符。 + * + * 为什么要放在迁移链路里: + * - 迁移的目标通常是另一个 agent 的本地存储,那里的内容随时可能被 `session push` + * 归档到团队可读的仓库;在迁出时就脱敏,敏感内容不会跟着会话扩散。 + * - 复用 `utils/redact`(`session save` 用的就是它),不新造一套规则,行为一致。 + * + * 注意:redact() 是 best-effort(规则匹配,不保证零漏)。脱敏后仍建议人工过一遍 + * 再归档;这一点与 `session save` 的注释保持一致。 + */ + +import type { Session, Message, ContentBlock } from './ir.js'; +import { redactWithEnv } from '../utils/redact.js'; + +export interface ScrubResult { + session: Session; + /** 被替换掉的疑似密钥数量(按替换处计数,用于迁移报告)。 */ + redactedCount: number; +} + +const REDACTED_MARKER = '`)。 */ +function countRedactions(before: string, after: string): number { + if (before === after) return 0; + let count = 0; + let idx = after.indexOf(REDACTED_MARKER); + while (idx >= 0) { + count++; + idx = after.indexOf(REDACTED_MARKER, idx + REDACTED_MARKER.length); + } + return count; +} + +function scrubBlock(block: ContentBlock): { block: ContentBlock; count: number } { + switch (block.type) { + case 'text': { + const next = redactWithEnv(block.text); + return { + block: next === block.text ? block : { type: 'text', text: next }, + count: countRedactions(block.text, next), + }; + } + case 'thinking': { + const next = redactWithEnv(block.text); + return { + block: next === block.text ? block : { ...block, text: next }, + count: countRedactions(block.text, next), + }; + } + case 'tool_call': { + // 参数是结构化对象:序列化后整段脱敏再解析回来,避免只对字符串字段生效 + const raw = JSON.stringify(block.arguments ?? {}); + const next = redactWithEnv(raw); + const count = countRedactions(raw, next); + if (count === 0) return { block, count: 0 }; + let parsed: Record = {}; + try { + parsed = JSON.parse(next) as Record; + } catch { + // 脱敏后不再是合法 JSON(极罕见:占位符插进了 key 名):退回原参数 + return { block, count: 0 }; + } + return { block: { ...block, arguments: parsed }, count }; + } + case 'tool_result': { + const next = redactWithEnv(block.content); + return { + block: next === block.content ? block : { ...block, content: next }, + count: countRedactions(block.content, next), + }; + } + case 'image': + // 图片内容不参与文本脱敏(二进制/URL 形态无密钥文本特征) + return { block, count: 0 }; + } +} + +/** + * 对会话做脱敏(纯函数,不修改入参)。 + * + * 标题也一起处理:首条提问里常常直接贴着 token,而标题会显示在目标端的列表里。 + */ +export function scrubSession(session: Session): ScrubResult { + let redactedCount = 0; + + const messages: Message[] = session.messages.map((msg) => { + let contentChanged = false; + const content = msg.content.map((block) => { + const { block: next, count } = scrubBlock(block); + redactedCount += count; + if (next !== block) contentChanged = true; + return next; + }); + return contentChanged ? { ...msg, content } : msg; + }); + + const title = redactWithEnv(session.title); + + return { + session: { ...session, title, messages }, + redactedCount, + }; +} diff --git a/src/session-flow/session-cmd.ts b/src/session-flow/session-cmd.ts index 17391ef2e..6ef001101 100644 --- a/src/session-flow/session-cmd.ts +++ b/src/session-flow/session-cmd.ts @@ -261,6 +261,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { .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('--scrub', 'Redact secrets (tokens/keys/passwords) from the session before writing it') .option('--all', 'Migrate every session from source (not just the 5 most recent)') .option('--limit ', 'Max sessions to migrate (only caps --all; ignored otherwise)') .option('-y, --yes', 'Skip confirmation prompt') @@ -402,11 +403,16 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { // encoded 形式(如 `-Users-foo-project`),无法还原真实路径。 // 目标工作区默认 = 源会话工作区(保持目录一致); // 只有显式 --target-cwd 才把会话搬到别的工作区。 - const result = await engine.migrate(m.sessionId, sourceProjectPath, opts.targetCwd); + const result = await engine.migrate(m.sessionId, sourceProjectPath, opts.targetCwd, Boolean(opts.scrub)); if (result.success) { console.log(`\n ✓ Migration successful`); console.log(` Target session ID: ${result.targetSessionId}`); if (result.targetCwd) console.log(` Target CWD: ${result.targetCwd}`); + if (opts.scrub) { + console.log( + ` Redacted: ${result.redactedCount ?? 0} secret-looking value(s) (best-effort; review before archiving)`, + ); + } if (result.targetFilePath) { console.log(` Target file: ${result.targetFilePath}`); } From 57d4a0ede53e0647315442b1ea26a10b0810131d Mon Sep 17 00:00:00 2001 From: lurkacai Date: Tue, 22 Sep 2026 15:00:36 +0800 Subject: [PATCH 10/28] feat(session): warn on unredacted push; --scrub redacts before archiving Reviewer note 3: `session push` archives full raw transcripts into a team-readable repo, so anything pasted during a session (tokens, keys, passwords, internal hosts) becomes readable by everyone with access. - `session push --scrub` redacts each session through utils/redact before it is written (same rules as `session save` plus secrets found in the current environment), and reports how many values were replaced. - Without --scrub, the command now says so explicitly: "Archived as-is: full transcripts (possibly secrets/paths) are team-readable. Use --scrub to redact." No more silent full-text archiving. `session migrate --scrub` (previous commit) covers the migration path with the same rules, so redacting at migration time also makes later pushes clean. --- src/session-flow/session-cmd.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/session-flow/session-cmd.ts b/src/session-flow/session-cmd.ts index 6ef001101..81f896edc 100644 --- a/src/session-flow/session-cmd.ts +++ b/src/session-flow/session-cmd.ts @@ -21,6 +21,7 @@ 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 { scrubSession } from './scrub.js'; import { MigrationEngine } from './migrate.js'; import { SyncManager, getRepoIdentity, getGitAuthor, defaultSyncMeta } from './sync.js'; import { SessionSearchEngine, type LoadedSession } from './search.js'; @@ -511,6 +512,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { .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)') + .option('--scrub', 'Redact secrets before archiving (archived sessions are team-readable)') .action(async (opts) => { try { const source = opts.source; @@ -555,10 +557,15 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { const syncMgr = new SyncManager(repoRoot); let saved = 0; + let redactedTotal = 0; for (const m of selected) { // --all 时会话可能来自任意工作区,scoped 查找(按 cwd 编码目录)会因 // 目录名解码有损而 miss——交由适配器全局查找;单 cwd 模式仍传 workCwd。 - const session = await adapter.readSession(m.sessionId, opts.all ? undefined : workCwd); + const readSession = await adapter.readSession(m.sessionId, opts.all ? undefined : workCwd); + // 归档的是完整原文,团队可读:--scrub 时先脱敏;未脱敏时明确提示一次。 + const scrubbed = opts.scrub ? scrubSession(readSession) : null; + const session = scrubbed ? scrubbed.session : readSession; + if (scrubbed) redactedTotal += scrubbed.redactedCount; if (opts.all) { console.log(` Source: ${session.cwd || 'unknown directory'}`); } @@ -576,6 +583,13 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { syncMgr.saveSession(session, meta); saved++; } + if (opts.scrub) { + console.log(` Redacted: ${redactedTotal} secret-looking value(s) (best-effort; review before sharing)`); + } else if (saved > 0) { + console.log( + ' ⚠ Archived as-is: full transcripts (possibly secrets/paths) are team-readable. Use --scrub to redact.', + ); + } // [已修] gitCommit 失败(非 git 目录 / index.lock 竞态)此前裸堆栈崩溃 const commitHash = runGitStep( () => syncMgr.gitCommit(`sync: push ${saved} session(s) from ${source}${opts.all ? ' (all workspaces)' : ''}`), From 3ed4a853bcff9680095ddbb16c8d5dad77b8701d Mon Sep 17 00:00:00 2001 From: lurkacai Date: Tue, 22 Sep 2026 15:11:26 +0800 Subject: [PATCH 11/28] =?UTF-8?q?fix(session):=20review=20sweep=20?= =?UTF-8?q?=E2=80=94=20injection,=20dry-run,=20archive=20correctness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static review found real defects in the migration/archive path. All reproduced or verified against the built CLI: - codex rollback built DELETE statements by string interpolation of a CLI-provided session id: `' OR 1=1; --` would wipe every thread from state_5. Reject non-v7 ids and escape quotes. - sync gitCommit committed everything staged in the repo (`git commit -m` without a pathspec) and treated a failed commit as success (rev-parse returned the previous HEAD). Now commits only sessions/, and compares HEAD before/after -- no new commit is an error. - encodeRepoIdentity mapped `_` and `/` to the same character, so github.com/org/a_b and github.com/org/a/b shared one archive directory and mixed sessions. Encoding is now reversible (`_` -> `__`). - git author names are free-form: sanitize before using as a path segment (':'/'/'/'..'/trailing dots would escape the author dir). - archived sessions rebuilt their title from the truncated file-name slug on load. The title is now persisted in origin.title and used verbatim on read. - migrate --all now enumerates every workspace when no --cwd is given (it used to silently mean "everything in the current directory"), and the usage guide no longer claims "the 5 most recent". - push/pull/resume honor --dry-run: list what would happen and stop before writing, committing, or restoring. - codex readSession extracts the title from content like the listing path; push archives no longer inherit "Session ". --- docs/usage-guide.md | 2 +- src/session-flow/adapters/codex.ts | 45 +++++++++++++++++++--- src/session-flow/adapters/cursor.ts | 10 +++-- src/session-flow/session-cmd.ts | 35 ++++++++++++++++- src/session-flow/sync.ts | 59 +++++++++++++++++++++++++---- 5 files changed, 133 insertions(+), 18 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index eea651028..d64e849da 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1728,7 +1728,7 @@ Supported platforms: `claude-code` (plus `claude-internal` / `tclaude`), `codex` ```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 migrate --all -s codebuddy -t claude-code # every session of the source 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 diff --git a/src/session-flow/adapters/codex.ts b/src/session-flow/adapters/codex.ts index 47092c590..61a4adda7 100644 --- a/src/session-flow/adapters/codex.ts +++ b/src/session-flow/adapters/codex.ts @@ -41,6 +41,7 @@ import { imagePlaceholderText } from '../ir.js'; import { titleFromUserText, visibleUserText } from '../title.js'; import { deriveTargetSessionId } from '../ids.js'; import { findSqlite3 } from '../sqlite.js'; +import { log } from '../../utils/logger.js'; import { getCodexSessionsDir, resolveRealCwd, @@ -461,7 +462,33 @@ export class CodexAdapter extends AgentAdapter { } } - const title = this.extractTitle(f); + // Same content-based extraction as listConversations: the filename fallback + // would leak "Session " into push archives and resume targets. + let readTitle = ''; + try { + for (const rec of readJsonlHead(f, 200)) { + const recPayload = (rec.payload as Record) ?? {}; + let text = ''; + if (rec.type === 'event_msg' && recPayload.type === 'item_completed') { + const item = recPayload.item as Record | undefined; + if (item?.type !== 'UserMessage') continue; + const content = item.content as Array> | undefined; + text = (content ?? []).map((c) => String(c.text ?? '')).join(' '); + } else if (rec.type === 'response_item' && recPayload.type === 'message' && recPayload.role === 'user') { + const content = recPayload.content as Array> | undefined; + text = (content ?? []).map((c) => String(c.text ?? '')).join(' '); + } + if (!text) continue; + const cleaned = titleFromUserText(visibleUserText(text)); + if (cleaned) { + readTitle = cleaned; + break; + } + } + } catch { + // ignore + } + const title = readTitle || this.extractTitle(f); return { sessionId, @@ -1020,6 +1047,14 @@ export class CodexAdapter extends AgentAdapter { * items/turns/投影水位。best-effort:CLI 缺失或加锁失败都不影响 rollout 删除。 */ private async unregisterThread(sessionId: string): Promise { + // Defense in depth against SQL injection: the id comes straight from the CLI + // argument, so a value like `' OR 1=1; --` would wipe the whole table. + // Require the Codex id shape (uuid v7) AND escape quotes anyway. + if (!isUuidV7(sessionId)) { + log.debug(`codex unregister skipped: not a codex session id: ${sessionId.slice(0, 12)}`); + return; + } + const safeId = sessionId.replace(/'/g, "''"); const bin = findSqlite3(); if (!bin) return; const home = path.dirname(this.storageRoot); // ~/.codex @@ -1027,15 +1062,15 @@ export class CodexAdapter extends AgentAdapter { [ path.join(home, 'state_5.sqlite'), [ - `DELETE FROM threads WHERE id='${sessionId}';`, - `DELETE FROM thread_history_projection_state WHERE thread_id='${sessionId}';`, + `DELETE FROM threads WHERE id='${safeId}';`, + `DELETE FROM thread_history_projection_state WHERE thread_id='${safeId}';`, ], ], [ path.join(home, 'thread_history_1.sqlite'), [ - `DELETE FROM thread_items WHERE thread_id='${sessionId}';`, - `DELETE FROM thread_turns WHERE thread_id='${sessionId}';`, + `DELETE FROM thread_items WHERE thread_id='${safeId}';`, + `DELETE FROM thread_turns WHERE thread_id='${safeId}';`, ], ], ]; diff --git a/src/session-flow/adapters/cursor.ts b/src/session-flow/adapters/cursor.ts index 437c8c6f0..406b80151 100644 --- a/src/session-flow/adapters/cursor.ts +++ b/src/session-flow/adapters/cursor.ts @@ -333,21 +333,23 @@ export class CursorAdapter extends AgentAdapter { // 提取标题 let title = ''; + // Same unwrap strategy as the listing path: slash-command messages are + // "injection head + real question", so dropping the whole block loses the + // question. titleFromCandidates unwraps and strips metadata. + const candidates: string[] = []; 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; + candidates.push(block.text); } } if (title) break; } } - if (!title) title = `Session ${sessionId.slice(0, 8)}`; + if (!title) title = titleFromCandidates(candidates) || fallbackTitle(sessionId); let createdAt: string; let updatedAt: string; diff --git a/src/session-flow/session-cmd.ts b/src/session-flow/session-cmd.ts index 81f896edc..48a003f83 100644 --- a/src/session-flow/session-cmd.ts +++ b/src/session-flow/session-cmd.ts @@ -283,12 +283,22 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { let workCwd = opts.cwd ?? process.cwd(); const sourceAdapter = safeGetAdapter(source); let metas = await sourceAdapter.listConversations(workCwd); + let crossDirExpanded = false; + // --all 的语义是"这个源的全部会话":无 --cwd 时跨所有工作区枚举, + // 而不是只看当前目录(那会让 --all 静默变成"当前目录的全部")。 + if (opts.all && !opts.cwd && metas.length >= 0 && !sessionId) { + const allMetas = await sourceAdapter.listConversations(); + if (allMetas.length > 0) { + metas = allMetas; + crossDirExpanded = true; + } + } + // 当前 cwd 无会话时,交互式提示列出全部目录的会话 // 展开后这些会话**不属于 workCwd**,源端定位必须传 undefined 让适配器全局按 id 查找 // (各适配器 findSessionFile 都有该兜底)。此前仍把 workCwd 传给源适配器, // claude-code 只在 encodeCwdClaude(workCwd) 一个目录里找 → 这条路径 100% 失败。 - let crossDirExpanded = false; if (metas.length === 0 && !opts.cwd && !sessionId) { const allMetas = await sourceAdapter.listConversations(); if (allMetas.length > 0) { @@ -457,6 +467,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { cwd: session.cwd || opts.targetCwd || workCwd, sessionId: t.sessionId, repoIdentity: deriveArchiveIdentity(session, target), + title: session.title, }, session.createdAt, ); @@ -541,6 +552,16 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { return; } + // dry-run:列出将归档的会话后停止,不写盘、不提交、不推送 + if (isDryRun()) { + console.log(`\nDry-run: would archive ${selected.length} session(s) from ${source}:`); + for (const m of selected) { + console.log(` ${m.sessionId.slice(0, 8)} ${m.title.slice(0, 50)} (${m.messageCount} msgs)`); + } + console.log(` Repo root: ${repoRoot}`); + return; + } + // 大批量确认:--all 推送超过 5 条时列清单(id/标题/条数)要求确认,-y 跳过 if (opts.all && selected.length > 5 && !opts.yes) { console.log(`\nAbout to push ${selected.length} session(s) from ${source}:`); @@ -577,6 +598,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { cwd: session.cwd || workCwd, sessionId: m.sessionId, repoIdentity: deriveArchiveIdentity(session, source), + title: session.title, }, session.createdAt, ); @@ -620,6 +642,10 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { const repoRoot = resolveRepoRoot(opts.repoRoot); const syncMgr = new SyncManager(repoRoot); + if (isDryRun()) { + console.log(`Dry-run: would pull from the team repo remote and rebuild indexes (repo root: ${repoRoot}).`); + return; + } // [已修] gitPull 失败(无 remote / repoRoot 不存在)此前裸堆栈崩溃 runGitStep(() => { syncMgr.gitPull(); @@ -729,6 +755,13 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { const resumeCwd = opts.cwd ?? process.cwd(); session.cwd = resumeCwd; + if (isDryRun()) { + console.log( + `Dry-run: would restore "${session.title}" (${session.messages.length} msgs) into ${opts.platform} at ${resumeCwd}.`, + ); + return; + } + const newSessionId = await resumeAdapter.writeSession(session, resumeCwd); console.log(`\n ✓ Session restored to ${opts.platform}`); diff --git a/src/session-flow/sync.ts b/src/session-flow/sync.ts index 4db05469d..be45271d6 100644 --- a/src/session-flow/sync.ts +++ b/src/session-flow/sync.ts @@ -103,10 +103,39 @@ export function canonicalizeRemote(remote: string): string { } /** - * 将 canonical remote 编码为目录安全字符串(/ → _)。 + * Encode a canonical remote into a directory-safe string, reversibly. + * + * `_` is escaped to `__` first, then every other non-whitelisted char + * (including `/`) becomes `_`. Without the escape step, `github.com/org/a_b` + * and `github.com/org/a/b` would encode to the same directory and mix two + * repositories' sessions together. + * + * Reversible via decodeRepoIdentity. */ export function encodeRepoIdentity(identity: string): string { - return identity.replace(/[^a-zA-Z0-9.-]/g, '_'); + const escaped = identity.replace(/_/g, '__'); + return escaped.replace(/[^a-zA-Z0-9.-]/g, '_'); +} + +/** Undo encodeRepoIdentity (used for display; the canonical id stays in meta). */ +export function decodeRepoIdentity(encoded: string): string { + return encoded.replace(/_(?!_)/g, '/').replace(/__/g, '_'); +} + +/** + * Sanitize a path segment (git author names are free-form text and may + * contain ':', '/', '..', trailing dots, or Windows-invalid characters). + * Collisions are acceptable here -- the canonical identity lives in + * meta/_index.json, not in the directory name. + */ +function sanitizePathSegment(name: string): string { + const cleaned = name + .replace(/[\u0000-\u001f<>:"|?*]/g, '_') + .replace(/\//g, '_') + .replace(/^\.+$|^\.\.$/g, '_') + .replace(/[. ]+$/g, '_') + .trim(); + return cleaned || 'unknown'; } /** @@ -137,6 +166,8 @@ export interface SessionSyncMeta { repoIdentity: string | null; createdAt: string; sessionId: string; + /** Persisted verbatim: the file name is a lossy slug, never a title source. */ + title?: string; }; migration: { migratedAt: string | null; @@ -167,12 +198,15 @@ export function defaultSyncMeta( cwd: string; sessionId: string; repoIdentity?: string | null; + /** Persisted verbatim into origin.title (the file name slug is lossy). */ + title?: string; }, createdAt?: string, ): SessionSyncMeta { return { origin: { platform: partial.platform, + title: partial.title, author: partial.author, cwd: partial.cwd, repoIdentity: partial.repoIdentity ?? null, @@ -262,7 +296,7 @@ export class SyncManager { } private authorDir(repoIdentity: string | null, author: string): string { - return path.join(this.repoDir(repoIdentity), author); + return path.join(this.repoDir(repoIdentity), sanitizePathSegment(author)); } private sessionPaths(repoIdentity: string | null, author: string, sessionName: string) { @@ -441,11 +475,13 @@ export class SyncManager { .map((l) => messageFromDict(JSON.parse(l) as Record)); // 从 meta + sessionName 提取标题 - const titleSlug = this.extractTitleFromSessionName(sessionName); + // Prefer the persisted title; the file name slug is truncated + lowercased + // and would otherwise rewrite every restored session's title. + const title = meta.origin.title || this.extractTitleFromSessionName(sessionName); const session: Session = { sessionId: meta.origin.sessionId, - title: titleSlug, + title, cwd: meta.origin.cwd, platform: meta.origin.platform, createdAt: meta.origin.createdAt, @@ -677,8 +713,17 @@ export class SyncManager { 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']); + + // Only ever commit the archive paths: `git commit -m` without a pathspec + // would also commit whatever else the user happened to have staged. + const before = this.runGit(['rev-parse', 'HEAD'], false); + const commit = this.runGit(['commit', '-m', message, '--', 'sessions/'], false); + const after = this.runGit(['rev-parse', 'HEAD'], false); + + // A failed commit (hooks, gpg signing, identity config) must not be + // reported as a successful push: no new HEAD means nothing was committed. + if (!commit.trim() || (before && after === before)) return null; + return after; } gitPush(remote = 'origin', branch?: string): void { From 12ec7044de6f373cfa87d22fe808b57c88b491b2 Mon Sep 17 00:00:00 2001 From: lurkacai Date: Tue, 22 Sep 2026 15:14:36 +0800 Subject: [PATCH 12/28] fix(session): treat a failed git commit as a failed push Let the archive commit throw (hooks, gpg signing, missing identity all exit non-zero) instead of swallowing the failure and reading back the previous HEAD as the new commit. --- src/session-flow/sync.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/session-flow/sync.ts b/src/session-flow/sync.ts index be45271d6..985230f7a 100644 --- a/src/session-flow/sync.ts +++ b/src/session-flow/sync.ts @@ -716,14 +716,16 @@ export class SyncManager { // Only ever commit the archive paths: `git commit -m` without a pathspec // would also commit whatever else the user happened to have staged. - const before = this.runGit(['rev-parse', 'HEAD'], false); - const commit = this.runGit(['commit', '-m', message, '--', 'sessions/'], false); - const after = this.runGit(['rev-parse', 'HEAD'], false); - - // A failed commit (hooks, gpg signing, identity config) must not be - // reported as a successful push: no new HEAD means nothing was committed. - if (!commit.trim() || (before && after === before)) return null; - return after; + // + // Let a failed commit throw (hooks, gpg signing, missing identity all exit + // non-zero) and surface as null: silence here used to be reported as a + // successful push with the previous HEAD printed as the new commit. + try { + this.runGit(['commit', '-m', message, '--', 'sessions/'], true); + } catch { + return null; + } + return this.runGit(['rev-parse', 'HEAD']); } gitPush(remote = 'origin', branch?: string): void { From b4b9d0c0cffce9e12cf90724efbb7e98ed43b1fb Mon Sep 17 00:00:00 2001 From: lurkacai Date: Tue, 22 Sep 2026 16:17:55 +0800 Subject: [PATCH 13/28] chore(session): English-only comments in new modules AGENTS.md: no Chinese in production code. Translates the comments of the new modules (ids / sqlite / scrub / workbuddy-store) added in this PR; behavior unchanged. --- src/session-flow/ids.ts | 25 +++++---- src/session-flow/scrub.ts | 42 +++++++++------ src/session-flow/sqlite.ts | 13 +++-- src/session-flow/workbuddy-store.ts | 81 +++++++++++++++-------------- 4 files changed, 90 insertions(+), 71 deletions(-) diff --git a/src/session-flow/ids.ts b/src/session-flow/ids.ts index c1c13061a..c7a968830 100644 --- a/src/session-flow/ids.ts +++ b/src/session-flow/ids.ts @@ -1,27 +1,30 @@ /** - * ids.ts — 目标平台会话 id 的确定性派生。 + * ids.ts -- deterministic derivation of target-platform session ids. * - * 源会话 id 常常不是 UUID(如 CodeBuddy IDE 的 32 位 hex `60062279ff104372bc110594720a8016`)。 - * 目标适配器若在这种情况下 `randomUUID()`,同一会话每次迁移都会生成一个新副本: - * 目标客户端里出现多条重复会话,且无法按源 id 回滚。 + * Source session ids are often not UUIDs (e.g. CodeBuddy IDE's 32-hex + * `60062279ff104372bc110594720a8016`). If a target adapter falls back to + * `randomUUID()` in that case, every re-migration of the same session mints a + * fresh duplicate in the target client. * - * 这里用 sha256(platform + sourceId) 派生出稳定的 UUID v8 形状 id: - * 同一 (平台, 源会话) 永远得到同一个目标 id → 重迁移 = 覆盖,天然幂等。 + * Deriving from sha256(platform + sourceId) yields a stable UUIDv7-shaped id: + * the same (platform, source session) always maps to the same target id, so a + * re-migration is an overwrite -- idempotent by construction. */ import * as crypto from 'node:crypto'; -/** 由源会话 id 确定性派生目标平台 session id(UUID v8 形状)。 */ +/** Derive a deterministic target session id (UUIDv7 shape) from a source id. */ export function deriveTargetSessionId(targetPlatform: string, sourceId: string): string { const hex = crypto .createHash('sha256') .update(`teamai:${targetPlatform}:${sourceId}`) .digest('hex'); const variant = ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16); - // version nibble 用 **7**:目标端(如 Codex 的 isUuidV7)据此判定"已是本平台的 id" - // 并直接沿用。若用 8,已迁移会话做二次迁移(codex→X→codex)会再派生出一个新 id, - // 幂等性跨链路失效。时间位仍是 hash(非真实时间戳),但只影响形状不影响排序字段 - // (recency/updated_at 都取自会话时间戳)。 + // version nibble is **7**: targets (e.g. Codex's isUuidV7) treat the id as + // "already a native id" and reuse it. With 8, migrating an already-migrated + // session (codex->X->codex) would derive a *new* id and break idempotency + // across chains. The time bits are hash, not a real timestamp -- sorting + // fields (recency/updated_at) come from session timestamps, not the id. return [ hex.slice(0, 8), hex.slice(8, 12), diff --git a/src/session-flow/scrub.ts b/src/session-flow/scrub.ts index 35d0019b1..0e33d87d4 100644 --- a/src/session-flow/scrub.ts +++ b/src/session-flow/scrub.ts @@ -1,17 +1,21 @@ /** - * scrub.ts — 迁移前的会话脱敏。 + * scrub.ts -- redact a session before migration. * - * `session migrate --scrub` 会把整份 IR 过一遍 `utils/redact`: - * 文本、思考块、工具调用参数、工具结果全部覆盖,密钥形状与当前环境变量里的 - * 疑似密钥都会被替换成 `` 占位符。 + * `session migrate --scrub` passes the whole IR through `utils/redact`: + * text, thinking, tool-call arguments and tool results are all covered. + * Secret-shaped values -- and values matching secrets found in the current + * environment -- are replaced with `` placeholders. * - * 为什么要放在迁移链路里: - * - 迁移的目标通常是另一个 agent 的本地存储,那里的内容随时可能被 `session push` - * 归档到团队可读的仓库;在迁出时就脱敏,敏感内容不会跟着会话扩散。 - * - 复用 `utils/redact`(`session save` 用的就是它),不新造一套规则,行为一致。 + * Why this lives in the migration path: the target of a migration is another + * agent's local store, whose content may later be archived into a + * team-readable repo via `session push`. Redacting at migration time keeps + * sensitive values from travelling with the session. * - * 注意:redact() 是 best-effort(规则匹配,不保证零漏)。脱敏后仍建议人工过一遍 - * 再归档;这一点与 `session save` 的注释保持一致。 + * `utils/redact` is reused (the same engine `session save` uses) so the rules + * stay consistent instead of forking a second set of patterns. + * + * Note: redact() is best-effort (pattern matching, not a guarantee). Review + * the result before archiving; this matches the caveat on `session save`. */ import type { Session, Message, ContentBlock } from './ir.js'; @@ -19,13 +23,13 @@ import { redactWithEnv } from '../utils/redact.js'; export interface ScrubResult { session: Session; - /** 被替换掉的疑似密钥数量(按替换处计数,用于迁移报告)。 */ + /** Number of replaced secret-looking values (counted per replacement). */ redactedCount: number; } const REDACTED_MARKER = '`)。 */ +/** Count how many replacements happened between before/after strings. */ function countRedactions(before: string, after: string): number { if (before === after) return 0; let count = 0; @@ -54,7 +58,8 @@ function scrubBlock(block: ContentBlock): { block: ContentBlock; count: number } }; } case 'tool_call': { - // 参数是结构化对象:序列化后整段脱敏再解析回来,避免只对字符串字段生效 + // Arguments are structured: serialize, redact as a whole, then parse + // back so string fields are covered too, not just top-level strings. const raw = JSON.stringify(block.arguments ?? {}); const next = redactWithEnv(raw); const count = countRedactions(raw, next); @@ -63,7 +68,8 @@ function scrubBlock(block: ContentBlock): { block: ContentBlock; count: number } try { parsed = JSON.parse(next) as Record; } catch { - // 脱敏后不再是合法 JSON(极罕见:占位符插进了 key 名):退回原参数 + // Redaction broke the JSON (placeholder landed in a key name): keep + // the original arguments rather than writing something unparsable. return { block, count: 0 }; } return { block: { ...block, arguments: parsed }, count }; @@ -76,15 +82,17 @@ function scrubBlock(block: ContentBlock): { block: ContentBlock; count: number } }; } case 'image': - // 图片内容不参与文本脱敏(二进制/URL 形态无密钥文本特征) + // Image payloads do not participate in text redaction (binary/URL + // shapes carry no secret-shaped text). return { block, count: 0 }; } } /** - * 对会话做脱敏(纯函数,不修改入参)。 + * Redact a session (pure function; the input is not mutated). * - * 标题也一起处理:首条提问里常常直接贴着 token,而标题会显示在目标端的列表里。 + * The title is scrubbed too: the first prompt often carries a token, and the + * title is what shows up in the target client's session list. */ export function scrubSession(session: Session): ScrubResult { let redactedCount = 0; diff --git a/src/session-flow/sqlite.ts b/src/session-flow/sqlite.ts index 1a3b7e6d9..a6f680c39 100644 --- a/src/session-flow/sqlite.ts +++ b/src/session-flow/sqlite.ts @@ -1,15 +1,18 @@ /** - * sqlite.ts — 客户端本地库(Cursor/Codex/WorkBuddy 的索引库)访问的公共部分。 + * sqlite.ts -- shared access to the local index DBs of the target clients + * (Cursor / Codex / WorkBuddy). * - * 这些库都由各自客户端进程持有,TeamAI 只在迁移/回滚时做极小的 upsert / delete。 - * 统一用 sqlite3 CLI 而不是 node 驱动:无需额外依赖,且能天然复用 macOS 自带的 - * sqlite3(支持 WAL 与 busy_timeout)。 + * Those DBs are owned by the client processes; TeamAI only performs tiny + * upserts/deletes during migration and rollback. We drive the sqlite3 CLI + * instead of a node driver: no extra dependency, and macOS ships sqlite3 + * (WAL and busy_timeout supported). */ import * as fs from 'node:fs'; import * as path from 'node:path'; -/** 定位 sqlite3 CLI:PATH → 常见安装位置。找不到时调用方应降级为「不写索引」。 */ +/** Locate the sqlite3 CLI: PATH first, then well-known install locations. + * Callers should degrade to "skip the index write" when it is missing. */ export function findSqlite3(): string | null { const candidates = [ ...(process.env.PATH ?? '') diff --git a/src/session-flow/workbuddy-store.ts b/src/session-flow/workbuddy-store.ts index 010c953be..022cbac5b 100644 --- a/src/session-flow/workbuddy-store.ts +++ b/src/session-flow/workbuddy-store.ts @@ -1,17 +1,22 @@ /** - * workbuddy-store.ts — 把迁移出来的会话注册进 WorkBuddy 的本地数据库。 + * workbuddy-store.ts -- register migrated sessions into WorkBuddy's local database. * - * 背景:WorkBuddy 的「任务 / 空间」列表**不是**扫 `~/.workbuddy/projects//*.jsonl` - * 列出来的,而是查 `~/.workbuddy/workbuddy.db` 的 `sessions` 表(Drizzle + WAL): - * - 列表项 = sessions 行(title / updated_at / cwd / is_playground …) - * - 空间分组 = workspaces 表(path + last_opened_at) - * 只写 jsonl 的话会话在 WorkBuddy 里完全不可见(迁移「成功」但看不到), - * 与 Cursor 的 composerHeaders / Codex 的 state_5.threads 是同一类问题。 + * Background: WorkBuddy's task/space list does **not** scan + * `~/.workbuddy/projects//*.jsonl`; it queries the `sessions` table in + * `~/.workbuddy/workbuddy.db` (Drizzle + WAL): + * - list entries = sessions rows (title / updated_at / cwd / is_playground ...) + * - space grouping = the workspaces table (path + last_opened_at) + * Writing only the jsonl leaves the session invisible in WorkBuddy -- the + * migration "succeeds" but shows nothing. Same class of problem as Cursor's + * composerHeaders and Codex's state_5.threads. * - * 这里 best-effort 做三件事: - * 1. 确保 workspaces 里有该 cwd(否则会话不属于任何「空间」) - * 2. upsert 一条 sessions 行(user_id 沿用库内既有值——它是账号标识,不能编造) - * 3. 失败一律返回 {ok:false, reason},由调用方决定是否提示;不影响 jsonl 已落盘 + * Best-effort, in order: + * 1. make sure the cwd exists in workspaces (otherwise the session belongs + * to no space) + * 2. upsert a sessions row (user_id reuses the value already in the DB -- + * it is an account id and must not be invented) + * 3. on failure return {ok:false, reason}; the caller decides what to show. + * The jsonl is already on disk either way. */ import * as fs from 'node:fs'; @@ -21,7 +26,7 @@ import { spawnSync } from 'node:child_process'; import { getWorkBuddyProjectsDir } from './fs.js'; import { findSqlite3 } from './sqlite.js'; -/** WorkBuddy 数据根目录(`~/.workbuddy`,projects/db 都在其下)。 */ + /** WorkBuddy data root (`~/.workbuddy`; projects/ and the DB live under it). */ export function getWorkBuddyHome(): string { return path.dirname(getWorkBuddyProjectsDir()); } @@ -34,7 +39,7 @@ function esc(value: string): string { return value.replace(/'/g, "''"); } -/** 用 sqlite3 CLI 执行一段 SQL(临时文件 mode 0600,执行完删除)。 */ + /** Run a SQL script through the sqlite3 CLI (temp file, mode 0600, deleted after). */ function runSql(dbPath: string, sql: string, timeoutMs = 30_000): { ok: boolean; reason?: string } { const sqlite3 = findSqlite3(); if (!sqlite3) return { ok: false, reason: 'sqlite3 CLI not found' }; @@ -66,21 +71,21 @@ function runSql(dbPath: string, sql: string, timeoutMs = 30_000): { ok: boolean; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** - * 取得本机的 WorkBuddy 账号标识 user_id(sessions.user_id 是 NOT NULL,且客户端按它过滤列表)。 + * Resolve the account id (sessions.user_id is NOT NULL and the client filters the list by it). * - * 多源探测,按可靠度降序: - * 1. workbuddy.db 里既有会话行的 user_id —— 最权威(就是客户端自己写的) - * 2. ~/.workbuddy/connectors// 的目录名 —— 客户端按账号分的目录,实测与 user_id 同值 - * 3. ~/.workbuddy/app/sessions.json 里出现的 uuid —— 兜底 + * Multi-source discovery, most reliable first: + * 1. user_id on existing session rows in workbuddy.db -- most authoritative (the client wrote it) + * 2. directory name of ~/.workbuddy/connectors// -- per-account dir, observed to equal user_id + * 3. a uuid found in ~/.workbuddy/app/sessions.json -- last resort * - * 注意:**不要用 ~/.workbuddy/device-id 兜底**。实测 device-id(76345293-…) ≠ user_id(b2778798-…), - * 它是设备标识不是账号标识,写进去客户端仍按 user_id 过滤 → 会话照样不可见,还留一条脏数据。 - * 三个来源都拿不到(真·全新未登录)时返回 null,由调用方跳过注册并告警。 + * Do **not** fall back to ~/.workbuddy/device-id: it is a device id, not the account id + * (they differ in practice), so the client's user filter would still hide the session and we + * would leave a dirty row behind. Returns null when no source has it; the caller skips registration and warns. */ function readUserId(dbPath: string): string | null { const sqlite3 = findSqlite3(); - // 1) 库内既有会话 + // 1) existing session rows if (sqlite3 && fs.existsSync(dbPath)) { try { const r = spawnSync( @@ -91,44 +96,44 @@ function readUserId(dbPath: string): string | null { const v = (r.stdout ?? '').trim(); if (UUID_RE.test(v)) return v; } catch { - // 落到下一来源 + // fall through to the next source } } const home = getWorkBuddyHome(); - // 2) connectors/ 目录名 + // 2) connectors/ directory names try { const connectorsDir = path.join(home, 'connectors'); for (const name of fs.readdirSync(connectorsDir)) { if (UUID_RE.test(name)) return name; } } catch { - // 落到下一来源 + // fall through to the next source } - // 3) app/sessions.json 里的 uuid + // 3) uuid in app/sessions.json try { const raw = fs.readFileSync(path.join(home, 'app', 'sessions.json'), 'utf-8'); for (const m of raw.matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi)) { return m[0]; } } catch { - // 拿不到就跳过注册 + // nothing available: skip registration } return null; } export interface RegisterWorkBuddySessionArgs { - /** 会话工作目录(绝对路径,决定归属哪个「空间」)。 */ + /** Session working directory (absolute; decides which space it lands in). */ cwd: string; sessionId: string; title: string; - /** epoch 毫秒。 */ + /** epoch milliseconds. */ createdAtMs: number; updatedAtMs: number; - /** 源会话模型名(可空,原生常见 'auto')。 */ + /** Source session model name (optional; natives commonly use 'auto'). */ model?: string; } @@ -138,9 +143,9 @@ export interface RegisterWorkBuddyResult { } /** - * 把会话注册进 WorkBuddy 的 sessions 表(并按需补 workspaces 行)。 + * Register the session in WorkBuddy's sessions table (and the workspaces row as needed). * - * 幂等:同一 sessionId 重复迁移走 ON CONFLICT DO UPDATE,不会产生重复项。 + * Idempotent: re-migrating the same sessionId hits ON CONFLICT DO UPDATE -- no duplicates. */ export function registerWorkBuddySession(args: RegisterWorkBuddySessionArgs): RegisterWorkBuddyResult { const dbPath = getWorkBuddyDbPath(); @@ -148,7 +153,7 @@ export function registerWorkBuddySession(args: RegisterWorkBuddySessionArgs): Re return { ok: false, reason: `workbuddy.db not found: ${dbPath}` }; } - // user_id 是账号标识,编造会导致列表按用户过滤时看不到 → 没有既有行就不写 + // user_id is the account id; inventing one makes the client's user filter hide the row. const userId = readUserId(dbPath); if (!userId) { return { ok: false, reason: 'no existing session row to derive user_id from' }; @@ -159,15 +164,15 @@ export function registerWorkBuddySession(args: RegisterWorkBuddySessionArgs): Re const model = args.model && args.model.trim() ? args.model.trim() : 'auto'; const sql = [ - // WorkBuddy 运行时持有写锁:给有限 busy 超时,避免 CLI 挂起 + // The client holds a write lock at runtime: bound the wait so the CLI cannot hang. 'PRAGMA busy_timeout=5000;', 'BEGIN IMMEDIATE;', - // 1) 空间(workspaces)——没有这行会话不属于任何空间,界面里无处显示 + // 1) the space row -- without it the session belongs to no space and has nowhere to show 'INSERT INTO workspaces (path, last_opened_at) VALUES ' + `('${esc(args.cwd)}', ${updated}) ` + `ON CONFLICT(path) DO UPDATE SET last_opened_at = MAX(last_opened_at, ${updated});`, - // 2) 会话行。is_playground=0 → 归入「空间」列表(=0 与原生在项目里开的会话一致); - // custom_title 留空,让 title 生效。 + // 2) the session row. is_playground=0 puts it in the space list (matches a native + // in-project session); custom_title stays empty so `title` takes effect. 'INSERT INTO sessions ' + '(id, cwd, user_id, title, custom_title, status, created_at, updated_at, deleted_at, ' + 'is_playground, source_mode, model, last_activity_at) VALUES (' + @@ -182,7 +187,7 @@ export function registerWorkBuddySession(args: RegisterWorkBuddySessionArgs): Re return runSql(dbPath, sql); } -/** 从 WorkBuddy 列表里移除该会话(迁移回滚 / 删除会话时调用)。 */ + /** Remove the session from WorkBuddy's list (called on rollback / delete). */ export function unregisterWorkBuddySession(sessionId: string): RegisterWorkBuddyResult { const dbPath = getWorkBuddyDbPath(); if (!fs.existsSync(dbPath)) return { ok: false, reason: 'workbuddy.db not found' }; From 90abe1e2748611e4283538086d340d6444c3310f Mon Sep 17 00:00:00 2001 From: lurkacai Date: Tue, 22 Sep 2026 17:05:52 +0800 Subject: [PATCH 14/28] chore(session): English-only comments in cursor-store --- src/session-flow/cursor-store.ts | 114 +++++++++++++++---------------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/src/session-flow/cursor-store.ts b/src/session-flow/cursor-store.ts index 9994cc395..3e7df7231 100644 --- a/src/session-flow/cursor-store.ts +++ b/src/session-flow/cursor-store.ts @@ -1,18 +1,18 @@ /** - * cursor-store.ts — 把迁移出来的会话注册进 Cursor 的本地数据库。 + * cursor-store.ts -- register migrated sessions into Cursor's local database. * - * 背景:Cursor 的 Agents Window **不是**扫 `~/.cursor/projects//agent-transcripts/` - * 列会话的 —— transcript jsonl 是 Cursor 从自己的库单向 `flushTranscriptForConversation` - * 导出的产物。UI 的列表来自 `state.vscdb` 的 `composerHeaders` 表,正文来自 - * `cursorDiskKV` 的 `composerData:` 与 `bubbleId::`。 - * 因此只写 transcript 文件,会话在 Cursor 里完全不可见(迁移「成功」但看不到)。 + * Background: Cursor's Agents Window does **not** list sessions by scanning + * `~/.cursor/projects//agent-transcripts/` -- that transcript jsonl is a + * one-way `flushTranscriptForConversation` export from Cursor's own store. The + * UI list comes from `composerHeaders` in state.vscdb, the body from + * `composerData:` and `bubbleId::` in cursorDiskKV. * - * 这里做三件事(best-effort,任何一步失败都不影响 transcript 已写入): - * 1. 从 `User/workspaceStorage//workspace.json` 反查 cwd 对应的 workspaceId - * 2. 按原生结构构造 head / composerData / bubbles - * 3. 用 sqlite3 以单事务 INSERT OR REPLACE 落库 + * migration "succeeds" but shows nothing. + + * Three steps here (best-effort; any failure must not lose the transcript): + * 1. resolve the cwd to a workspaceId via User/workspaceStorage//workspace.json * - * 只新增/覆盖自己这个 composerId 的行,不动其他会话;回滚 = 删掉这三类 key。 + * 3. write with sqlite3 in a single transaction, INSERT OR REPLACE */ import * as crypto from 'node:crypto'; @@ -22,10 +22,10 @@ import * as path from 'node:path'; import { spawnSync } from 'node:child_process'; // --------------------------------------------------------------------------- -// 路径与工具 +// Paths and helpers // --------------------------------------------------------------------------- -/** Cursor 用户数据目录(macOS / Linux;其他平台返回 null 表示不支持注册)。 */ + /** Cursor user-data dir (macOS / Linux; other platforms return null = registration unsupported). */ export function getCursorStateRoot(): string | null { const home = os.homedir(); if (process.platform === 'darwin') { @@ -34,16 +34,16 @@ export function getCursorStateRoot(): string | null { if (process.platform === 'linux') { return path.join(home, '.config', 'Cursor'); } - return null; // Windows: %APPDATA%/Cursor —— 暂不支持(sqlite3 CLI 不保证存在) + return null; // Windows: %APPDATA%/Cursor -- not supported yet (no guaranteed sqlite3 CLI) } -/** Cursor 的 state.vscdb 路径。 */ + /** Path to Cursor's state.vscdb. */ export function getCursorStateDbPath(): string | null { const root = getCursorStateRoot(); return root ? path.join(root, 'User', 'globalStorage', 'state.vscdb') : null; } -/** 找 sqlite3 CLI:PATH → 常见安装位置。 */ + /** Locate the sqlite3 CLI: PATH first, then well-known install locations. */ function findSqlite3(): string | null { const candidates = [ ...(process.env.PATH ?? '').split(path.delimiter).filter(Boolean).map((d) => path.join(d, 'sqlite3')), @@ -62,7 +62,7 @@ function findSqlite3(): string | null { return null; } -/** cwd → Cursor workspaceId(由 workspaceStorage//workspace.json 的 folder 反查)。 */ + /** cwd -> Cursor workspaceId (resolved from workspaceStorage//workspace.json's folder). */ export function resolveCursorWorkspaceId(cwd: string): { id: string; uri: CursorUri } | null { const root = getCursorStateRoot(); if (!root) return null; @@ -119,10 +119,10 @@ function makeUri(external: string, fsPath: string): CursorUri { } // --------------------------------------------------------------------------- -// 模板(字段集取自 Cursor 0.155 附近版本的原生记录) +// Templates (field set taken from native records around Cursor 0.155) // --------------------------------------------------------------------------- -/** Lexical 富文本:Cursor 的 composer/bubble 用它渲染编辑器内容。 */ + /** Lexical rich text: Cursor's composer/bubble render editor content with it. */ function lexical(text: string): string { const paragraph = text ? [ @@ -145,7 +145,7 @@ interface BubbleTemplate { [k: string]: unknown; } -/** 工具输出:原生 result 是 JSON 字符串(对象),裸文本要包成对象,否则 UI 解析不出来。 */ + /** Tool output: the native result is a JSON string (object); bare text must be wrapped, or the UI cannot parse it. */ function encodeToolResult(raw?: string): string { if (!raw) return ''; const s = raw.trim(); @@ -153,7 +153,7 @@ function encodeToolResult(raw?: string): string { return JSON.stringify({ output: raw }); } -/** 原生 composerData.context / bubble.context 的空形态。 */ + /** Empty shape of the native composerData.context / bubble.context. */ function emptyContext(): Record { return { composers: [], @@ -174,7 +174,7 @@ function emptyContext(): Record { }; } -/** bubble 默认值(原生 bubble 的字段全量铺开,避免 UI 解析时缺字段)。 */ + /** Bubble defaults (native bubble fields laid out in full so the UI never hits a missing field). */ function emptyBubble(): BubbleTemplate { return { _v: 3, @@ -247,34 +247,34 @@ function emptyBubble(): BubbleTemplate { } // --------------------------------------------------------------------------- -// 构造 head / composerData / bubbles +// Build head / composerData / bubbles // --------------------------------------------------------------------------- export interface CursorComposerTool { name: string; args: Record; /** - * 工具输出。原生把它放在 assistant 的 tool 气泡 `toolFormerData.result` 里, - * **不会**单独成为一条消息 —— 所以工具结果必须挂在这里,否则 UI 里会冒出一堆 - * `[tool_result] {json}` 的用户气泡。 + * Tool output. The native record keeps it on the assistant's tool bubble as + * `toolFormerData.result` and does **not** emit a separate message -- so the + * result must hang here, otherwise the UI shows a pile of */ result?: string; - /** 失败的工具调用(原生 status: failed)。 */ + /** Failed tool call (native status: failed). */ isError?: boolean; } export interface CursorComposerMessage { role: 'user' | 'assistant'; /** - * 纯文本正文:只放真实叙述文本。 - * 不要把 thinking 包成 `` 塞进来 —— 以 HTML 标签开头的正文会被 Cursor 当 - * HTML 块处理,markdown(粗体/列表/代码块)与换行全部失效,整段显示成一行。 + * Plain narrative text only. + * Do not wrap thinking as in here -- content starting with an HTML + * tag is treated as an HTML block by Cursor: markdown (bold/lists/fences) and */ text: string; - /** 该消息里的工具调用(含结果)。 */ + /** Tool calls in this message (with results). */ tools: CursorComposerTool[]; createdAt: string; // ISO8601 - /** assistant 消息的模型名(可选)。 */ + /** Model name of the assistant message (optional). */ modelName?: string; } @@ -311,15 +311,15 @@ function buildBubbleRecords( bubble.richText = lexical(msg.text); bubble.requestId = uuid(); bubble.checkpointId = uuid(); - // 原生 user bubble 还带这三项,缺失会让 UI 少渲染上下文/模型标签 + // Native user bubbles carry these three; without them the UI drops the context/model chips bubble.context = emptyContext(); bubble.modelInfo = { modelName: msg.modelName ?? 'default' }; bubble.isPlanExecution = false; } else { bubble.modelInfo = { modelName: msg.modelName ?? 'default' }; bubble.turnDurationMs = 0; - // 原生 assistant 气泡带 codeBlocks(哪怕为空);缺失时正文可能按纯文本渲染, - // markdown 不生效 + // Native assistant bubbles carry codeBlocks even when empty; without it the body may render as plain text + // and markdown stops working bubble.codeBlocks = []; } records.push({ key: `bubbleId:${composerId}:${bid}`, value: JSON.stringify(bubble) }); @@ -330,7 +330,7 @@ function buildBubbleRecords( ? { isRenderable: true, hasText: true, - // 原生按文本长度决定,写死 true 会让长提问被当短文本渲染 + // Native decides by text length; hardcoding true makes long prompts render as short text isShortPlainText: msg.text.length <= 120, textPreview: msg.text.slice(0, 80), toolDisplayComputed: true, @@ -341,9 +341,9 @@ function buildBubbleRecords( }); } - // 工具调用:原生是「无正文的 type 2 气泡 + toolFormerData」。 - // tool / toolCallBinary 是 Cursor 内部 protobuf,无法还原,省略(仅影响工具图标的 - // 精细展示,不影响会话可见性与正文)。 + // Tool calls: natively a body-less type-2 bubble + toolFormerData. + // tool / toolCallBinary are Cursor-internal protobuf and cannot be rebuilt; omitted (only + // affects the fine-grained tool icon, not session visibility or the body). for (const [i, tool] of msg.tools.entries()) { const bid = uuid(); const callId = `tool_${uuid()}`; @@ -362,9 +362,9 @@ function buildBubbleRecords( name: tool.name, rawArgs: argsJson, params: argsJson, - // 工具输出挂在这里(原生位置),不是一个独立的用户气泡。 - // 原生 result 是「JSON 字符串(对象)」,UI 会 JSON.parse 后取字段, - // 所以裸文本要包成对象,否则工具输出显示不出来。 + // Tool output hangs here (the native location), not as a separate user bubble. + // The native result is a "JSON string (object)"; the UI JSON.parses it and reads fields, + // so bare text must be wrapped or the output will not show. result: encodeToolResult(tool.result), }; records.push({ key: `bubbleId:${composerId}:${bid}`, value: JSON.stringify(bubble) }); @@ -406,7 +406,7 @@ function buildComposerData( lastUpdatedAt: lastMs, createdAt: createdMs, hasChangedContext: false, - // 原生是固定三项能力描述;留空会让部分工具/能力面板 UI 缺内容 + // Natively a fixed three-item capability list; leaving it empty breaks some tool/capability panels capabilities: [ { type: 15, data: { bubbleDataMap: '{}' } }, { type: 19, data: {} }, @@ -509,7 +509,7 @@ function buildHead( } function randomBase64Key(): string { - // 32 字节随机 key(原生是 base64)。 + // 32-byte random key (native stores base64). return crypto.randomBytes(32).toString('base64'); } @@ -518,7 +518,7 @@ function cryptoRandomUuid(): string { } // --------------------------------------------------------------------------- -// 落库 +// Persistence // --------------------------------------------------------------------------- function esc(value: string): string { @@ -527,16 +527,16 @@ function esc(value: string): string { export interface RegisterResult { ok: boolean; - /** 失败原因(ok=false 时给 CLI 记录 debug 用)。 */ + /** Failure reason (ok=false; for the CLI debug log). */ reason?: string; bubbleCount?: number; } /** - * 将会话注册进 Cursor 的 Agents 列表。 + * Register the session into Cursor's Agents list. * - * 失败一律返回 `{ok:false, reason}`,调用方不应把它当迁移失败 —— transcript 已落盘, - * 注册失败只是「列表里看不到」,不会损坏任何数据。 + * Failures always return {ok:false, reason}; the caller must not treat it as a + * migration failure -- the transcript is on disk, a failed registration only */ export function registerCursorComposer(args: RegisterCursorComposerArgs): RegisterResult { const dbPath = getCursorStateDbPath(); @@ -557,9 +557,9 @@ export function registerCursorComposer(args: RegisterCursorComposerArgs): Regist .filter((n) => Number.isFinite(n)); const createdMs = times.length ? Math.min(...times) : Date.now(); const lastMs = times.length ? Math.max(...times) : createdMs; - // 列表排序字段(lastUpdatedAt/recency)用迁移时刻:保留源时间会把迁移会话 - // 埋进「N 天前」分组,用户迁完在顶部找不到。会话内容时间轴(composerData - // 内的 lastMs)保持源时间不变。 + // List sort fields (lastUpdatedAt/recency) use the migration time: keeping the + // source time would bury the migrated session in an "N days ago" group and the + // user would not find it at the top. The in-session timeline (lastMs inside const recencyMs = Math.max(lastMs, Date.now()); const subtitle = args.messages.find((m) => m.role === 'user' && m.text.trim())?.text.slice(0, 30) ?? ''; @@ -567,10 +567,10 @@ export function registerCursorComposer(args: RegisterCursorComposerArgs): Regist const head = buildHead(args.composerId, args.title, subtitle, createdMs, lastMs, ws); const stmts: string[] = [ - // Cursor 运行时会持有写锁:给一个有限的 busy 超时,避免 CLI 永久挂起 + // Cursor holds a write lock at runtime: bound the wait so the CLI cannot hang 'PRAGMA busy_timeout=5000;', 'BEGIN IMMEDIATE;', - // OR REPLACE 依赖唯一索引,先显式删一次,避免重迁移出现重复行 + // OR REPLACE relies on a unique index; delete first so a re-migration `DELETE FROM composerHeaders WHERE composerId='${esc(args.composerId)}';`, ]; stmts.push( @@ -578,7 +578,7 @@ export function registerCursorComposer(args: RegisterCursorComposerArgs): Regist '(composerId, workspaceId, createdAt, lastUpdatedAt, isArchived, isSubagent, recency, checkpointAt, value, subagentTypeName) ' + `VALUES ('${esc(args.composerId)}','${esc(ws.id)}',${createdMs},${recencyMs},0,0,${recencyMs},NULL,'${esc(JSON.stringify(head))}',NULL);`, ); - // 重迁移同一会话时先清掉旧的 bubble,避免残留 + // Re-migrating the same session: clear the old bubbles to avoid leftovers stmts.push(`DELETE FROM cursorDiskKV WHERE key LIKE 'bubbleId:${esc(args.composerId)}:%';`); stmts.push( 'INSERT OR REPLACE INTO cursorDiskKV (key, value) VALUES ' + @@ -617,8 +617,8 @@ export function registerCursorComposer(args: RegisterCursorComposerArgs): Regist } /** - * 从 Cursor 的 Agents 列表里移除该会话(迁移回滚 / 删除会话时调用)。 - * 只删自己这个 composerId 的行,best-effort。 + * Remove the session from Cursor's Agents list (called on rollback / delete). + * Only rows for our own composerId are touched; best-effort. */ export function unregisterCursorComposer(composerId: string): RegisterResult { const dbPath = getCursorStateDbPath(); From 48d9408da73e8db6ca4bccec3ee4cd2390cac5e5 Mon Sep 17 00:00:00 2001 From: lurkacai Date: Tue, 22 Sep 2026 19:21:19 +0800 Subject: [PATCH 15/28] chore(session): English-only comments in codex adapter and new modules --- src/session-flow/adapters/codex.ts | 169 +++++++++++++++-------------- 1 file changed, 90 insertions(+), 79 deletions(-) diff --git a/src/session-flow/adapters/codex.ts b/src/session-flow/adapters/codex.ts index 61a4adda7..25474127f 100644 --- a/src/session-flow/adapters/codex.ts +++ b/src/session-flow/adapters/codex.ts @@ -15,18 +15,23 @@ * - reasoning 块写入为 response_item:reasoning(而非跳过) * - 支持 custom_tool_call / custom_tool_call_output * - * 写入的 rollout 必须能被 Codex 直接索引(否则会话不会出现在 Codex Desktop 历史列表): - * - session_meta.payload.model_provider:Codex 按 provider 分桶展示会话,只有与 - * ~/.codex/config.toml 当前 model_provider 一致的会话才会出现在列表里(见 - * https://github.com/farion1231/cc-switch/issues/4710)。缺失/写错 → 会话静默消失。 - * - 不声明 history_mode:Codex 0.155+ 把无声明的 rollout 当 legacy,由 - * `codex migrate-rollouts --apply` 转成分页历史并建立 items 投影;自己声明 'paginated' - * 会被当成 already-paginated 跳过迁移 → 无投影 → 列表无预览、打开空白。 - * - 每行顶层 ordinal:分页游标依赖它,缺失时 thread/items/list 返回空。 - * - 至少一条 event_msg:item_completed 的 UserMessage:标题与列表预览取自第一条用户 - * item;元信息块(/包裹的时间戳头等)会被整条丢弃, - * 导致没有标题/预览 → 不显示。 - * - 写完后主动调用 codex CLI 建投影(paginateRollout),保证迁移完立刻可见。 + * The written rollout must be directly indexable by Codex, or the session never + * appears in the Codex Desktop history list: + * - session_meta.payload.model_provider: Codex buckets the list by provider; + * only sessions matching ~/.codex/config.toml's model_provider are shown (see + * https://github.com/farion1231/cc-switch/issues/4710). Missing/wrong -> silently hidden. + * - No history_mode: Codex 0.155+ treats it as legacy and `codex + * migrate-rollouts --apply` converts it to paginated history plus the items + * projection; claiming 'paginated' marks it already-migrated -> no + * projection -> no preview, blank body. + * - A top-level ordinal per line: the pagination cursor depends on it; without + * it thread/items/list returns empty. + * - At least one event_msg:item_completed UserMessage: title and list preview + * come from the first user item; injected metadata blocks + * (/ timestamp headers etc.) are dropped wholesale, + * which would leave no title/preview -> invisible. + * - After writing, run the codex CLI to build the projection (paginateRollout) + * so the session is visible immediately. */ import * as crypto from 'node:crypto'; @@ -82,15 +87,15 @@ function denormalizeToolName(irName: string): string { function generateUuidV7(): string { const timestampMs = Date.now(); - // 前 48 位时间戳左移 80 位。 - // 注意:必须用 BigInt 按位与(0xffffffffffffn)。 - // Number 的 `&` 运算符是 32 位有符号按位与,时间戳超过 2^31 会变成负数, - // 导致后续 BigInt 为负、toString(16) 输出带负号的非法 UUID, - // 使 Codex 端 Uuid 反序列化失败、整个会话被忽略(迁移后 Codex 里看不到)。 + // 48-bit timestamp shifted left by 80 bits. + // Must use BigInt bitwise AND (0xffffffffffffn): Number's `&` is a 32-bit + // signed op, timestamps above 2^31 go negative, the subsequent BigInt turns + // negative and toString(16) emits a negative hex -- an invalid UUID that + // fails Codex's Uuid deserialization and hides the whole session. let uuidInt = (BigInt(timestampMs) & 0xffffffffffffn) << 80n; - // 版本位 7(位 76-79) + // version bits 7 (bits 76-79) uuidInt |= 7n << 76n; - // 随机位(低 62 位) + // random bits (low 62) const randBytes = crypto.randomBytes(8); let rand = 0n; for (let i = 0; i < 8; i++) { @@ -98,10 +103,10 @@ function generateUuidV7(): string { } rand &= (1n << 62n) - 1n; uuidInt |= rand; - // 设置变体位(位 62-63 为 10) + // variant bits (62-63 = 10) uuidInt = (uuidInt & ~(0x3n << 62n)) | (0x2n << 62n); - // 转为 UUID 字符串 + // format as a UUID string 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)}`; } @@ -139,10 +144,11 @@ function formatFilenameTimestamp(isoStr: string): string { // --------------------------------------------------------------------------- /** - * 读取 Codex 当前生效的 model_provider(config.toml 顶层 `model_provider = "..."`)。 + * Read the effective model_provider (top-level `model_provider = "..."` in config.toml). * - * Codex Desktop 的会话列表按 provider 分桶:只有与当前配置一致的会话才会展示, - * 切换 provider 后旧会话“消失”就是这个机制。迁移写入的 rollout 必须带上当前值。 + * Codex Desktop buckets the session list by provider: only sessions matching + * the current config are shown -- that is why sessions "disappear" after + * switching providers. Migrated rollouts must carry the current value. */ function readCodexModelProvider(configPath: string): string { try { @@ -156,13 +162,17 @@ function readCodexModelProvider(configPath: string): string { } /** - * 源平台注入的“纯元信息”块。它们不是真实用户输入,而 Codex 用第一条 UserMessage item - * 生成标题与列表预览,首条消息若是元信息会被整条丢弃 → 会话没有 title/preview → - * 不出现在历史列表。 + * "Pure metadata" blocks injected by source platforms. They are not real user + * input, and Codex builds title/list preview from the first UserMessage item -- + * if that first message is metadata it is dropped wholesale -> no + * title/preview -> the session never shows up. * - * 注意:1) 只列纯元信息标签, 之类包裹真实提问的标签由 extractUserText - * 单独处理;2) 不锚定行首——前一块剥离后剩余文本常以 \n\n 开头,行首锚定会导致 - * 后续块匹配失败;3) system_reminder 同时覆盖下划线(CodeBuddy)与连字符(Claude Code)。 + * Notes: 1) only pure-metadata tags are listed here; -style + * wrappers around real questions are handled separately by extractUserText; + * 2) not line-anchored -- after stripping one block the rest often starts with + * \n\n, and line anchors would miss the following blocks; 3) + * system_reminder covers both the underscore (CodeBuddy) and hyphen (Claude + * Code) spellings. */ const META_BLOCK_RE = /<(user_info|rules|environment_context|system-reminder|system_reminder|system_instructions|available_skills|agent_request|local-command-caveat|uploaded_documents|additional_data|timestamp)[^>]*>[\s\S]*?<\/\1>[ \t]*\r?\n?/gi; @@ -178,9 +188,10 @@ function stripMetaBlocks(text: string): string { } /** - * 从一条用户消息里提取真实用户输入。 - * CodeBuddy / Cursor 会把真实提问包在 ... 里(外层还挂着大段 - * / 元信息),直接取包裹内容最干净;没有该包裹的平台走元信息剥离。 + * Extract the real user input from a user message. + * CodeBuddy / Cursor wrap the actual question in ... + * (with large / metadata outside), so unwrapping is the + * cleanest path; platforms without the wrapper fall back to metadata stripping. */ function extractUserText(text: string): string { const qm = text.match(/]*>([\s\S]*?)<\/user_query>/i); @@ -381,10 +392,10 @@ export class CodexAdapter extends AgentAdapter { // ignore } - // 单次有限扫描:统计消息数 + 提取内容标题。 - // 标题取首条真实用户文本(item_completed 的 UserMessage 或 response_item 的 - // user message,后者覆盖无 item_completed 的老 legacy rollout)——此前只从 - // 文件名生成 `Session <时间戳>`,列表里一整排时间戳没法辨认。 + // One bounded scan: count messages and extract the content title. + // Title = first real user text (item_completed UserMessage, or response_item + // user message for legacy rollouts without it). Previously only the + // filename fallback produces an unreadable wall of "Session ". let messageCount = 0; let contentTitle = ''; try { @@ -609,9 +620,10 @@ export class CodexAdapter extends AgentAdapter { } async writeSession(session: Session, projectPath?: string): Promise { - // session_id: 已是 UUIDv7 则沿用;否则**确定性派生**而非随机生成—— - // 随机会让同一源会话每次迁移都产出新的目标 id,Codex 里出现内容完全重复的 - // 第二个线程(threads 行数翻倍)。派生后重迁移=覆盖,天然幂等。 + // session_id: reuse if already UUIDv7; otherwise derive deterministically. + // Random ids would give every re-migration a fresh target id -- a fully + // duplicated second thread in Codex (threads doubled). Derived ids make a + // re-migration an overwrite: idempotent by construction. const sessionId = isUuidV7(session.sessionId) ? session.sessionId : deriveTargetSessionId(this.platform, session.sessionId); @@ -637,14 +649,12 @@ export class CodexAdapter extends AgentAdapter { const records: Record[] = []; // 1. session_meta - // 注意:Codex 端 SessionMeta.payload.timestamp 必须是 RFC3339 字符串(非 epoch 毫秒数字), - // source 必须是 SessionSource 合法枚举值('cli'/'vscode'/...)。 - // 非交互来源(自定义字符串)会被 INTERACTIVE_SESSION_SOURCES 过滤, - // 导致会话不在 Codex 列表中显示。 - // model_provider 必须跟随 ~/.codex/config.toml(按 provider 分桶展示,见文件头注释)。 - // 不声明 history_mode:0.155+ 会把 rollout 当 legacy 并由 `codex migrate-rollouts --apply` - // 转成分页历史 + 建立 items 投影(标题/预览/内容都来自这次投影)。自己声明 'paginated' - // 反而会跳过迁移——线程没有投影,列表无预览、打开空白。 + // Note: Codex's SessionMeta.payload.timestamp must be an RFC3339 string (not + // and would be hidden from the Codex list. + // model_provider must follow ~/.codex/config.toml (the list buckets by provider; see header). + // No history_mode: 0.155+ treats the rollout as legacy and `codex + // migrate-rollouts --apply` converts it to paginated history plus the items + // projection. Claiming 'paginated' ourselves skips that: no projection, no preview, blank body. const modelProvider = readCodexModelProvider( path.join(path.dirname(this.storageRoot), 'config.toml'), ); @@ -675,8 +685,8 @@ export class CodexAdapter extends AgentAdapter { let itemCount = 0; let lastAgentMessage: string | undefined; let userItemEmitted = false; - // 第一条 UserMessage item 决定会话标题与列表预览。若整个会话里没有一句真实用户输入 - // (全部是源平台注入的元信息),兜底写一条迁移说明,否则会话没有 preview 而不可见。 + // The first UserMessage item decides title and list preview. If the session has no real + // (all injected metadata), write a fallback note or the session has no preview and stays invisible. const fallbackUserText = session.messages.some( (m) => m.role === 'user' && @@ -684,7 +694,7 @@ export class CodexAdapter extends AgentAdapter { ) ? null : `Migrated session from ${session.platform || 'external agent'}`; - // 兜底 item 的插入位置(第一个 turn 的 turn_context 之后) + // Insert position for the fallback item (after the first turn's turn_context) let firstTurnInsertAt = -1; let firstTurnId = ''; @@ -756,8 +766,8 @@ export class CodexAdapter extends AgentAdapter { if (block.type !== 'text') continue; if (msg.role === 'user') { - // 源平台注入的元信息(///…)与附件路径 - // (@image:/path)都不是真实用户输入,剥掉后剩下的才是标题/预览要用的文本。 + // Injected metadata (///...) and attachment paths + // (@image:/path) are not real input; what remains becomes the title/preview text. // 整块都是元信息则跳过。 const userText = visibleUserText(block.text); if (!isRenderableText(userText)) continue; @@ -784,7 +794,7 @@ export class CodexAdapter extends AgentAdapter { } } - // 没有任何真实用户输入时补一条兜底 UserMessage,保证会话有标题/预览。 + // With no real user input at all, write a fallback UserMessage so the session has a title/preview. if (!userItemEmitted && fallbackUserText && firstTurnInsertAt >= 0) { const fallbackRecord = buildItemCompletedRecord({ timestamp: tsIso, @@ -811,9 +821,9 @@ export class CodexAdapter extends AgentAdapter { }); } - // 每行补 ordinal:新版 Codex 用它做 rollout 行序号与 items 游标分页, - // 缺失时 thread/items/list 返回空,会话打开后一片空白。 - // 字段顺序与原生 rollout 保持一致(timestamp, ordinal, type, payload)。 + // Per-line ordinal: new Codex uses it for rollout ordering and items pagination; + // without it thread/items/list returns empty and the session opens blank. + // Field order matches native rollouts (timestamp, ordinal, type, payload). const ordered = records.map((rec, idx) => ({ timestamp: rec.timestamp, ordinal: idx, @@ -823,7 +833,7 @@ export class CodexAdapter extends AgentAdapter { writeJsonl(filePath, ordered); - // 3. 让 Codex CLI 把 legacy rollout 转成分页历史并建立 items 投影(标题/预览/内容)。 + // 3. Let the Codex CLI convert the legacy rollout to paginated history + items projection. await this.paginateRollout(sessionId, path.dirname(this.storageRoot)); return sessionId; } @@ -833,12 +843,12 @@ export class CodexAdapter extends AgentAdapter { * 分页历史并建立 items 投影。 * * 不跑这一步,会话在 Codex Desktop 里:列表无标题/预览(不可见),打开后内容空白 - * (items 投影只有在 legacy→paginated 迁移时才会建立)。 + * (the items projection is only built during the legacy->paginated migration). * - * 新写入的 rollout 还没进 state_5.sqlite 时,定向迁移会报 missing_sqlite_metadata; - * 此时起一个临时 app-server 调一次 thread/list(官方索引入口,会把新 rollout 登记 - * 进 threads 表并算出标题/预览),再重试定向迁移。所有步骤均为 best-effort:找不到 - * codex CLI 或仍失败时保持 legacy 原样,由 Codex 自身启动迁移兜底,不算迁移失败。 + * A freshly written rollout is not in state_5.sqlite yet, so the targeted migration + * reports missing_sqlite_metadata; start a temporary app-server, call thread/list once + * (the official indexing path: it registers the rollout and computes title/preview), + * then retry. Everything is best-effort: keep the legacy rollout as-is when the CLI */ private async paginateRollout(sessionId: string, codexHome: string): Promise { const bin = findCodexCli(); @@ -856,8 +866,9 @@ export class CodexAdapter extends AgentAdapter { ); stdout = r.stdout ?? ''; } catch (e) { - // 退出码非 0(如预存损坏 rollout 导致 "one or more rollout migrations failed") - // 时 stdout 仍带完整 JSON 报告,取出来判断本线程的结果。 + // Non-zero exit (e.g. a corrupted rollout causing "one or more rollout + // migrations failed") still carries the full JSON report; parse it to + // judge this thread's outcome. stdout = (e as { stdout?: string }).stdout ?? ''; } try { @@ -873,15 +884,15 @@ export class CodexAdapter extends AgentAdapter { let status = await runApply(); if (status === 'migrated' || status === 'already_paginated') return; - // 未索引(missing_sqlite_metadata 等)→ 让 app-server 的 thread/list 登记新文件,重试 + // Not indexed (missing_sqlite_metadata etc.) -> register via app-server thread/list, retry await this.indexThreadViaAppServer(bin, codexHome); await runApply(); } /** - * 起一个临时 `codex app-server`,initialize + thread/list(官方索引入口:会扫描 - * sessions 目录、把新 rollout upsert 进 state_5.threads 并计算标题/预览),拿到 - * thread/list 响应后立即退出。任何异常都静默结束(best-effort)。 + * Start a temporary `codex app-server`, initialize + thread/list (the official + * indexing path: it scans the sessions dir, upserts the new rollout into + * state_5.threads and computes title/preview), then exits after the response. */ private indexThreadViaAppServer(bin: string, codexHome: string): Promise { return new Promise((resolve) => { @@ -963,8 +974,8 @@ export class CodexAdapter extends AgentAdapter { type: 'response_item', payload: { type: 'message', - // 当前版本 Codex 的 ResponseItem::Message 要求必填 id(msg_ 格式), - // 缺失时整行反序列化失败,resume 重放产出 0 个 item,UI 显示空白 + // Current Codex ResponseItem::Message requires an id (msg_ form); + // missing it fails the line and resume replays 0 items (blank UI). id: `msg_${generateUuidV7()}`, role, content: [{ type: contentType, text: block.text }], @@ -972,7 +983,7 @@ export class CodexAdapter extends AgentAdapter { }; } case 'image': { - // rollout 的消息只支持 text,图片降级为占位文本(保真度计 degraded) + // rollout messages only support text: degrade the image to a placeholder (counted degraded) const contentType = role === 'user' ? 'input_text' : 'output_text'; return { timestamp: ts, @@ -1028,8 +1039,8 @@ export class CodexAdapter extends AgentAdapter { } async deleteSession(sessionId: string, projectPath?: string): Promise { - // 先摘索引,再删正文。只删 rollout 会让 Codex 列表里留下一条 title/preview 都在 - // 但点开空白的孤儿会话(threads 行与 items 投影仍在),回滚等于没回滚。 + // Unregister first, then delete the body. Removing only the rollout leaves an + // orphan listed with title/preview but opening blank -- rollback achieved nothing. await this.unregisterThread(sessionId); const f = this.findSessionFile(sessionId); @@ -1043,8 +1054,8 @@ export class CodexAdapter extends AgentAdapter { } /** - * 删除 Codex 两库里的会话痕迹:state_5.threads(列表项)+ thread_history_1 的 - * items/turns/投影水位。best-effort:CLI 缺失或加锁失败都不影响 rollout 删除。 + * Remove the session's traces from both Codex stores: state_5.threads (list rows) and + * items/turns/projection watermark. Best-effort: a missing CLI or lock contention never blocks the rollout delete. */ private async unregisterThread(sessionId: string): Promise { // Defense in depth against SQL injection: the id comes straight from the CLI @@ -1074,8 +1085,8 @@ export class CodexAdapter extends AgentAdapter { ], ], ]; - // 逐条执行、不用事务:不同 Codex 版本的表结构不一致(如无 projection_state 表), - // 放进同一事务会因一条报错整体回滚,连 threads 都删不掉。 + // Run statement by statement, no transaction: table shapes differ across Codex + // versions (e.g. no projection_state table) and one error in a transaction for (const [db, sqls] of stmts) { if (!fileExists(db)) continue; for (const sql of sqls) { @@ -1085,7 +1096,7 @@ export class CodexAdapter extends AgentAdapter { maxBuffer: 16 * 1024 * 1024, }); } catch { - // 表不存在 / 加锁失败:跳过,不影响其它清理 + // Missing table / lock contention: skip, other cleanup continues } } } From a1c6ecb81bc935987226a97d09965eae32a71068 Mon Sep 17 00:00:00 2001 From: lurkacai Date: Tue, 22 Sep 2026 19:22:54 +0800 Subject: [PATCH 16/28] chore(session): English comments in cursor adapter and scrub/doc touch-ups --- src/session-flow/adapters/cursor.ts | 54 ++++++++++++++--------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/session-flow/adapters/cursor.ts b/src/session-flow/adapters/cursor.ts index 406b80151..0a9d2af28 100644 --- a/src/session-flow/adapters/cursor.ts +++ b/src/session-flow/adapters/cursor.ts @@ -237,7 +237,7 @@ export class CursorAdapter extends AgentAdapter { } try { - // 50 行常全是注入块,预算不够会让有真实提问的会话也 fallback 成 "Session " + // 50 lines are often all injected blocks; a too-small budget makes sessions with real questions fall back to "Session " for (const record of readJsonlHead(jsonlPath, 200)) { // 消息行没有 type 字段 if (record.type === 'turn_ended') continue; @@ -405,7 +405,7 @@ export class CursorAdapter extends AgentAdapter { } async writeSession(session: Session, projectPath?: string): Promise { - // 确定性 id:同一源会话反复迁移命中同一个 composerId(不再每次生成副本) + // Deterministic id: re-migrations of the same source session hit the same composerId (no more per-run copies) const sessionId = isUuid(session.sessionId) ? session.sessionId : deriveCursorId(session.platform || 'unknown', session.sessionId); @@ -416,8 +416,8 @@ export class CursorAdapter extends AgentAdapter { const jsonlPath = path.join(transcriptDir, `${sessionId}.jsonl`); const records: Record[] = []; - // 原生 transcript 的 turn 语义:1 条 user + N 条连续 assistant + 1 条 turn_ended。 - // 之前按「每条 assistant 后都写 turn_ended」,把一次工具轮次切成了几十个 turn。 + // Native transcript turn semantics: 1 user + N consecutive assistant + 1 turn_ended. + // Previously we wrote turn_ended after every assistant, splitting one tool round into dozens of turns. let lastAssistantRecord: Record | null = null; let lastUserRecord: Record | null = null; let turnHasAssistant = false; @@ -433,8 +433,8 @@ export class CursorAdapter extends AgentAdapter { for (const msg of session.messages) { if (msg.role === 'user') { - // 源平台把工具结果放在 user 消息里:它属于上一个 assistant 的工具调用, - // 所以挂回上一条 assistant 记录(作为文本块),而不是变成一条「假 user 消息」。 + // The source platform puts tool results in user messages: they belong to the previous assistant's tool calls, + // so hang it back on the previous assistant record (as a text block) instead of a fake user message. const toolResults: string[] = []; const cursorContent: Record[] = []; for (const block of msg.content) { @@ -451,7 +451,7 @@ export class CursorAdapter extends AgentAdapter { for (const t of toolResults) content.content.push({ type: 'text', text: t }); } - // 真实用户提问:同一 turn 内连续的用户消息合并进同一条(原生不会出现连续 user) + // Real user prompts: consecutive user messages in one turn merge into one (natives never emit consecutive user messages) if (cursorContent.length > 0) { if (lastUserRecord && !turnHasAssistant) { const prev = lastUserRecord.message as { content: Record[] }; @@ -460,7 +460,7 @@ export class CursorAdapter extends AgentAdapter { endTurn(); const rec: Record = { role: 'user', - // 写入 cwd 使 readSession 能恢复真实路径(归档键派生依赖它) + // write cwd so readSession can recover the real path (archive key depends on it) cwd, message: { content: cursorContent }, }; @@ -486,7 +486,7 @@ export class CursorAdapter extends AgentAdapter { case 'tool_call': cursorContent.push({ type: 'tool_use', - // id 让 readSession 能把 tool_use 与 tool_result 配对(读端就认它) + // id lets readSession pair tool_use with tool_result (the reader expects it) id: block.callId || `tool_${cursorContent.length}`, name: denormalizeToolName(block.toolName), input: block.arguments, @@ -499,7 +499,7 @@ export class CursorAdapter extends AgentAdapter { }); break; case 'image': - // Cursor 存储不含图片,降级为占位文本(保真度计 degraded) + // Cursor storage has no images: degrade to a placeholder (counted degraded) cursorContent.push({ type: 'text', text: imagePlaceholderText(block) }); break; } @@ -517,14 +517,14 @@ export class CursorAdapter extends AgentAdapter { } } - // 收尾最后一个 turn + // close the final turn endTurn(); writeJsonl(jsonlPath, records); - // transcript 只是 Cursor 的**导出**产物:Agents Window 的列表来自 state.vscdb 的 - // composerHeaders、正文来自 cursorDiskKV 的 composerData/bubbleId。不注册这一步, - // 会话在 Cursor 里「迁移成功但完全看不见」。注册是 best-effort:失败只影响可见性。 + // The transcript is only Cursor's **export**: the Agents Window list comes from state.vscdb's + // composerHeaders, and the body from composerData/bubbleId in cursorDiskKV. Skipping registration + // otherwise the session "migrates successfully" yet is invisible in Cursor. Registration is best-effort: failure only affects visibility. try { const title = this.buildComposerTitle(session, sessionId); const reg = registerCursorComposer({ @@ -534,7 +534,7 @@ export class CursorAdapter extends AgentAdapter { messages: this.toComposerMessages(session), }); if (!reg.ok) { - // 不静默:transcript 已落盘但列表注册失败,用户在 Cursor 里会「看不到」。 + // Never silent: the transcript is on disk but list registration failed -- the user would not see it in Cursor. log.debug(`cursor register failed: composer=${sessionId} reason=${reg.reason ?? 'unknown'}`); log.warn( `Cursor session list registration failed (transcript written, session may be invisible in Cursor): ${reg.reason ?? 'unknown'}`, @@ -561,8 +561,8 @@ export class CursorAdapter extends AgentAdapter { /** IR 消息 → Cursor composer 的 bubble 素材(文本 + 工具调用/结果)。 */ private toComposerMessages(session: Session): CursorComposerMessage[] { - // 先按 callId 收集工具结果:原生的工具结果挂在 assistant 的 tool 气泡里, - // 不单独成为用户消息(否则 UI 里会冒出成百上千个 `[tool_result] {json}` 气泡)。 + // Collect tool results by callId first: natively they hang on the assistant's tool bubble, + // never a standalone user message (the UI would sprout hundreds of `[tool_result] {json}` bubbles). const toolResults = new Map(); for (const msg of session.messages) { for (const block of msg.content) { @@ -590,13 +590,13 @@ export class CursorAdapter extends AgentAdapter { const tools: CursorComposerTool[] = []; for (const block of msg.content) { if (block.type === 'text') { - // MigrationEngine 会把 Cursor 不支持的 ThinkingBlock 降级成 `…` - // 包裹的文本块(migrate.ts degradeThinkingBlocks)。这类包裹留在正文里会让 Cursor - // 按 HTML 块渲染整段(markdown 失效、换行被吞),所以进 DB 气泡前剥掉;原文仍留在 + // MigrationEngine degrades Cursor-unsupported ThinkingBlocks into `...`, + // wrapped text blocks (migrate.ts degradeThinkingBlocks). Left in the body they make Cursor + // rendered as an HTML block (markdown broken, newlines swallowed) -- strip before the DB bubble; the original stays in the // transcript 里。 const stripped = block.text.replace(THINKING_WRAP_RE, '').trim(); - // 用户气泡只显示真实提问:注入块(///…)与附件 - // 路径都是噪音(Cursor 原生把它们渲染成 chip,我们渲染不出来)。 + // user bubbles show the real question only: injected blocks (///...) and attachment + // paths are noise (Cursor renders them as chips natively; we cannot). const visible = msg.role === 'user' ? visibleUserText(stripped) : stripped; if (visible && isRenderableText(visible)) textParts.push(visible); } else if (block.type === 'tool_call') { @@ -608,9 +608,9 @@ export class CursorAdapter extends AgentAdapter { isError: res?.isError, }); } - // thinking:不写进 bubble 正文(原生 Cursor 不存 thinking;一旦以 `` 开头, - // 整段会被当 HTML 块,markdown 与换行失效)。原文仍保留在 transcript 里。 - // tool_result:已配对进上面的 tool 气泡,不单独成消息。 + // thinking: not written into bubble bodies (native Cursor stores no thinking; once a body starts with , + // the whole thing renders as an HTML block with broken markdown/newlines). Original kept in the transcript. + // tool_result: already paired into the tool bubble above; never a standalone message. } const text = textParts.join('\n\n'); if (!text.trim() && tools.length === 0) continue; @@ -626,11 +626,11 @@ export class CursorAdapter extends AgentAdapter { } async deleteSession(sessionId: string, projectPath?: string): Promise { - // 先摘掉 DB 注册(否则删了 transcript,Agents 列表里还留着一条点不开的会话) + // Unregister first (otherwise deleting the transcript leaves an unopenable entry in the Agents list) try { unregisterCursorComposer(sessionId); } catch { - // best-effort:注册残留只影响列表显示,不影响数据安全 + // best-effort: leftover registration rows only affect the list display, never data safety } const jsonlPath = this.findSessionFile(sessionId, projectPath); From f9aee9244a882fce8f883c85d566559e23a4d0ea Mon Sep 17 00:00:00 2001 From: lurkacai Date: Wed, 23 Sep 2026 17:46:57 +0800 Subject: [PATCH 17/28] chore(session): delete the leftover empty codebuddy.ts shell Review note 1 asked for this file to be removed before merging: it was an accidental leftover with no imports; the real CLI adapter lives at adapters/codebuddy.ts and the IDE store is handled by adapters/codebuddy-ide.ts. --- src/session-flow/codebuddy.ts | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 src/session-flow/codebuddy.ts diff --git a/src/session-flow/codebuddy.ts b/src/session-flow/codebuddy.ts deleted file mode 100644 index 8799b7bba..000000000 --- a/src/session-flow/codebuddy.ts +++ /dev/null @@ -1,4 +0,0 @@ -// 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 {}; From 873c73b424c78e57ca491e7927a0a29ffda084a1 Mon Sep 17 00:00:00 2001 From: lurkacai Date: Wed, 23 Sep 2026 20:01:18 +0800 Subject: [PATCH 18/28] fix(cursor): synthesize placeholder tool results so targets don't render cancelled Cursor transcripts stop at tool_use -- the export never records tool outputs. Migrated sessions therefore had tool calls with no paired result, and CodeBuddy IDE renders every unpaired call as cancelled: a real 128-message migration showed a wall of cancelled Bash/Grep bubbles. Pair each unpaired tool_call with an honest placeholder ('[tool output not captured: Cursor transcripts do not record tool results]'). --- src/session-flow/adapters/cursor.ts | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/session-flow/adapters/cursor.ts b/src/session-flow/adapters/cursor.ts index 0a9d2af28..f81d3f66d 100644 --- a/src/session-flow/adapters/cursor.ts +++ b/src/session-flow/adapters/cursor.ts @@ -399,9 +399,34 @@ export class CursorAdapter extends AgentAdapter { arguments: (it.input as Record) ?? {}, }); } - // Cursor 没有 tool_result / thinking + // Cursor transcripts carry no tool results and no thinking (the export + // stops at tool_use). Every tool_call therefore ends up without a paired + // result, and targets that render unpaired calls as "cancelled" (e.g. + // CodeBuddy IDE) show the whole session as cancelled. Synthesize an + // honest placeholder result so the pairing is complete and the target + // UI renders a normal state instead of a wall of cancellations. } - return blocks; + + // Pair each tool_call that has no tool_result with a placeholder result. + const hasResult = new Set( + blocks + .filter((b): b is Extract => b.type === 'tool_result') + .map((b) => b.callId), + ); + const withResults: ContentBlock[] = []; + for (const block of blocks) { + withResults.push(block); + if (block.type === 'tool_call' && block.callId && !hasResult.has(block.callId)) { + withResults.push({ + type: 'tool_result', + callId: block.callId, + content: + '[tool output not captured: Cursor transcripts do not record tool results]', + isError: false, + }); + } + } + return withResults; } async writeSession(session: Session, projectPath?: string): Promise { From 3993a1c8862490fb85c4b27d666ae010634fa253 Mon Sep 17 00:00:00 2001 From: lurkacai Date: Wed, 23 Sep 2026 20:08:33 +0800 Subject: [PATCH 19/28] fix(session): strip injected wrappers from IDE-bound user text; title skips [Image] Two follow-ups from the cursor -> codebuddy-ide comparison: - migrated user bubbles kept the raw Cursor wrappers (// + attachment paths); IDE messages are the display layer, so run user text through visibleUserText before writing. - a session whose messages were only image attachments got titled "[Image] [Image] [Image]"; the title segment filter now skips [image]/[file]/[attachment] placeholders like it already did [tool_result]. --- src/session-flow/ide-history.ts | 9 +++++++-- src/session-flow/title.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/session-flow/ide-history.ts b/src/session-flow/ide-history.ts index a9b8ec089..0346588cd 100644 --- a/src/session-flow/ide-history.ts +++ b/src/session-flow/ide-history.ts @@ -26,7 +26,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import type { ContentBlock, Session } from './ir.js'; import { imagePlaceholderText } from './ir.js'; -import { isInjectedText, titleFromUserText } from './title.js'; +import { isInjectedText, titleFromUserText , visibleUserText } from './title.js'; // --------------------------------------------------------------------------- // 类型 @@ -343,7 +343,12 @@ function irToIdeMessages(session: Session): { messages: IdeMessageFile[]; assets if (b.type === 'thinking') { content.push({ type: 'reasoning', text: b.text }); } else if (b.type === 'text') { - content.push({ type: 'text', text: b.text }); + // IDE messages are the display layer (there is no separate raw copy + // like codex's response_item): strip injected wrappers + // (//...) and attachment paths + // from user text so the bubble shows the real question. + const text = msg.role === 'user' ? visibleUserText(b.text) : b.text; + if (text) content.push({ type: 'text', text }); } else if (b.type === 'image') { const ref = assetRef(b); if (ref) { diff --git a/src/session-flow/title.ts b/src/session-flow/title.ts index 712e5f93c..0c7126dd9 100644 --- a/src/session-flow/title.ts +++ b/src/session-flow/title.ts @@ -148,7 +148,7 @@ export function visibleUserText(text: string): string { /** 只看「看起来就是路径」的片段:@tag: 开头或绝对路径开头(正文里的 http URL 不算)。 */ const ATTACH_SEG_RE = /^@[A-Za-z_]+:/; const PATH_SEG_RE = /^(?:[A-Za-z]:)?[/\\]/; -const HYGIENE_RE = /^\[tool_result/i; +const HYGIENE_RE = /^\[(?:tool_result|image|file|attachment)\b/i; /** * 由首条用户消息得到「像人话」的标题。 From 1c10cd47a5f5a2823801985ceffac89322c40078 Mon Sep 17 00:00:00 2001 From: lurkacai Date: Wed, 23 Sep 2026 20:10:54 +0800 Subject: [PATCH 20/28] fix(cursor): synthesize stable callIds for id-less tool_use; prefer unwrapped titles The comparison team found the placeholder-result pairing was being short-circuited: some Cursor versions export tool_use without an id (observed 173/173 on one transcript), the reader produced empty callIds, and the truthiness guard skipped every placeholder -- so the target still rendered a wall of cancelled tools. Synthesize a stable per-parse id (tool_) when the source has none, mirroring the writeSession fallback. Titles: prefer titleFromUserText over cleanTitleText for non-injected text as well, so '' is unwrapped and [Image] segments skipped before the raw first line wins. --- src/session-flow/adapters/cursor.ts | 8 +++++++- src/session-flow/title.ts | 7 ++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/session-flow/adapters/cursor.ts b/src/session-flow/adapters/cursor.ts index f81d3f66d..8a310d5ef 100644 --- a/src/session-flow/adapters/cursor.ts +++ b/src/session-flow/adapters/cursor.ts @@ -392,10 +392,16 @@ export class CursorAdapter extends AgentAdapter { if (btype === 'text') { blocks.push({ type: 'text', text: String(it.text ?? '') }); } else if (btype === 'tool_use') { + // Some Cursor versions export tool_use without an id (observed 173/173 + // on one transcript). An empty callId then short-circuits the result + // pairing downstream and every call lands as "cancelled" in targets + // like CodeBuddy IDE. Synthesize a stable per-parse id instead -- + // same approach as the writeSession fallback below. + const rawId = String(it.id ?? '').trim(); blocks.push({ type: 'tool_call', toolName: normalizeToolName(String(it.name ?? '')), - callId: String(it.id ?? ''), + callId: rawId || `tool_${blocks.length}`, arguments: (it.input as Record) ?? {}, }); } diff --git a/src/session-flow/title.ts b/src/session-flow/title.ts index 0c7126dd9..4a32fbbf8 100644 --- a/src/session-flow/title.ts +++ b/src/session-flow/title.ts @@ -49,9 +49,14 @@ export function isInjectedText(text: string): boolean { export function titleFromCandidates(candidates: string[]): string { for (const text of candidates) { if (!text) continue; + // Prefer titleFromUserText for non-injected text too: it unwraps + // and skips placeholder segments ([Image]/attachment paths), + // so a session whose first message is "[Image] [Image] [Image] + // the real question " gets titled by the question, not by the + // attachments. cleanTitleText stays as the fallback. const cleaned = isInjectedText(text) ? titleFromUserText(text) - : cleanTitleText(text) || titleFromUserText(text); + : titleFromUserText(text) || cleanTitleText(text); if (cleaned) return cleaned; } return ''; From 2a3526ded555cf8fbd2c32815d2117266296c84c Mon Sep 17 00:00:00 2001 From: lurkacai Date: Thu, 24 Sep 2026 12:51:41 +0800 Subject: [PATCH 21/28] fix(session): harden archive trust boundary, git reporting and rollback scoping Review follow-up on #593: - Sessions loaded from a team archive are marked untrusted and no longer read local image filePaths (`mayReadLocalImageFile`); a crafted archive could otherwise pull any readable file into a restored session. IDE asset and message file names are reduced to a bare name, so archive-controlled labels/ids cannot write outside assets/ or messages/. - Refuse writes and deletes through symlinked archive paths (a team repo checkout can carry symlinks out of sessions/). - canonicalizeRemote strips userinfo, so a token in the remote never reaches archive metadata, indexes or list output; ssh:// and scp forms now match the https identity. - Repo directory names are encoded injectively (%XX) and author names escape the Windows separator. - gitCommit reports committed / no-changes / failed separately, stages only files this run wrote, and a failed remote push exits 1 instead of printing a success line. - rollback: every adapter returns whether it deleted anything, rollback --cwd no longer falls back to a global delete, and Codex/Cursor/WorkBuddy unregister only after the scoped transcript is found. - migrate: same-platform overwrite needs --target-cwd or -y; an ambiguous session-id prefix is rejected; untrusted archive text is stripped of terminal control sequences before printing; each session is attributed to its own workspace git identity. - rebuildIndex keeps origin title/author; pull --all rebuilds by directory so repos with a missing index are repaired; indexes are written atomically. - --scrub drops image filePath (local home paths leaked into the archive); cursor temp SQL files are created 0600. --- src/__tests__/scrub-session.test.ts | 13 ++ src/__tests__/session-cmd.test.ts | 13 +- src/__tests__/session-sync.test.ts | 76 ++++++- src/session-flow/adapters/base.ts | 11 +- src/session-flow/adapters/claude-code.ts | 22 +- src/session-flow/adapters/codex.ts | 45 ++-- src/session-flow/adapters/cursor.ts | 31 ++- src/session-flow/adapters/workbuddy.ts | 24 ++- src/session-flow/cursor-store.ts | 6 +- src/session-flow/fs.ts | 43 ++++ src/session-flow/ide-history.ts | 37 +++- src/session-flow/ids.ts | 18 +- src/session-flow/scrub.ts | 20 +- src/session-flow/session-cmd.ts | 202 +++++++++++++----- src/session-flow/sync.ts | 251 +++++++++++++++++++---- 15 files changed, 657 insertions(+), 155 deletions(-) diff --git a/src/__tests__/scrub-session.test.ts b/src/__tests__/scrub-session.test.ts index fbe5e3f00..7eefdfc67 100644 --- a/src/__tests__/scrub-session.test.ts +++ b/src/__tests__/scrub-session.test.ts @@ -75,6 +75,19 @@ describe('scrubSession', () => { expect(typeof (call as { arguments: unknown }).arguments).toBe('object'); }); + it('图片块的本地绝对路径也被去掉(否则脱敏了正文却泄露家目录)', () => { + const s = makeSession(); + s.messages.push({ + role: 'user', + content: [ + { type: 'image', mimeType: 'image/png', filePath: '/Users/alice/secret/screenshot.png', label: 'shot.png' }, + ], + timestamp: s.createdAt, + }); + const result = scrubSession(s); + expect(JSON.stringify(result.session)).not.toContain('/Users/alice/secret'); + }); + it('无敏感内容时原样返回、计数为 0', () => { const clean = makeSession(); clean.title = '普通提问'; diff --git a/src/__tests__/session-cmd.test.ts b/src/__tests__/session-cmd.test.ts index 81e019045..d1a92635c 100644 --- a/src/__tests__/session-cmd.test.ts +++ b/src/__tests__/session-cmd.test.ts @@ -3,6 +3,7 @@ import { Command } from 'commander'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { encodeRepoIdentity } from '../session-flow/sync.js'; /** * session-cmd 子命令测试(M2 消费端)。 @@ -263,7 +264,7 @@ describe('session pull --all', () => { seed(null, 'bob', 'b-1', 'plain', 'plain notes'); // 手动清空 alpha 的索引条目,验证 pull --all 会按磁盘内容幂等重建 - const idxPath = path.join(repoRoot, 'sessions', 'repos', 'github.com_org_alpha', '_index.json'); + const idxPath = path.join(repoRoot, 'sessions', 'repos', encodeRepoIdentity('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: [] }), @@ -335,7 +336,7 @@ describe('session push --all', () => { // --all 枚举全部工作区:listConversations 以无参形式调用 expect(adapter.listConversations).toHaveBeenCalledWith(); - const dir = path.join(repoRoot, 'sessions', 'repos', 'gitlab.com_team_beta', 'tester'); + const dir = path.join(repoRoot, 'sessions', 'repos', encodeRepoIdentity('gitlab.com/team/beta'), 'tester'); expect(fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'))).toHaveLength(8); const text = out.join('\n'); @@ -392,7 +393,7 @@ describe('session push --all', () => { 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'); + const dir = path.join(repoRoot, 'sessions', 'repos', encodeRepoIdentity('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); }); @@ -408,9 +409,9 @@ describe('session push archive key (native cwd, not the run directory)', () => { await runSession('push', '--source', 'fakeplat', '--repo-root', repoRoot, '--cwd', '/run/other'); - const betaDir = path.join(repoRoot, 'sessions', 'repos', 'gitlab.com_team_beta', 'tester'); + const betaDir = path.join(repoRoot, 'sessions', 'repos', encodeRepoIdentity('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); + expect(fs.existsSync(path.join(repoRoot, 'sessions', 'repos', encodeRepoIdentity('github.com/org/other')))).toBe(false); // 归档键写进 meta,与目录一致 const metaPath = fs.readdirSync(betaDir).find((f) => f.endsWith('.meta.json'))!; @@ -469,7 +470,7 @@ describe('session migrate --push archive key', () => { '--push', '--repo-root', repoRoot, '--cwd', '/run/dir', ); - const dir = path.join(repoRoot, 'sessions', 'repos', 'github.com_org_target', 'tester'); + const dir = path.join(repoRoot, 'sessions', 'repos', encodeRepoIdentity('github.com/org/target'), 'tester'); const metaFiles = fs.readdirSync(dir).filter((f) => f.endsWith('.meta.json')); expect(metaFiles).toHaveLength(1); diff --git a/src/__tests__/session-sync.test.ts b/src/__tests__/session-sync.test.ts index 7840051d1..416078f82 100644 --- a/src/__tests__/session-sync.test.ts +++ b/src/__tests__/session-sync.test.ts @@ -2,7 +2,14 @@ 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 { + SyncManager, + defaultSyncMeta, + generateSessionName, + encodeRepoIdentity, + decodeRepoIdentity, + canonicalizeRemote, +} from '../session-flow/sync.js'; import type { Session } from '../session-flow/ir.js'; /** @@ -44,13 +51,14 @@ function mkSession(o: Partial = {}): Session { }; } -function mkMeta(o: { sessionId?: string; author?: string; repoIdentity?: string | null } = {}) { +function mkMeta(o: { sessionId?: string; author?: string; repoIdentity?: string | null; title?: string } = {}) { return defaultSyncMeta( { platform: 'claude-code', author: o.author ?? 'alice', cwd: '/proj/alpha', sessionId: o.sessionId ?? 's-1', + title: o.title, // 显式传 null(_unattributed)不能被默认值吞掉 repoIdentity: o.repoIdentity === undefined ? 'github.com/org/alpha' : o.repoIdentity, }, @@ -58,9 +66,9 @@ function mkMeta(o: { sessionId?: string; author?: string; repoIdentity?: string ); } -/** 与 SyncManager.repoDir 相同的编码规则(/ → _,保留字母数字和 . -)。 */ +/** 与 SyncManager.repoDir 相同的编码规则(非白名单字符 → %XX)。 */ function repoDirOf(identity: string): string { - return path.join(repoRoot, 'sessions', 'repos', identity.replace(/[^a-zA-Z0-9.-]/g, '_')); + return path.join(repoRoot, 'sessions', 'repos', encodeRepoIdentity(identity)); } function writeRepoIndex(identity: string, index: unknown): void { @@ -272,4 +280,64 @@ describe('rebuildIndex', () => { expect(mgr.rebuildIndex('github.com/org/alpha')).toBe(1); expect(readRepoIndex('github.com/org/alpha').sessions).toHaveLength(1); }); + + it('keeps the original title and author instead of the sanitized directory name', () => { + // The author directory is sanitized (`alice:ci` → `alice_ci`) and the file + // name is a truncated slug; rebuilding from those destroyed both fields. + const mgr = new SyncManager(repoRoot); + mgr.saveSession( + mkSession({ title: 'Fix Payment Retry Logic' }), + mkMeta({ author: 'alice:ci', repoIdentity: 'github.com/org/alpha', title: 'Fix Payment Retry Logic' }), + ); + + fs.writeFileSync(path.join(repoDirOf('github.com/org/alpha'), '_index.json'), '{ corrupted'); + mgr.rebuildIndex('github.com/org/alpha'); + + const entry = readRepoIndex('github.com/org/alpha').sessions[0]; + expect(entry.author).toBe('alice:ci'); + expect(entry.title).toBe('Fix Payment Retry Logic'); + }); + + it('rebuilds repos whose index is missing entirely (pull --all repair path)', () => { + const mgr = new SyncManager(repoRoot); + mgr.saveSession(mkSession(), mkMeta()); + + // 删掉索引后,listAllRepoIdentities 反查不到 → 只有按目录重建才能修回来 + fs.unlinkSync(path.join(repoDirOf('github.com/org/alpha'), '_index.json')); + expect(new SyncManager(repoRoot).listAllRepoIdentities()).toEqual([]); + + const { repos, sessions } = new SyncManager(repoRoot).rebuildAllIndexes(); + expect(repos).toBe(1); + expect(sessions).toBe(1); + expect(new SyncManager(repoRoot).listAllRepoIdentities()).toEqual(['github.com/org/alpha']); + }); +}); + +describe('canonicalizeRemote', () => { + it('strips credentials embedded in the remote URL', () => { + // Tokens live in remotes on CI checkouts; publishing them into the + // archive index would leak them to the whole team. + expect(canonicalizeRemote('https://oauth2:TOKEN@github.com/org/repo.git')).toBe('github.com/org/repo'); + expect(canonicalizeRemote('https://user:pass@gitlab.company.com/g/repo.git')).toBe('gitlab.company.com/g/repo'); + }); + + it('normalizes https, scp and ssh remote forms to the same identity', () => { + expect(canonicalizeRemote('https://github.com/org/repo.git')).toBe('github.com/org/repo'); + expect(canonicalizeRemote('git@github.com:org/repo.git')).toBe('github.com/org/repo'); + expect(canonicalizeRemote('ssh://git@github.com/org/repo.git')).toBe('github.com/org/repo'); + }); +}); + +describe('encodeRepoIdentity', () => { + it('is injective: distinct identities never share a directory', () => { + const ids = ['github.com/org/a/b', 'github.com/org/a_b', 'github.com/org/a:b']; + const encoded = ids.map(encodeRepoIdentity); + expect(new Set(encoded).size).toBe(ids.length); + }); + + it('round-trips through decodeRepoIdentity', () => { + for (const id of ['github.com/org/repo', 'gitlab.company.com/g/a_b']) { + expect(decodeRepoIdentity(encodeRepoIdentity(id))).toBe(id); + } + }); }); diff --git a/src/session-flow/adapters/base.ts b/src/session-flow/adapters/base.ts index 4335191df..b8297deac 100644 --- a/src/session-flow/adapters/base.ts +++ b/src/session-flow/adapters/base.ts @@ -36,14 +36,11 @@ export abstract class AgentAdapter { /** * 删除目标平台上的会话(用于回滚)。 * - * 返回值用于区分「真的删掉了」和「压根没找到」: - * - `false` —— 确认没有任何东西被删除(会话不存在) - * - `true` / `undefined` —— 已删除,或该适配器不检测存在性(沿用原有行为) - * - * 之所以允许返回 void:多数适配器不具备存在性检测能力, - * 为回滚的可观测性改动全部适配器不划算,未实现的保持 undefined 即可。 + * 返回值区分「真的删掉了」和「压根没找到」:`false` 表示确认没有任何东西 + * 被删除(会话不存在,或 `projectPath` 作用域内没有它)。回滚必须能报出 + * no-op——否则脚本无法判断回滚是否生效。所有适配器都要给出明确布尔值。 */ - abstract deleteSession(sessionId: string, projectPath?: string): Promise; + abstract deleteSession(sessionId: string, projectPath?: string): Promise; /** 检测该平台 CLI 是否已安装且可用(静态,检查基础路径)。 */ static isAvailable(): boolean { diff --git a/src/session-flow/adapters/claude-code.ts b/src/session-flow/adapters/claude-code.ts index 6b4d956f2..05545eb6f 100644 --- a/src/session-flow/adapters/claude-code.ts +++ b/src/session-flow/adapters/claude-code.ts @@ -37,6 +37,7 @@ import { dirExists, scanFiles, removeDirRecursive, + mayReadLocalImageFile, } from '../fs.js'; import { cleanTitleText, @@ -241,7 +242,7 @@ function guessImageLabel(mimeType: string): string { // content 块序列化(IR → CC) // --------------------------------------------------------------------------- -function irBlockToCc(block: ContentBlock): Record | null { +function irBlockToCc(block: ContentBlock, session?: Session): Record | null { switch (block.type) { case 'text': return { type: 'text', text: block.text }; @@ -269,9 +270,12 @@ function irBlockToCc(block: ContentBlock): Record | null { // CC 原生支持用户消息里的 base64 图片块。data 缺失(源文件读不到)时 // 从 filePath 现读;再不行降级占位文本,绝不静默丢块。 let data = block.data; - if (!data && block.filePath) { + // Untrusted sessions (restored from a team archive) must never read a + // local path: an archived image block can point at any readable file, + // and its contents would then be embedded into the restored session. + if (!data && mayReadLocalImageFile(session, block.filePath)) { try { - data = fs.readFileSync(block.filePath).toString('base64'); + data = fs.readFileSync(block.filePath as string).toString('base64'); } catch { data = undefined; } @@ -676,7 +680,7 @@ export class ClaudeCodeAdapter extends AgentAdapter { // 构建 content 块 const ccBlocks: Record[] = []; for (const block of msg.content) { - const ccBlock = irBlockToCc(block); + const ccBlock = irBlockToCc(block, session); if (ccBlock) ccBlocks.push(ccBlock); } @@ -779,14 +783,17 @@ export class ClaudeCodeAdapter extends AgentAdapter { return records; } - async deleteSession(sessionId: string, projectPath?: string): Promise { + async deleteSession(sessionId: string, projectPath?: string): Promise { const jsonlPath = this.findSessionFile(sessionId, projectPath); - if (!jsonlPath) return; + // Nothing to delete under this project: report it instead of printing ✓. + if (!jsonlPath) return false; + let deleted = false; try { fs.unlinkSync(jsonlPath); + deleted = true; } catch { - // ignore + deleted = false; } // 删除同名子目录 @@ -794,5 +801,6 @@ export class ClaudeCodeAdapter extends AgentAdapter { if (dirExists(subdir)) { removeDirRecursive(subdir); } + return deleted; } } diff --git a/src/session-flow/adapters/codex.ts b/src/session-flow/adapters/codex.ts index 25474127f..80dcbe978 100644 --- a/src/session-flow/adapters/codex.ts +++ b/src/session-flow/adapters/codex.ts @@ -321,16 +321,31 @@ export class CodexAdapter extends AgentAdapter { return scanFiles(this.storageRoot, /\.jsonl$/); } - private findSessionFile(sessionId: string): string | null { + private findSessionFile(sessionId: string, projectPath?: 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; + if (!(base === sessionId || (sessionId.length >= 8 && base.endsWith(sessionId)))) continue; + // rollback --cwd: only the copy that belongs to this project. Without + // this, deleting one session id would remove every workspace's copy. + if (projectPath && !this.rolloutMatchesCwd(f, projectPath)) continue; + return f; } return null; } + /** rollout 首行 session_meta 里带真实 cwd,用它做 --cwd 作用域过滤。 */ + private rolloutMatchesCwd(file: string, projectPath: string): boolean { + try { + const rec = this.readFirstLine(file); + const payload = (rec?.payload ?? {}) as Record; + return String(payload.cwd ?? '') === projectPath; + } catch { + return false; + } + } + private readFirstLine(filePath: string): Record | null { try { for (const record of readJsonlHead(filePath, 1)) { @@ -624,9 +639,12 @@ export class CodexAdapter extends AgentAdapter { // Random ids would give every re-migration a fresh target id -- a fully // duplicated second thread in Codex (threads doubled). Derived ids make a // re-migration an overwrite: idempotent by construction. + // cwd 参与派生:Codex rollout 的 sessionId 是全局键,同一源会话迁到两个 + // 工作区若共用 id,第二份会把第一份顶掉。 + const cwd = projectPath ?? session.cwd; const sessionId = isUuidV7(session.sessionId) ? session.sessionId - : deriveTargetSessionId(this.platform, session.sessionId); + : deriveTargetSessionId(this.platform, session.sessionId, cwd); // 损坏输入防御:session.createdAt 非法时 new Date(...) 得到 Invalid Date, // 直接 toISOString() 会抛 RangeError 让整个写入崩溃。 @@ -1038,18 +1056,21 @@ export class CodexAdapter extends AgentAdapter { } } - async deleteSession(sessionId: string, projectPath?: string): Promise { - // Unregister first, then delete the body. Removing only the rollout leaves an + async deleteSession(sessionId: string, projectPath?: string): Promise { + // Scope first: unregisterThread is global, so running it before the lookup + // would drop another workspace's copy when `rollback --cwd` matched nothing. + const f = this.findSessionFile(sessionId, projectPath); + if (!f || !fileExists(f)) return false; + + // Unregister, then delete the body. Removing only the rollout leaves an // orphan listed with title/preview but opening blank -- rollback achieved nothing. await this.unregisterThread(sessionId); - const f = this.findSessionFile(sessionId); - if (f && fileExists(f)) { - try { - fs.unlinkSync(f); - } catch { - // ignore - } + try { + fs.unlinkSync(f); + return true; + } catch { + return false; } } diff --git a/src/session-flow/adapters/cursor.ts b/src/session-flow/adapters/cursor.ts index 8a310d5ef..47ef73466 100644 --- a/src/session-flow/adapters/cursor.ts +++ b/src/session-flow/adapters/cursor.ts @@ -132,8 +132,15 @@ function uuidV4(): string { * 同一会话反复迁移会各留一份副本:transcript 与 composerHeaders 都堆积重复条目, * 而且无法按源 id 回滚。派生后同一源会话永远命中同一个 composerId(重迁移=覆盖)。 */ -function deriveCursorId(sourcePlatform: string, sourceId: string): string { - const hex = crypto.createHash('sha256').update(`teamai:cursor:${sourcePlatform}:${sourceId}`).digest('hex'); +function deriveCursorId(sourcePlatform: string, sourceId: string, targetCwd?: string): string { + // The composerId is a global key in state.vscdb: without the target cwd, + // migrating one source session into two workspaces reuses one id and the + // second copy overwrites the first. + const scope = targetCwd ? `:${targetCwd}` : ''; + const hex = crypto + .createHash('sha256') + .update(`teamai:cursor:${sourcePlatform}${scope}:${sourceId}`) + .digest('hex'); const variant = ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16); return [ hex.slice(0, 8), @@ -436,12 +443,11 @@ export class CursorAdapter extends AgentAdapter { } async writeSession(session: Session, projectPath?: string): Promise { + const cwd = projectPath ?? session.cwd; // Deterministic id: re-migrations of the same source session hit the same composerId (no more per-run copies) const sessionId = isUuid(session.sessionId) ? session.sessionId - : deriveCursorId(session.platform || 'unknown', session.sessionId); - - const cwd = projectPath ?? session.cwd; + : deriveCursorId(session.platform || 'unknown', session.sessionId, cwd); const projDir = path.join(getCursorProjectsDir(), encodeCwdGeneric(cwd)); const transcriptDir = path.join(projDir, 'agent-transcripts', sessionId); const jsonlPath = path.join(transcriptDir, `${sessionId}.jsonl`); @@ -656,21 +662,26 @@ export class CursorAdapter extends AgentAdapter { return out; } - async deleteSession(sessionId: string, projectPath?: string): Promise { - // Unregister first (otherwise deleting the transcript leaves an unopenable entry in the Agents list) + async deleteSession(sessionId: string, projectPath?: string): Promise { + // Resolve the scoped transcript before touching the global registration: + // unregistering first would drop another workspace's copy of the same + // session id when `rollback --cwd` matched nothing here. + const jsonlPath = this.findSessionFile(sessionId, projectPath); + if (!jsonlPath) return false; + + // Unregister (otherwise deleting the transcript leaves an unopenable entry in the Agents list) try { unregisterCursorComposer(sessionId); } catch { // best-effort: leftover registration rows only affect the list display, never data safety } - const jsonlPath = this.findSessionFile(sessionId, projectPath); - if (!jsonlPath) return; - // 删除整个 session 目录 const sessionDir = path.dirname(jsonlPath); if (dirExists(sessionDir)) { removeDirRecursive(sessionDir); + return !dirExists(sessionDir); } + return false; } } diff --git a/src/session-flow/adapters/workbuddy.ts b/src/session-flow/adapters/workbuddy.ts index 06f009168..4f388f444 100644 --- a/src/session-flow/adapters/workbuddy.ts +++ b/src/session-flow/adapters/workbuddy.ts @@ -509,12 +509,12 @@ export class WorkBuddyAdapter extends AgentAdapter { } async writeSession(session: Session, projectPath?: string): Promise { + const cwd = projectPath ?? session.cwd; // 非 UUID 源 id 用确定性派生(同一源会话反复迁移命中同一个 id → 不产生重复会话) + // cwd 参与派生:WorkBuddy 的会话记录是全局键,同名 id 迁到两个工作区会互相覆盖。 const sessionId = isUuidV4(session.sessionId) ? session.sessionId - : deriveTargetSessionId('workbuddy', session.sessionId); - - const cwd = projectPath ?? session.cwd; + : deriveTargetSessionId('workbuddy', session.sessionId, cwd); // WorkBuddy 与 CodeBuddy 同构:项目目录名**保留空格**(实测 CodeBuddy 落盘为 // `Users-caiwenzhe-Desktop-Code-teamai cli`)。用 encodeCwdGeneric 会把空格也换成 // `-`,目录名与客户端按当前 cwd 算出的不一致 → 会话不出现在该项目列表里。 @@ -706,21 +706,26 @@ export class WorkBuddyAdapter extends AgentAdapter { return sessionId; } - async deleteSession(sessionId: string, projectPath?: string): Promise { - // 先摘掉 DB 注册(否则删了 jsonl,WorkBuddy 列表里还留着一条点不开的会话) + async deleteSession(sessionId: string, projectPath?: string): Promise { + // Resolve the scoped transcript first: unregistering is global, so doing + // it before the lookup would drop another workspace's copy of the same + // session id when `rollback --cwd` matched nothing here. + const jsonlPath = this.findSessionFile(sessionId, projectPath); + if (!jsonlPath) return false; + + // 摘掉 DB 注册(否则删了 jsonl,WorkBuddy 列表里还留着一条点不开的会话) try { unregisterWorkBuddySession(sessionId); } catch { // best-effort } - const jsonlPath = this.findSessionFile(sessionId, projectPath); - if (!jsonlPath) return; - + let deleted = false; try { fs.unlinkSync(jsonlPath); + deleted = true; } catch { - // ignore + deleted = false; } const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json'); @@ -737,5 +742,6 @@ export class WorkBuddyAdapter extends AgentAdapter { if (dirExists(subdir)) { removeDirRecursive(subdir); } + return deleted; } } diff --git a/src/session-flow/cursor-store.ts b/src/session-flow/cursor-store.ts index 3e7df7231..76af834a4 100644 --- a/src/session-flow/cursor-store.ts +++ b/src/session-flow/cursor-store.ts @@ -594,7 +594,9 @@ export function registerCursorComposer(args: RegisterCursorComposerArgs): Regist const sqlPath = path.join(os.tmpdir(), `teamai-cursor-${process.pid}-${Date.now()}.sql`); try { - fs.writeFileSync(sqlPath, stmts.join('\n'), 'utf-8'); + // The SQL file holds the full conversation text and lives in shared /tmp: + // default umask would leave it world-readable until we unlink it. + fs.writeFileSync(sqlPath, stmts.join('\n'), { encoding: 'utf-8', mode: 0o600 }); const r = spawnSync(sqlite3, [dbPath], { input: fs.readFileSync(sqlPath), maxBuffer: 32 * 1024 * 1024, @@ -634,7 +636,7 @@ export function unregisterCursorComposer(composerId: string): RegisterResult { const sqlPath = path.join(os.tmpdir(), `teamai-cursor-del-${process.pid}-${Date.now()}.sql`); try { - fs.writeFileSync(sqlPath, sql, 'utf-8'); + fs.writeFileSync(sqlPath, sql, { encoding: 'utf-8', mode: 0o600 }); const r = spawnSync(sqlite3, [dbPath], { input: fs.readFileSync(sqlPath), maxBuffer: 32 * 1024 * 1024, diff --git a/src/session-flow/fs.ts b/src/session-flow/fs.ts index 57b52bec3..6c0c36f24 100644 --- a/src/session-flow/fs.ts +++ b/src/session-flow/fs.ts @@ -242,6 +242,49 @@ export function scanFiles(rootDir: string, pattern: RegExp): string[] { return results; } +// --------------------------------------------------------------------------- +// 归档内容的安全边界(untrusted input) +// --------------------------------------------------------------------------- + +/** 只有这些后缀才可能是真正的图片,值得按本地文件读取。 */ +const IMAGE_FILE_RE = /\.(png|jpe?g|gif|webp|bmp)$/i; + +/** + * 是否允许读取图片块里的本地 `filePath`。 + * + * 团队归档里的 `filePath` 是别人写进去的不可信输入:伪造一条指向 + * `~/.ssh/id_rsa`(或任意可读文件)的图片块,写入侧一旦照读就会把文件内容 + * base64 塞进恢复出来的会话里——等于用一次 `session resume` 把本机文件 + * 带出去。因此: + * + * - 会话来自归档(`metadata.untrusted`)时一律不读本地文件,只用块内 data; + * - 其余情形也要求绝对路径 + 图片后缀,避免读到随便什么文件。 + */ +export function mayReadLocalImageFile( + session: { metadata?: Record | undefined } | undefined, + filePath?: string, +): boolean { + if (!filePath) return false; + if (!path.isAbsolute(filePath)) return false; + if (!IMAGE_FILE_RE.test(filePath)) return false; + return session?.metadata?.untrusted !== true; +} + +/** + * 把归档里任意字符串压成安全的文件名(用于 assets/、messages/ 等落盘名)。 + * + * 归档里的 label / message id 由推送方控制,`../../foo` 之类的值会直接变成 + * 路径片段写穿 assets 目录。这里强制只取 basename 并清掉分隔符与控制字符。 + */ +export function safeFileName(raw: string, fallback = 'image.png'): string { + const base = path + .basename(String(raw ?? '').replace(/\\/g, '/')) + .replace(/[\u0000-\u001f<>:"|?*]/g, '_') + .replace(/^\.+/, '_') + .trim(); + return base || fallback; +} + /** * 递归删除目录(用于 delete_session 清理子目录)。 */ diff --git a/src/session-flow/ide-history.ts b/src/session-flow/ide-history.ts index 0346588cd..ad3d05171 100644 --- a/src/session-flow/ide-history.ts +++ b/src/session-flow/ide-history.ts @@ -26,6 +26,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import type { ContentBlock, Session } from './ir.js'; import { imagePlaceholderText } from './ir.js'; +import { mayReadLocalImageFile, safeFileName } from './fs.js'; import { isInjectedText, titleFromUserText , visibleUserText } from './title.js'; // --------------------------------------------------------------------------- @@ -311,13 +312,16 @@ function irToIdeMessages(session: Session): { messages: IdeMessageFile[]; assets // IR 图片块 → assets/ + codebuddy-asset:// 引用(与原生存储一致) const assetRef = (b: Extract): string | null => { - const base = b.label || path.basename(b.filePath ?? 'image.png') || 'image.png'; + // The label is archive-controlled when the session came from the team + // repo: `../../evil` would otherwise become a path component and write + // outside assets/. Force it down to a bare file name. + const base = safeFileName(b.label || path.basename(b.filePath ?? 'image.png') || 'image.png'); const ext = path.extname(base) || `.${(b.mimeType.split('/')[1] ?? 'png').replace('jpeg', 'jpg')}`; const stem = base.slice(0, base.length - ext.length) || 'image'; - let name = `${stem}${ext}`; - for (let i = 1; usedNames.has(name); i++) name = `${stem}-${i}${ext}`; + let name = `${safeFileName(stem, 'image')}${ext}`; + for (let i = 1; usedNames.has(name); i++) name = `${safeFileName(stem, 'image')}-${i}${ext}`; usedNames.add(name); - if (b.filePath && fs.existsSync(b.filePath)) { + if (mayReadLocalImageFile(session, b.filePath) && b.filePath && fs.existsSync(b.filePath)) { assets.push({ name, sourcePath: b.filePath }); } else if (b.data) { assets.push({ name, data: b.data }); @@ -378,7 +382,12 @@ function irToIdeMessages(session: Session): { messages: IdeMessageFile[]; assets out.push({ role: msg.role, message: JSON.stringify({ role: msg.role, content }), - id: msg.messageId ?? stableId([session.sessionId, String(msgIdx), 'message']), + // The message id becomes a file name (messages/.json). Ids read + // back from an archive are untrusted: accept only the safe shape and + // fall back to a locally derived id otherwise. + id: /^[A-Za-z0-9_-]{1,128}$/.test(msg.messageId ?? '') + ? (msg.messageId as string) + : stableId([session.sessionId, String(msgIdx), 'message']), extra, createdAt: ts, }); @@ -640,6 +649,9 @@ export function writeIdeSession(session: Session, cwd: string): IdeSyncResult { let ok = false; try { const dest = path.join(assetsDir, asset.name); + // Belt and braces: never write outside assets/ even if a name + // slips past safeFileName. + if (path.relative(assetsDir, dest).startsWith('..')) throw new Error('unsafe asset name'); if (asset.sourcePath && fs.existsSync(asset.sourcePath)) { fs.copyFileSync(asset.sourcePath, dest); ok = true; @@ -750,12 +762,17 @@ export function deleteIdeSession(sessionId: string, cwd?: string): number { // 同时存在于多个工作区(把同一会话迁移到 A、B 两个项目)。此时按 convId 全局删 // 会连带删掉另一个工作区的副本——那是不可恢复的数据丢失。 // - // 但 cwd 本身常常不可靠:deleteSession 里的 cwd 是从 CLI 侧 jsonl 目录名反解出来的, - // 而目录名编码有损(路径分隔符与连字符无法区分),解出来的往往不是真实绝对路径, - // findIdeHistoryDirs 会因此返回空数组。所以限定失败时必须回退到全局搜索—— - // 宁可多删,也绝不能让清理退化成 no-op 留下永久残留。 - // 需要精确定界时用 rollback --cwd <真实路径>。 + // cwd 一旦给出就是**用户的显式界定**(rollback --cwd <真实路径>):解析不出 + // 工作区时只能报没删到,绝不能回退成全局删除。全局回退会把别的项目的同名 + // 副本一起删掉——那是不可恢复的数据丢失,比留下残留严重得多。 + // 调用方没给 cwd 时(migrate 失败后的自动回滚)才按 convId 全局清理。 const scoped = cwd ? findIdeHistoryDirs(cwd, false) : []; + if (cwd && scoped.length === 0) { + console.warn( + ` · No CodeBuddy IDE workspace matches ${cwd}; nothing deleted. Pass no --cwd to delete every copy.`, + ); + return 0; + } const targets: Array<{ historyDir: string; convDir: string }> = scoped.length > 0 ? scoped.map((historyDir) => ({ historyDir, convDir: path.join(historyDir, convId) })) : findIdeConversationDirs(convId); diff --git a/src/session-flow/ids.ts b/src/session-flow/ids.ts index c7a968830..3cb543f36 100644 --- a/src/session-flow/ids.ts +++ b/src/session-flow/ids.ts @@ -13,11 +13,23 @@ import * as crypto from 'node:crypto'; -/** Derive a deterministic target session id (UUIDv7 shape) from a source id. */ -export function deriveTargetSessionId(targetPlatform: string, sourceId: string): string { +/** + * Derive a deterministic target session id (UUIDv7 shape) from a source id. + * + * `targetCwd` participates when known: Cursor/WorkBuddy/Codex key their + * records globally, so migrating one source session into two workspaces with + * the same derived id makes the second copy replace (or redirect) the first. + * Omitting it keeps the previous id, so already-migrated sessions stay stable. + */ +export function deriveTargetSessionId( + targetPlatform: string, + sourceId: string, + targetCwd?: string, +): string { + const scope = targetCwd ? `:${targetCwd}` : ''; const hex = crypto .createHash('sha256') - .update(`teamai:${targetPlatform}:${sourceId}`) + .update(`teamai:${targetPlatform}${scope}:${sourceId}`) .digest('hex'); const variant = ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16); // version nibble is **7**: targets (e.g. Codex's isUuidV7) treat the id as diff --git a/src/session-flow/scrub.ts b/src/session-flow/scrub.ts index 0e33d87d4..e2c45f3f6 100644 --- a/src/session-flow/scrub.ts +++ b/src/session-flow/scrub.ts @@ -20,6 +20,7 @@ import type { Session, Message, ContentBlock } from './ir.js'; import { redactWithEnv } from '../utils/redact.js'; +import { imagePlaceholderText } from './ir.js'; export interface ScrubResult { session: Session; @@ -81,10 +82,21 @@ function scrubBlock(block: ContentBlock): { block: ContentBlock; count: number } count: countRedactions(block.content, next), }; } - case 'image': - // Image payloads do not participate in text redaction (binary/URL - // shapes carry no secret-shaped text). - return { block, count: 0 }; + case 'image': { + // Image payloads do not participate in text redaction, but their + // `filePath` is an absolute local path (`/Users/alice/...`) and lands in + // the archive verbatim -- scrub has to drop it, otherwise redacting the + // transcript still publishes the user's home directory layout. + if (!block.filePath) return { block, count: 0 }; + if (block.data) { + return { block: { type: 'image', mimeType: block.mimeType, data: block.data, label: block.label }, count: 1 }; + } + // No inline data: keeping the block would mean keeping the path. + return { + block: { type: 'text', text: imagePlaceholderText(block) }, + count: 1, + }; + } } } diff --git a/src/session-flow/session-cmd.ts b/src/session-flow/session-cmd.ts index 48a003f83..491424605 100644 --- a/src/session-flow/session-cmd.ts +++ b/src/session-flow/session-cmd.ts @@ -36,6 +36,20 @@ function formatBytes(bytes: number): string { return `${(bytes / 1024 / 1024).toFixed(1)}MB`; } +/** + * 去掉终端控制序列后再打印不可信文本。 + * + * 会话标题、作者名、仓库标识、搜索片段都来自团队仓——任何成员推送的内容都 + * 会进入别人的终端。ANSI/OSC 序列能移动光标、清屏、改标题,甚至把输出伪装成 + * 别的命令的结果(同源的 `\r` 覆写)。显示前一律剥掉控制字符。 + */ +// eslint-disable-next-line no-control-regex +const CONTROL_SEQ_RE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b\[[0-9;?]*[ -\/]*[@-~]/g; + +function safeText(value: string): string { + return String(value ?? '').replace(CONTROL_SEQ_RE, ' '); +} + /** * 预读所有 stdin 行到队列,ask 从队列取。 * @@ -144,6 +158,23 @@ function safeGetAdapter(platform: string) { } } +/** + * 归档作者:优先取会话自身工作区的 git identity。 + * + * `push --all` 会把多个工作区(可能分属不同仓库、不同本地身份)的会话一起 + * 归档。用 CLI 运行目录那一套 identity 统一署名,会把别人的会话记到当前用户 + * 名下,也让 `--author` 过滤失真。取不到时回退到运行目录的 identity。 + */ +function authorForCwd(cwd: string | undefined, fallback: string): string { + if (!cwd || !path.isAbsolute(cwd)) return fallback; + try { + const author = getGitAuthor(cwd); + return author && author !== 'unknown' ? author : fallback; + } catch { + return fallback; + } +} + /** * 解析当前 cwd 的 repoIdentity(git remote canonical)。 * 非 git 目录返回 null(降级到 _unattributed),不报错。 @@ -188,15 +219,19 @@ function resolveRepoRoot(repoRoot?: string): string { * 远端失败的原因常常与数据无关(无 upstream、只读 HTTP 模式、网络), * 用堆栈炸掉会把一次成功的归档伪装成彻底失败,用户再跑一次还会造出重复提交。 */ -function pushToRemote(syncMgr: SyncManager): void { +function pushToRemote(syncMgr: SyncManager): boolean { try { syncMgr.gitPush(); + return true; } 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}`); + const reason = safeText(fatal?.trim() ?? msg.split('\n')[0]); + // The local commit is kept, so nothing is lost -- but this is still a + // failure and must not be printed as "✓ Pushed". + console.error(` ✗ Remote push failed (local commit kept): ${reason}`); + return false; } } @@ -205,7 +240,7 @@ function pushToRemote(syncMgr: SyncManager): void { * 竞态等)给出单行英文错误并 exit 1,而不是让 execFileSync 的异常以裸 * stack trace 打到用户面(内部路径泄漏 + 伪造的崩溃感)。 */ -function runGitStep(step: () => string | null, repoRoot: string, what: string): string | null { +function runGitStep(step: () => T, repoRoot: string, what: string): T { try { return step(); } catch (err) { @@ -280,6 +315,27 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { target = await promptSelect('Select target platform:', others); } + // 同平台迁移会复用原生会话 ID,写回源工作区时直接覆盖原会话, + // 之后的 rollback 再把原件删掉——不可恢复。必须显式指定目标工作区 + // (--target-cwd)或明确 -y 才放行。 + if (source === target && !opts.targetCwd) { + const confirmed = + opts.yes || + (process.stdin.isTTY && + /^y(es)?$/i.test( + ( + await ask( + `Migrating ${source} → ${target} reuses the native session id and overwrites the source transcript. Continue? (y/N): `, + ) + ).trim(), + )); + if (!confirmed) { + console.error('Refusing to overwrite the source session.'); + console.error('Pass --target-cwd to write a copy elsewhere, or -y to confirm in place.'); + process.exit(1); + } + } + let workCwd = opts.cwd ?? process.cwd(); const sourceAdapter = safeGetAdapter(source); let metas = await sourceAdapter.listConversations(workCwd); @@ -336,8 +392,8 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { if (targets.length > BATCH_CONFIRM_THRESHOLD && !opts.yes) { console.log(`\nAbout to migrate ${targets.length} session(s) from ${source}:`); for (const m of targets) { - 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 title = safeText(m.title); + console.log(` ${m.sessionId.slice(0, 8)} ${title.length > 50 ? title.slice(0, 50) + '...' : title} (${m.messageCount} msgs)`); } const ans = await ask('\nMigrate all of the above? (y/N): '); if (ans.toLowerCase() !== 'y' && ans.toLowerCase() !== 'yes') { @@ -346,19 +402,33 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { } } } else if (sessionId) { - targets = metas.filter((m) => m.sessionId === sessionId || m.sessionId.startsWith(sessionId)); - if (targets.length === 0) { + const matches = metas.filter( + (m) => m.sessionId === sessionId || m.sessionId.startsWith(sessionId), + ); + if (matches.length === 0) { console.error(`Session not found: ${sessionId}`); process.exit(1); } + // Ambiguous prefix: taking every match silently migrates sessions the + // user never named (and rollback then has to undo all of them). + if (matches.length > 1) { + console.error(`Ambiguous session id "${sessionId}": matches ${matches.length} sessions.`); + for (const m of matches.slice(0, 10)) { + console.error(` ${m.sessionId} ${safeText(m.title).slice(0, 60)}`); + } + console.error('Pass more characters of the id, or use --all / no argument to pick from a list.'); + process.exit(1); + } + targets = matches; } 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 title = safeText(m.title); + const titleShown = title.length > 50 ? title.slice(0, 50) + '...' : title; + console.log(` [${i + 1}] ${m.sessionId.slice(0, 8)} ${titleShown} (${m.messageCount} msgs, ${formatBytes(m.sizeBytes)})`); } const ans = await ask('\nSelect session (number) or Enter to cancel: '); const num = parseInt(ans, 10); @@ -383,7 +453,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { console.log(` ─────────────────────────────────`); console.log(` Source: ${preview.sourcePlatform}`); console.log(` Target: ${preview.targetPlatform}`); - console.log(` Session: ${preview.sessionTitle} (${preview.sessionId.slice(0, 8)}...)`); + console.log(` Session: ${safeText(preview.sessionTitle)} (${preview.sessionId.slice(0, 8)}...)`); console.log(` CWD: ${preview.cwd}`); // 目标工作区默认保持源会话的工作区,只有 --target-cwd 才搬走 console.log(` Target CWD:${opts.targetCwd ? ' ' + opts.targetCwd : ' (same as source)'}`); @@ -463,7 +533,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { const meta = defaultSyncMeta( { platform: target, - author, + author: authorForCwd(session.cwd ?? t.cwd, author), cwd: session.cwd || opts.targetCwd || workCwd, sessionId: t.sessionId, repoIdentity: deriveArchiveIdentity(session, target), @@ -479,15 +549,26 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { saved++; } // [已修] gitCommit 失败(非 git 目录 / index.lock 竞态)此前裸堆栈崩溃 - const commitHash = runGitStep( + const commit = 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)}`); + if (commit.status === 'committed') { + const pushed = pushToRemote(syncMgr); + if (pushed) { + console.log(`\n ✓ Pushed ${saved} session(s) to team repo`); + } else { + console.log(`\n · ${saved} session(s) committed locally but not pushed`); + process.exitCode = 1; + } + console.log(` commit: ${commit.commit.slice(0, 8)}`); + } else if (commit.status === 'failed') { + // Hooks / gpg / missing identity: the archive files are still + // staged. Say so, and fail -- "No changes to push" here would hide + // a broken archive. + console.error(`\n ✗ Commit failed, ${saved} session(s) left staged in ${repoRoot}: ${safeText(commit.reason)}`); + process.exitCode = 1; } else { console.log(`\n · No changes to push\n`); } @@ -556,7 +637,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { if (isDryRun()) { console.log(`\nDry-run: would archive ${selected.length} session(s) from ${source}:`); for (const m of selected) { - console.log(` ${m.sessionId.slice(0, 8)} ${m.title.slice(0, 50)} (${m.messageCount} msgs)`); + console.log(` ${m.sessionId.slice(0, 8)} ${safeText(m.title).slice(0, 50)} (${m.messageCount} msgs)`); } console.log(` Repo root: ${repoRoot}`); return; @@ -566,8 +647,8 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { 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 title = safeText(m.title); + console.log(` ${m.sessionId.slice(0, 8)} ${title.length > 50 ? title.slice(0, 50) + '...' : title} (${m.messageCount} msgs)`); } const ans = await ask('\nPush all of the above? (y/N): '); if (ans.toLowerCase() !== 'y' && ans.toLowerCase() !== 'yes') { @@ -576,6 +657,24 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { } } + // 归档的是完整原文、团队可读:交互场景下先征得同意再落盘。 + // 写完之后再提醒等于马后炮——文件已经进仓库、commit 已经建好了。 + // 非交互(脚本/CI)没有 TTY,保持原行为:只警告,不阻断。 + if (!opts.scrub && !opts.yes && process.stdin.isTTY) { + console.log( + `\n ⚠ About to archive ${selected.length} unredacted session(s) from ${source}.`, + ); + console.log( + ' Archived sessions are team-readable: full prompts, tool output, file paths and any secret in them.', + ); + console.log(' Re-run with --scrub to redact secret-shaped values first.'); + const ans = await ask('Archive as-is? (y/N): '); + if (!/^y(es)?$/i.test(ans.trim())) { + console.log('Cancelled. Re-run with --scrub to archive a redacted copy.'); + return; + } + } + const syncMgr = new SyncManager(repoRoot); let saved = 0; let redactedTotal = 0; @@ -588,13 +687,14 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { const session = scrubbed ? scrubbed.session : readSession; if (scrubbed) redactedTotal += scrubbed.redactedCount; if (opts.all) { - console.log(` Source: ${session.cwd || 'unknown directory'}`); + console.log(` Source: ${safeText(session.cwd || 'unknown directory')}`); } // P3:归档键按会话原生 cwd 派生(见 deriveArchiveIdentity),而非 CLI 运行目录 const meta = defaultSyncMeta( { platform: source, - author, + // 每个会话按自己工作区的 git identity 署名(--all 跨工作区时尤其重要) + author: authorForCwd(session.cwd, author), cwd: session.cwd || workCwd, sessionId: m.sessionId, repoIdentity: deriveArchiveIdentity(session, source), @@ -613,15 +713,25 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { ); } // [已修] gitCommit 失败(非 git 目录 / index.lock 竞态)此前裸堆栈崩溃 - const commitHash = runGitStep( + const commit = 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`); + if (commit.status === 'committed') { + const pushed = pushToRemote(syncMgr); + if (pushed) { + console.log(`\n ✓ Pushed ${saved} session(s) from ${source}`); + } else { + console.log(`\n · ${saved} session(s) committed locally but not pushed`); + process.exitCode = 1; + } + console.log(` commit: ${commit.commit.slice(0, 8)}\n`); + } else if (commit.status === 'failed') { + console.error( + `\n ✗ Commit failed, ${saved} session(s) left staged in ${repoRoot}: ${safeText(commit.reason)}`, + ); + process.exitCode = 1; } else { console.log(`\n · No changes to push\n`); } @@ -652,13 +762,10 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { 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`); + // P2:按目录重建(含索引丢失/损坏的仓库——那才是最需要修复的对象), + // 而不是只重建能从 _index.json 反查出 identity 的那些。 + const { repos, sessions } = syncMgr.rebuildAllIndexes(); + console.log(`\n ✓ Pulled and indexed ${sessions} session(s) across ${repos} repo(s)\n`); } else { const repoIdentity = resolveRepoIdentity(workCwd); const count = syncMgr.rebuildIndex(repoIdentity); @@ -695,13 +802,14 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { 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 name = safeText(s.sessionName); + const nameCol = name.length > 36 ? name.slice(0, 34) + '..' : name.padEnd(36); + const authorCol = safeText(s.author).slice(0, 12).padEnd(12); + const platCol = safeText(s.platform).slice(0, 16).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}`); + const source = safeText(s.repoIdentity ?? '_unattributed'); + console.log(` ${nameCol} ${authorCol}${platCol}${msgCol} ${dateCol} ${source}`); } } else { const repoLabel = repoIdentity ?? '_unattributed'; @@ -709,12 +817,13 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { 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 name = safeText(s.sessionName); + const nameCol = name.length > 36 ? name.slice(0, 34) + '..' : name.padEnd(36); + const authorCol = safeText(s.author).slice(0, 12).padEnd(12); + const platCol = safeText(s.platform).slice(0, 16).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(` ${nameCol} ${authorCol}${platCol}${msgCol} ${dateCol}`); } } console.log(`\n ${sessions.length} session(s)\n`); @@ -820,10 +929,11 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { 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})`); + const snippet = safeText(hit.snippet); + const snippetShown = snippet.length > 150 ? snippet.slice(0, 150) + '...' : snippet; + console.log(` [${i + 1}] ${safeText(hit.sessionName)} (${safeText(hit.author)}, ${date})`); console.log(` Score: ${hit.score.toFixed(1)}`); - console.log(` ${snippet}`); + console.log(` ${snippetShown}`); console.log(''); } console.log(` ${results.length} result(s) found`); diff --git a/src/session-flow/sync.ts b/src/session-flow/sync.ts index 985230f7a..b7114ba1b 100644 --- a/src/session-flow/sync.ts +++ b/src/session-flow/sync.ts @@ -89,14 +89,23 @@ export function getRepoIdentity(cwd?: string): string | null { } /** - * 归一化 git remote URL → `host/owner/repo`(去协议、去 .git 后缀)。 + * Normalize a git remote URL to `host/owner/repo` (no scheme, no .git suffix, + * no credentials). + * + * Credentials are stripped deliberately: remotes of the form + * `https://oauth2:TOKEN@host/org/repo.git` (CI checkouts, token-authenticated + * clones) are common, and keeping the userinfo would write the token into + * archive metadata, indexes and `list --all` output -- i.e. publish it to the + * whole team. */ 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/'); + // Drop userinfo: oauth2:TOKEN@host/... and git@host (both scp and ssh:// forms). + s = s.replace(/^[^/@]+@/, ''); + // git@github.com:org/repo → github.com/org/repo (scp-style colon separator) + s = s.replace(/^([^/:]+):(?!\d+(?:\/|$))/, '$1/'); // 去前导 / s = s.replace(/^\/+/, ''); return s; @@ -105,21 +114,26 @@ export function canonicalizeRemote(remote: string): string { /** * Encode a canonical remote into a directory-safe string, reversibly. * - * `_` is escaped to `__` first, then every other non-whitelisted char - * (including `/`) becomes `_`. Without the escape step, `github.com/org/a_b` - * and `github.com/org/a/b` would encode to the same directory and mix two - * repositories' sessions together. + * Every character outside `[A-Za-z0-9._-]` becomes `%XX` (uppercase hex), so + * the mapping is injective: `github.com/org/a/b`, `github.com/org/a_b` and + * `github.com/org/a:b` all get their own directory. The previous scheme folded + * every separator onto `_`, which merged distinct repositories into one + * archive directory and one index. * * Reversible via decodeRepoIdentity. */ export function encodeRepoIdentity(identity: string): string { - const escaped = identity.replace(/_/g, '__'); - return escaped.replace(/[^a-zA-Z0-9.-]/g, '_'); + return identity.replace( + /[^a-zA-Z0-9._-]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`, + ); } /** Undo encodeRepoIdentity (used for display; the canonical id stays in meta). */ export function decodeRepoIdentity(encoded: string): string { - return encoded.replace(/_(?!_)/g, '/').replace(/__/g, '_'); + return encoded.replace(/%([0-9A-F]{2})/g, (_, hex: string) => + String.fromCharCode(parseInt(hex, 16)), + ); } /** @@ -130,8 +144,11 @@ export function decodeRepoIdentity(encoded: string): string { */ function sanitizePathSegment(name: string): string { const cleaned = name + // `\` is a separator on Windows: an author name of `..\..\evil` would + // otherwise escape the author directory when the archive is checked out + // there (git author names are free-form). .replace(/[\u0000-\u001f<>:"|?*]/g, '_') - .replace(/\//g, '_') + .replace(/[/\\]/g, '_') .replace(/^\.+$|^\.\.$/g, '_') .replace(/[. ]+$/g, '_') .trim(); @@ -260,10 +277,52 @@ interface RepoIndex { sessions: IndexEntry[]; } +/** `gitCommit` 的三种结局(成功 / 无变更 / 失败),不可再合并成 null。 */ +export type GitCommitResult = + | { status: 'committed'; commit: string } + | { status: 'no-changes' } + | { status: 'failed'; reason: string }; + // --------------------------------------------------------------------------- // SyncManager // --------------------------------------------------------------------------- +/** + * 旧编码(`github.com_org_alpha`)的目录名解码:只用于索引丢失时的兜底, + * 因为旧规则把 `_` 一律当分隔符,本身有歧义(`a_b` / `a/b` 同码)。 + */ +function decodeLegacyRepoDirName(dirName: string): string { + return dirName.includes('%') ? decodeRepoIdentity(dirName) : dirName.replace(/_/g, '/'); +} + +/** + * 拒绝写入路径中经过 symlink 的目标。 + * + * `sessions/` 下的目录来自团队仓 checkout——仓库内容不由本机控制,而 git 会 + * 原样记录 symlink。若 `sessions/repos/x` 或某个 author 目录被换成指向 + * `~/.ssh` 的链接,下面的每次 writeFileSync 都会写穿链接落到仓库之外, + * 覆盖任意可写文件。写入前逐个祖先 lstat,命中 symlink 直接报错放弃。 + */ +function assertNoSymlinkedAncestors(target: string, root: string): void { + const rel = path.relative(root, target); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error(`Refusing to write outside the archive root: ${target}`); + } + let cur = root; + for (const part of rel.split(path.sep)) { + cur = path.join(cur, part); + try { + if (fs.lstatSync(cur).isSymbolicLink()) { + throw new Error(`Refusing to write through a symlinked archive path: ${cur}`); + } + } catch (err) { + if (err instanceof Error && err.message.startsWith('Refusing')) throw err; + // ENOENT: this and every deeper component does not exist yet. + return; + } + } +} + /** * 管理团队仓 `sessions/` 目录下的完整会话存储。 * @@ -274,11 +333,24 @@ interface RepoIndex { */ export class SyncManager { private readonly sessionsDir: string; + /** + * 本次进程写入过的归档文件(相对 repoRoot)。 + * + * `gitCommit` 只 `git add` 这些路径——此前是 `git add sessions/`,会把用户在 + * `sessions/` 下的其它既有改动(甚至删除)一起提交进去。 + */ + private readonly writtenPaths = new Set(); constructor(private readonly repoRoot: string) { this.sessionsDir = path.join(repoRoot, 'sessions'); } + /** 记录写入路径,并在写入前确认没有 symlink 劫持。 */ + private guardWrite(target: string): void { + assertNoSymlinkedAncestors(target, this.repoRoot); + this.writtenPaths.add(path.relative(this.repoRoot, target).split(path.sep).join('/')); + } + // ------------------------------------------------------------------ // 路径解析 // ------------------------------------------------------------------ @@ -330,7 +402,23 @@ export class SyncManager { 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'); + const target = this.indexPath(repoIdentity); + this.guardWrite(target); + // 原子写:先落临时文件再 rename。直接 writeFileSync 时,两个并发 push 会 + // 互相截断,留下半截(甚至空)的 _index.json——那会让整个仓库的会话 + // 看起来凭空消失。rename 在同一文件系统上是原子的,读者只会看到旧值或新值。 + const tmp = `${target}.${process.pid}.tmp`; + try { + fs.writeFileSync(tmp, JSON.stringify(index, null, 2), 'utf-8'); + fs.renameSync(tmp, target); + } catch (err) { + try { + fs.unlinkSync(tmp); + } catch { + // ignore + } + throw err; + } } private upsertIndexEntry(repoIdentity: string | null, entry: IndexEntry): void { @@ -418,6 +506,8 @@ export class SyncManager { } const paths = this.sessionPaths(repoId, author, sessionName); + this.guardWrite(paths.jsonl); + this.guardWrite(paths.meta); fs.mkdirSync(path.dirname(paths.jsonl), { recursive: true }); // 写 JSONL — 每条消息一行 @@ -487,7 +577,15 @@ export class SyncManager { createdAt: meta.origin.createdAt, updatedAt: utcNow(), messages, - metadata: { originator: meta.migration.sourcePlatform ?? undefined }, + metadata: { + originator: meta.migration.sourcePlatform ?? undefined, + // Archive content is untrusted input: image blocks may carry absolute + // `filePath` values planted by whoever pushed the archive. Adapters + // must never read a local file for an untrusted session (see + // mayReadLocalImageFile) or a crafted archive could pull ~/.ssh keys + // into the restored session. + untrusted: true, + }, }; return { session, meta }; @@ -497,15 +595,34 @@ export class SyncManager { 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}`); + const matches: string[] = []; for (const entry of fs.readdirSync(dir)) { if (entry.startsWith('_')) continue; const candidate = path.join(dir, entry); - if (!fs.statSync(candidate).isDirectory()) continue; + // Skip symlinked author dirs: they come from the team repo checkout and + // would make "which author owns this session" resolve outside the archive. + let st: fs.Stats; + try { + st = fs.lstatSync(candidate); + } catch { + continue; + } + if (!st.isDirectory() || st.isSymbolicLink()) continue; if (fs.existsSync(path.join(candidate, `${sessionName}.jsonl`))) { - return entry; + matches.push(entry); } } - throw new Error(`Session ${sessionName} not found (searched all author directories)`); + if (matches.length === 0) { + throw new Error(`Session ${sessionName} not found (searched all author directories)`); + } + // Ambiguous: picking the first match silently restores/resumes the wrong + // author's session. Make the caller disambiguate with --author. + if (matches.length > 1) { + throw new Error( + `Session ${sessionName} is ambiguous (authors: ${matches.join(', ')}). Re-run with --author .`, + ); + } + return matches[0]; } private extractTitleFromSessionName(sessionName: string): string { @@ -618,6 +735,9 @@ export class SyncManager { deleteSession(repoIdentity: string | null, sessionName: string, author?: string): void { const resolvedAuthor = author ?? this.findAuthor(repoIdentity, sessionName); const paths = this.sessionPaths(repoIdentity, resolvedAuthor, sessionName); + // Same symlink guard as writes: deleting through a checkout-controlled + // symlink would remove files outside the repository. + assertNoSymlinkedAncestors(paths.jsonl, this.repoRoot); if (fs.existsSync(paths.jsonl)) fs.unlinkSync(paths.jsonl); if (fs.existsSync(paths.meta)) fs.unlinkSync(paths.meta); @@ -641,7 +761,8 @@ export class SyncManager { for (const authorName of fs.readdirSync(dir)) { if (authorName.startsWith('_')) continue; const authorDir = path.join(dir, authorName); - if (!fs.statSync(authorDir).isDirectory()) continue; + const authorSt = fs.lstatSync(authorDir); + if (!authorSt.isDirectory() || authorSt.isSymbolicLink()) continue; for (const file of fs.readdirSync(authorDir)) { if (!file.endsWith('.meta.json')) continue; @@ -657,9 +778,13 @@ export class SyncManager { entries.push({ sessionName, - author: authorName, + // The directory name is the sanitized author (`a:b` → `a_b`); the + // canonical identity lives in meta. Rebuilding from the directory + // name silently rewrote every author and broke --author filtering. + author: meta.origin.author || authorName, platform: meta.origin.platform, - title: this.extractTitleFromSessionName(sessionName), + // Same for the title: the file name is a truncated, lowercased slug. + title: meta.origin.title || this.extractTitleFromSessionName(sessionName), cwd: meta.origin.cwd, repoIdentity: meta.origin.repoIdentity, messageCount: msgCount, @@ -684,6 +809,56 @@ export class SyncManager { return entries.length; } + /** + * 重建团队仓里**所有**仓库目录的索引,包括索引丢失/损坏的目录。 + * + * listAllRepoIdentities() 依赖 `_index.json` 反查 canonical identity, + * 索引没了的仓库会被直接跳过——可它们恰恰是最需要 `pull --all` 修复的对象。 + * 所以这里按目录遍历:identity 优先取索引原文,取不到再从目录名解码兜底。 + */ + rebuildAllIndexes(): { repos: number; sessions: number } { + const identities: Array = []; + const seen = new Set(); + + 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.lstatSync(full).isDirectory()) continue; + } catch { + continue; + } + const identity = this.identityOfRepoDir(full) ?? decodeLegacyRepoDirName(dir); + const key = identity ?? '_unattributed'; + if (seen.has(key)) continue; + seen.add(key); + identities.push(identity); + } + } + + const unattrDir = path.join(this.sessionsDir, '_unattributed'); + if (fs.existsSync(unattrDir) && !seen.has('_unattributed')) identities.push(null); + + let sessions = 0; + for (const identity of identities) { + sessions += this.rebuildIndex(identity); + } + return { repos: identities.length, sessions }; + } + + /** 目录索引里的 canonical identity;无索引/损坏时返回 null。 */ + private identityOfRepoDir(dir: string): string | null { + try { + const idxPath = path.join(dir, '_index.json'); + if (!fs.existsSync(idxPath)) return null; + const index = JSON.parse(fs.readFileSync(idxPath, 'utf-8')) as RepoIndex; + return index.repoIdentity ?? null; + } catch { + return null; + } + } + // ------------------------------------------------------------------ // Git 操作 // ------------------------------------------------------------------ @@ -703,34 +878,40 @@ export class SyncManager { } /** - * git add sessions/ && git commit → 返回 commit hash。 + * 提交本次写入的归档文件。 * - * 无变更时 commit 静默失败,而 `rev-parse HEAD` 仍会返回旧 HEAD——调用方会 - * 误报 "Pushed N"。因此 commit 前先用 `status --porcelain -- sessions/` - * 检测暂存区是否有变更,无变更返回 null,由调用方打印 "No changes to push"。 + * 三种结果必须区分开:成功提交 / 无变更 / 提交失败。此前三者统一返回 null, + * 于是 gpg 签名失败、pre-commit hook 拒绝、git identity 缺失都被当成 + * “没有要提交的东西”,界面上只显示一行无害的 “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; + gitCommit(message: string): GitCommitResult { + const paths = [...this.writtenPaths]; + // 没有本次写入的路径时退回目录级 add(例如索引重建后的提交), + // 但仍然只在 sessions/ 内操作。 + const addArgs = paths.length > 0 ? ['add', '--', ...paths] : ['add', 'sessions/']; + this.runGit(addArgs); + + const scopeArgs = paths.length > 0 ? ['--', ...paths] : ['--', 'sessions/']; + const staged = this.runGit(['status', '--porcelain', ...scopeArgs], false); + if (!staged.trim()) return { status: 'no-changes' }; // Only ever commit the archive paths: `git commit -m` without a pathspec // would also commit whatever else the user happened to have staged. - // - // Let a failed commit throw (hooks, gpg signing, missing identity all exit - // non-zero) and surface as null: silence here used to be reported as a - // successful push with the previous HEAD printed as the new commit. try { - this.runGit(['commit', '-m', message, '--', 'sessions/'], true); - } catch { - return null; + this.runGit(['commit', '-m', message, ...scopeArgs], true); + } catch (err) { + const reason = err instanceof Error ? err.message.split('\n')[0] : String(err); + return { status: 'failed', reason }; } - return this.runGit(['rev-parse', 'HEAD']); + return { status: 'committed', commit: this.runGit(['rev-parse', 'HEAD']) }; } gitPush(remote = 'origin', branch?: string): void { const args = ['push', remote]; if (branch) args.push(branch); + // Errors must reach the caller: swallowing them here let `push` print + // "✓ Pushed" after a rejected or unreachable remote. this.runGit(args); } From 2f4cfbbae1722d4413f4817c6a05c6a3ab5e1290 Mon Sep 17 00:00:00 2001 From: lurkacai Date: Thu, 24 Sep 2026 12:51:49 +0800 Subject: [PATCH 22/28] docs(session): changelog, product overview row and regenerated command reference - CHANGELOG entries for the archive trust boundary, git reporting and rollback fixes. - The README capability table moved to docs/product-overview(.zh-CN).md on main, so the Session Sync row lands there instead. - usage-guide.zh-CN: --all migrates every session, matching the English guide and the implementation. - Regenerate skill-data/core/references/commands.md for the session subcommands. --- CHANGELOG.md | 10 ++++++ docs/product-overview.md | 1 + docs/product-overview.zh-CN.md | 1 + docs/usage-guide.zh-CN.md | 2 +- skill-data/core/references/commands.md | 44 +++++++++++++++++++++++++- 5 files changed, 56 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 218edd927..f576f0474 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,16 @@ All notable changes to this project will be documented in this file. See [standa - `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. +- Sessions restored from a team archive no longer read local files. An archived image block names a `filePath`, and restoring it used to read that path and embed the bytes, so a crafted archive could pull any readable file (an SSH key, a credential file) into the restored session. Restored sessions are marked untrusted and only inline image data is written; local reads additionally require an absolute path with an image extension. CodeBuddy IDE asset and message file names are reduced to a bare file name, so an archive-controlled label or message id can no longer write outside `assets/` or `messages/`. +- Archive writes refuse a path whose existing ancestor is a symlink. Team-repo checkouts carry symlinks, and `sessions/repos/` or an author directory pointing elsewhere made every write (or delete) land outside the repository, overwriting arbitrary writable files. +- `session push` / `migrate --push` strip credentials from the git remote before archiving it. `https://oauth2:TOKEN@host/org/repo.git` (CI checkouts) kept the token in archive metadata, indexes and `list --all` output, publishing it to the team. `ssh://git@host/org/repo.git` and `git@host:org/repo.git` now canonicalize to the same identity as the https form. +- Repository directory names are encoded injectively (`%XX` instead of folding every separator onto `_`). `github.com/org/a/b`, `github.com/org/a_b` and `github.com/org/a:b` previously shared one directory and one index, mixing two repositories' sessions; author directories also escape `\`, the separator Windows uses. +- A failed `git commit` is no longer reported as "No changes to push". Hook, signing, identity and permission failures all exit non-zero and were swallowed as `null`, so the archive stayed staged while the command printed a clean-tree message. `session push` now stages and commits only the files it wrote, instead of `git add sessions/` picking up every pre-existing change under that directory, and a failed remote push exits 1 instead of printing "✓ Pushed". +- `session rollback` reports the truth. Every adapter now returns whether anything was deleted, `rollback --cwd ` no longer falls back to a global delete when the directory cannot be resolved (that removed another workspace's copy of the same session), and Codex / Cursor / WorkBuddy unregister their global index entry only after confirming the scoped transcript exists. +- `session migrate` refuses to overwrite the source without consent: same-platform migration reuses the native session id, so writing into the source workspace replaced the original — which `rollback` could then delete. Pass `--target-cwd` or `-y`. An ambiguous session-id prefix is rejected (it used to migrate every match), and an ambiguous archive name now asks for `--author` instead of picking one. +- `session pull --all` rebuilds indexes by scanning archive directories, so repositories whose `_index.json` is missing or corrupt are repaired instead of skipped — they were exactly the ones needing repair. Rebuilt entries keep `meta.origin.title` and `meta.origin.author` instead of the truncated file-name slug and the sanitized directory name, which used to degrade every title and break `--author` filtering. +- `session push --all` attributes each session to the git identity of its own workspace instead of one identity resolved from the command's directory, and `session list` / `search` output strips terminal control sequences from archived titles, authors, repository identities and snippets. +- Cursor's temporary SQL files (which contain the full conversation text) are created with `0600` in shared temporary storage instead of the default umask. - `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)). diff --git a/docs/product-overview.md b/docs/product-overview.md index ccd2a6a9a..ba2a2350e 100644 --- a/docs/product-overview.md +++ b/docs/product-overview.md @@ -158,5 +158,6 @@ 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` | Unified Overview / Team Execution / Team Context / Team Improvement views with local live sessions, 7-day trends, estimated cost per session, English/Chinese, and light/dark/system themes. | | **KB Health** | `teamai dashboard` → Team Context / Team Improvement | Coverage by type, top-recalled and silent entries, last-recall month distribution, author contributions, and maintenance; the full `/kb-report` remains available. | diff --git a/docs/product-overview.zh-CN.md b/docs/product-overview.zh-CN.md index c09f9f49c..accf994f6 100644 --- a/docs/product-overview.zh-CN.md +++ b/docs/product-overview.zh-CN.md @@ -158,5 +158,6 @@ 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` | 统一的 Overview / Team Execution / Team Context / Team Improvement 界面,保留本机实时会话、近 7 天趋势、每会话估算费用,支持中英文及日间/夜间/跟随系统主题。 | | **知识库健康(KB Health)** | `teamai dashboard` → Team Context / Team Improvement | 保留各类型覆盖率、高频召回与沉默条目、最近召回月份统计、作者贡献及维护控制台;完整 `/kb-report` 报告仍可访问。 | diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index b95ec910b..dbe76ca40 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1618,7 +1618,7 @@ teamai session save --push --include-prompt # 额外带上(脱敏后的)首 ```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 migrate --all -s codebuddy -t claude-code # 该源的全部会话(--limit 可限量) teamai session rollback --platform claude-code # 撤销一次迁移 teamai session push --source codebuddy # 归档当前目录的会话 teamai session push --source codebuddy --all # 该平台的全部工作区 diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 7e8bbe9ba..e2fce9cb9 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -260,13 +260,55 @@ Generated: do not edit by hand. Regenerate with ## session -- `teamai session` — Record and inspect coding-session summaries +- `teamai session` — Session recording, cross-platform migration, and team sync - `teamai session save` — Record a privacy-scrubbed summary of a coding session to a local monthly log - `--session-id ` — Session to record (default: the agent's session, e.g. $CLAUDE_CODE_SESSION_ID, or the most recent) - `--push` — Also push the summary to the team repo (feeds `teamai digest`) - `--force` — Push even if the session is not flagged as valuable - `--include-prompt` — Include the redacted first-prompt line in the pushed summary (default: off) - `--scope ` — Config scope for --push: user | project (default: auto-detect) + - `teamai session platforms` — List supported and installed AI agent platforms + - `teamai session migrate [sessionId]` — Migrate a session from one platform to another (or archive to same platform) + - `-s, --source ` — Source platform (e.g. claude-code, codebuddy) + - `-t, --target ` — Target platform + - `--cwd ` — Working directory (defaults to current directory) + - `--target-cwd ` — Override cwd for the target session + - `--push` — Also push the migrated session to the team repo + - `--repo-root ` — Team repo root (for --push) + - `--scrub` — Redact secrets (tokens/keys/passwords) from the session before writing it + - `--all` — Migrate every session from source (not just the 5 most recent) + - `--limit ` — Max sessions to migrate (only caps --all; ignored otherwise) + - `-y, --yes` — Skip confirmation prompt + - `teamai session push` — Push local sessions to the team repo + - `--source ` — Source platform to read sessions from + - `--repo-root ` — Team repo root (defaults to cwd) + - `--cwd ` — Working directory (defaults to current directory) + - `--limit ` — Max sessions to push (default: 5; ignored with --all) + - `--all` — Push every session of the platform across all workspace directories (ignores --limit) + - `-y, --yes` — Skip the confirmation prompt for large batches (--all) + - `--scrub` — Redact secrets before archiving (archived sessions are team-readable) + - `teamai session pull` — Pull team sessions for the current project + - `--repo-root ` — Team repo root (defaults to cwd) + - `--cwd ` — Working directory (defaults to current directory) + - `--all` — Rebuild indexes for every repo in the team repo (not just the current project) + - `teamai session list` — List team sessions for the current project + - `--repo-root ` — Team repo root (defaults to cwd) + - `--cwd ` — Working directory (defaults to current directory) + - `--author ` — Filter by author + - `--all` — List sessions across all projects in the team repo (not just the current one) + - `teamai session resume ` — Restore a team session to a local platform + - `--platform ` — Target platform to restore into + - `--repo-root ` — Team repo root (defaults to cwd) + - `--cwd ` — Working directory for the restored session (defaults to current directory) + - `--author ` — Author of the session (if ambiguous) + - `teamai session search ` — Search team session content + - `--repo-root ` — Team repo root (defaults to cwd) + - `--cwd ` — Working directory (defaults to current directory) + - `--limit ` — Max results (default: 10) + - `--all` — Search across all projects (not just current) + - `teamai session rollback ` — Rollback a migration (delete the target session) + - `--platform ` — Platform where the session was written + - `--cwd ` — Only roll back the copy under this project path (default: all) ## digest From 092f02666638038a6b88ce39ec86ed653ee0a05f Mon Sep 17 00:00:00 2001 From: lurkacai Date: Thu, 24 Sep 2026 16:28:13 +0800 Subject: [PATCH 23/28] =?UTF-8?q?fix(session):=20review=20follow-up=20?= =?UTF-8?q?=E2=80=94=20index=20repair,=20id=20derivation,=20temp=20SQL,=20?= =?UTF-8?q?image=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Team review of #593 found two blocking issues and a set of smaller ones: - A corrupted _index.json was read as empty, so the next upsert overwrote it with the single new entry and every other session of that repo disappeared from list (files still on disk). Write paths -- including the dedup lookup, which otherwise wrote an extra _1 copy first -- now rebuild from disk and stop the push if that fails. rebuildAllIndexes moves a legacy '_'-folded directory onto the encoded name, so reads (which resolve that name) see its sessions again instead of an index nothing reads. - deriveTargetSessionId hashes the resolved cwd, Codex rollback --cwd compares rollout cwd after resolving, and Cursor's composerId does the same: /tmp/x and /private/tmp/x are one workspace on macOS, and the mismatch made rollback report 'not found' and delete nothing, or registered two Cursor entries for one session. claude-code and codebuddy keep the cwd out of the hash on purpose (per-project storage); the comment now says why. - canonicalizeRemote also strips a password that contains '/', which the old rule could not match and which therefore reached meta, _index.json and the SOURCE column of list --all. - Cursor/WorkBuddy SQL scripts are piped to sqlite3 instead of written to a predictable temp file in shared /tmp (symlink-plantable, world-listed). - Identity encoding is byte-wise (non-ASCII round-trips), git hosts are lower-cased, the symlink guard runs before mkdir, and an archived session name must be a plain file name before it reaches the filesystem. - migrate --scrub keeps an image block's local filePath (only archiving drops it) and runs before the thinking-block downgrade, so fidelity is computed from the scrubbed but not-yet-downgraded session: blocks lost to scrub count as degraded, and thinking blocks still count, which keeps the score and the degradedBlocks warning in line with --dry-run's preview. - rollback without --cwd deletes every workspace copy, as its help promises, instead of only the first match. Confirmations use isInteractive(); migrate --all refuses with a non-zero exit in a non-interactive run instead of printing Cancelled. and exiting 0; rollback failures print one English line. Tests: 75 passing across the session suites, including new regressions for corrupted-index repair, legacy directory repair (now asserted through listSessions), credential stripping with a slash in the password, image embedding on migration (and its untrusted counterpart), one Cursor id per workspace, failed commit and failed remote push (both exit non-zero). Known limitation left alone: two concurrent pushes can lose one index upsert; rename keeps the file itself intact and 'pull --all' rebuilds from disk. --- CHANGELOG.md | 10 +- docs/usage-guide.md | 4 +- docs/usage-guide.zh-CN.md | 2 + .../migrate-image-and-cursor-id.test.ts | 118 +++++++++++++ src/__tests__/scrub-session.test.ts | 12 ++ src/__tests__/session-cmd.test.ts | 47 +++++- src/__tests__/session-sync.test.ts | 55 ++++++ src/session-flow/adapters/claude-code.ts | 49 ++++-- src/session-flow/adapters/codebuddy.ts | 2 + src/session-flow/adapters/codex.ts | 7 +- src/session-flow/adapters/cursor.ts | 6 +- src/session-flow/cursor-store.ts | 26 +-- src/session-flow/ids.ts | 19 ++- src/session-flow/migrate.ts | 17 +- src/session-flow/scrub.ts | 22 ++- src/session-flow/session-cmd.ts | 47 +++++- src/session-flow/sync.ts | 158 ++++++++++++++---- src/session-flow/workbuddy-store.ts | 13 +- 18 files changed, 516 insertions(+), 98 deletions(-) create mode 100644 src/__tests__/migrate-image-and-cursor-id.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f576f0474..e1444d7e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,10 +102,16 @@ All notable changes to this project will be documented in this file. See [standa - Repository directory names are encoded injectively (`%XX` instead of folding every separator onto `_`). `github.com/org/a/b`, `github.com/org/a_b` and `github.com/org/a:b` previously shared one directory and one index, mixing two repositories' sessions; author directories also escape `\`, the separator Windows uses. - A failed `git commit` is no longer reported as "No changes to push". Hook, signing, identity and permission failures all exit non-zero and were swallowed as `null`, so the archive stayed staged while the command printed a clean-tree message. `session push` now stages and commits only the files it wrote, instead of `git add sessions/` picking up every pre-existing change under that directory, and a failed remote push exits 1 instead of printing "✓ Pushed". - `session rollback` reports the truth. Every adapter now returns whether anything was deleted, `rollback --cwd ` no longer falls back to a global delete when the directory cannot be resolved (that removed another workspace's copy of the same session), and Codex / Cursor / WorkBuddy unregister their global index entry only after confirming the scoped transcript exists. -- `session migrate` refuses to overwrite the source without consent: same-platform migration reuses the native session id, so writing into the source workspace replaced the original — which `rollback` could then delete. Pass `--target-cwd` or `-y`. An ambiguous session-id prefix is rejected (it used to migrate every match), and an ambiguous archive name now asks for `--author` instead of picking one. +- `session migrate` refuses to overwrite the source without consent: same-platform migration reuses the native session id, so writing into the source workspace replaced the original — which `rollback` could then delete. Pass `--target-cwd` or `-y`. An ambiguous session-id prefix is rejected (it used to migrate every match), and an ambiguous archive name now asks for `--author` instead of picking one. `migrate --all` asks for confirmation above 10 sessions and, in a non-interactive run, refuses with a non-zero exit instead of printing `Cancelled.` and exiting 0 — a script used to read that as "every session was migrated". Confirmations now use the CLI's `isInteractive()` rule (stdin TTY and not `CI` / `TEAMAI_NONINTERACTIVE`) rather than `process.stdin.isTTY`, so a pseudo-terminal nobody is reading no longer blocks on a prompt. - `session pull --all` rebuilds indexes by scanning archive directories, so repositories whose `_index.json` is missing or corrupt are repaired instead of skipped — they were exactly the ones needing repair. Rebuilt entries keep `meta.origin.title` and `meta.origin.author` instead of the truncated file-name slug and the sanitized directory name, which used to degrade every title and break `--author` filtering. - `session push --all` attributes each session to the git identity of its own workspace instead of one identity resolved from the command's directory, and `session list` / `search` output strips terminal control sequences from archived titles, authors, repository identities and snippets. -- Cursor's temporary SQL files (which contain the full conversation text) are created with `0600` in shared temporary storage instead of the default umask. +- Cursor's and WorkBuddy's SQL scripts (which contain the full conversation text) are piped straight to `sqlite3` instead of being written to shared temporary storage, where the file name was predictable and a symlink planted there would have been written through. +- A corrupted `sessions/**/_index.json` is rebuilt from disk before the next entry is written, instead of being read as empty and overwritten with just the new session — which removed every other session of that repository from `list` while its files stayed on disk. If the index cannot be rebuilt, `push` stops with an error instead of committing a partial archive. Repositories whose index is missing are rebuilt in the directory that was scanned, so a legacy `_`-folded directory name no longer loses its index to the new encoding. +- Repository identities are encoded byte-wise (`encodeURIComponent`), so a non-ASCII identity survives the round trip; it previously came back as a mangled prefix. Git hosts are lower-cased, so `GitHub.com/Org/Repo` and `github.com/org/repo` are one repository instead of two archive directories. +- Derived target session ids hash the resolved cwd (`/tmp/x` and `/private/tmp/x` are one workspace on macOS), so a re-migration overwrites instead of adding a second copy. Only `claude-code` and `codebuddy` keep the cwd out of the hash, since they store one file per project directory; for `codex` / `cursor` / `workbuddy`, whose records are keyed globally, a session migrated before this change gets a different id when re-migrated and the earlier copy stays behind. +- `session rollback` without `--cwd` deletes every workspace's copy of the session, as its help promises, instead of only the first one `findSessionFile` happened to return. +- `session rollback --cwd` compares Codex rollout paths after resolving them, so it deletes the copy in the workspace you named instead of reporting "not found" and leaving it. +- `migrate --scrub` keeps an image block's local file path (the target can still read it); only archiving to the team repo drops it. Fidelity is now computed from what is actually written, so degraded blocks no longer count as preserved. - `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)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index d64e849da..5687e29a3 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1728,7 +1728,7 @@ Supported platforms: `claude-code` (plus `claude-internal` / `tclaude`), `codex` ```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 # every session of the source +teamai session migrate --all -s codebuddy -t claude-code # every session of the source (--limit to cap) 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 @@ -1741,6 +1741,8 @@ teamai session resume --platform claude-code # restore into a lo 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. +**Confirmations.** `--all` asks for confirmation once it passes 10 sessions (push: more than 5) and refuses outright in a non-interactive run, so a script cannot silently skip the batch — pass `-y` to confirm. Migrating a platform onto itself reuses the native session id and overwrites the source transcript, so it needs `--target-cwd ` (write a copy elsewhere) or `-y`. `push` also asks before archiving unredacted transcripts; `--scrub` redacts them first. + **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. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index dbe76ca40..1122f6d68 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1631,6 +1631,8 @@ teamai session resume --platform claude-code # 恢复到本地 以上命令均支持 `--dry-run` 与 `-v`。`migrate --push` 一步完成迁移 + 归档;`resume` 会打印新的会话 id,用工具自身的 resume 参数继续。 +**确认提示。** `--all` 超过 10 条(push 为超过 5 条)会先列清单要求确认;非交互运行(CI / 脚本)直接拒绝并以非 0 退出,避免脚本以为整批都跑完了——用 `-y` 明确确认。同平台迁移会复用原生会话 id 覆盖源会话,必须给 `--target-cwd `(写到别处)或 `-y`。`push` 在归档未脱敏原文前也会先问一次,`--scrub` 可先脱敏。 + **归档布局。** 会话按其工作目录的 git 标识归档到团队仓库的 `sessions/repos///`;非 git 目录的会话落入 `_unattributed`。归档键取自会话自身的工作区——而不是执行命令时所在的目录——从别的目录迁入也会归到正确的项目名下。CodeBuddy IDE 中无法还原工作区的会话会带警告归入 `_unattributed`。 **项目级 vs 用户级仓库。** `list` / `pull` / `resume` 按当前目录的 git remote 过滤,项目级团队仓库因此只显示本项目的会话。传入 `--repo-root <任意 clone>`(例如个人仓库)并配合 `--all`,即可跨全部归档项目读取。 diff --git a/src/__tests__/migrate-image-and-cursor-id.test.ts b/src/__tests__/migrate-image-and-cursor-id.test.ts new file mode 100644 index 000000000..4ea30e2bc --- /dev/null +++ b/src/__tests__/migrate-image-and-cursor-id.test.ts @@ -0,0 +1,118 @@ +/** + * migrate-image-and-cursor-id.test.ts — 迁移路径上的两个细节。 + * + * 1. 本地→本地迁移(含 --scrub)时,只有 filePath、没有内联 data 的图片块要 + * 被读出来嵌入目标端;只有来自团队归档的会话才不许读本地文件。 + * 2. Cursor 的 composerId 派生要认同一个工作区的两种拼写(/tmp 与 /private/tmp)。 + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const mocks = vi.hoisted(() => ({ home: '' })); + +// 适配器存储路径由 os.homedir() 派生,整体替换 home 才能用临时目录做 fixture。 +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; +}); + +import { ClaudeCodeAdapter } from '../session-flow/adapters/claude-code.js'; +import { CursorAdapter } from '../session-flow/adapters/cursor.js'; +import type { Session } from '../session-flow/ir.js'; + +function mkSession(imageFilePath: string): Session { + return { + sessionId: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + title: 'image migration', + cwd: '/proj/alpha', + platform: 'codebuddy-ide', + createdAt: '2026-09-24T10:00:00.000Z', + updatedAt: '2026-09-24T10:00:05.000Z', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'look at this' }, + { type: 'image', mimeType: 'image/png', filePath: imageFilePath, label: 'pic.png' }, + ], + }, + ], + metadata: {}, + }; +} + +/** 迁移后按 id 在目标工作区里找回落盘文件。 */ +async function findWritten( + adapter: ClaudeCodeAdapter, + cwd: string, + id: string, +): Promise { + const metas = await adapter.listConversations(cwd); + const found = metas.find((m) => m.sessionId === id); + if (!found?.filePath) throw new Error(`session ${id} not listed in ${cwd}`); + return found.filePath; +} + +beforeEach(() => { + mocks.home = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-img-')); +}); + +afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(mocks.home, { recursive: true, force: true }); + mocks.home = ''; +}); + +describe('migrating an image that only has a local filePath', () => { + it('embeds the file into claude-code instead of dropping the block', async () => { + const pic = path.join(mocks.home, 'pic.png'); + fs.writeFileSync(pic, Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3])); + const expected = fs.readFileSync(pic).toString('base64'); + + fs.mkdirSync(path.join(mocks.home, '.claude', 'projects'), { recursive: true }); + const target = new ClaudeCodeAdapter(); + const cwd = path.join(mocks.home, 'work'); + + const id = await target.writeSession(mkSession(pic), cwd); + const text = fs.readFileSync(await findWritten(target, cwd, id), 'utf-8'); + expect(text).toContain(expected); + }); + + it('does not read the file for a session restored from the archive', async () => { + const pic = path.join(mocks.home, 'secret.png'); + fs.writeFileSync(pic, Buffer.from([0x89, 0x50, 0x4e, 0x47, 9, 9, 9])); + const secret = fs.readFileSync(pic).toString('base64'); + + fs.mkdirSync(path.join(mocks.home, '.claude', 'projects'), { recursive: true }); + const session = mkSession(pic); + session.metadata = { untrusted: true }; + + const target = new ClaudeCodeAdapter(); + const cwd = path.join(mocks.home, 'work'); + const id = await target.writeSession(session, cwd); + const text = fs.readFileSync(await findWritten(target, cwd, id), 'utf-8'); + expect(text).not.toContain(secret); + }); +}); + +describe('cursor composerId derivation', () => { + it('gives one id to the two spellings of one workspace', async () => { + fs.mkdirSync(path.join(mocks.home, '.cursor', 'projects'), { recursive: true }); + const adapter = new CursorAdapter(); + const real = path.join(fs.realpathSync(os.tmpdir()), 'teamai-cursor-cwd'); + fs.mkdirSync(real, { recursive: true }); + // /tmp is a symlink to /private/tmp on macOS: the unresolved spelling is + // what a user types, the resolved one is what Cursor itself writes. + const typed = path.join(os.tmpdir(), 'teamai-cursor-cwd'); + + const [idTyped, idReal] = await Promise.all([ + adapter.writeSession(mkSession('/nope.png'), typed), + adapter.writeSession(mkSession('/nope.png'), real), + ]); + expect(idTyped).toBe(idReal); + }); +}); diff --git a/src/__tests__/scrub-session.test.ts b/src/__tests__/scrub-session.test.ts index 7eefdfc67..7a98c2aac 100644 --- a/src/__tests__/scrub-session.test.ts +++ b/src/__tests__/scrub-session.test.ts @@ -88,6 +88,18 @@ describe('scrubSession', () => { expect(JSON.stringify(result.session)).not.toContain('/Users/alice/secret'); }); + it('本地迁移可以保留图片路径(只有归档才必须丢弃)', () => { + const s = makeSession(); + s.messages.push({ + role: 'user', + content: [{ type: 'image', mimeType: 'image/png', filePath: '/Users/alice/shot.png', label: 'shot.png' }], + timestamp: s.createdAt, + }); + const kept = scrubSession(s, { dropImagePaths: false }).session; + const last = kept.messages[kept.messages.length - 1].content[0]; + expect(last).toMatchObject({ type: 'image', filePath: '/Users/alice/shot.png' }); + }); + it('无敏感内容时原样返回、计数为 0', () => { const clean = makeSession(); clean.title = '普通提问'; diff --git a/src/__tests__/session-cmd.test.ts b/src/__tests__/session-cmd.test.ts index d1a92635c..8ed456ef6 100644 --- a/src/__tests__/session-cmd.test.ts +++ b/src/__tests__/session-cmd.test.ts @@ -24,6 +24,9 @@ const mocks = vi.hoisted(() => ({ remotes: {} as Record, /** `git status --porcelain -- sessions/` 的返回值;空串 = 无变更可提交 */ porcelain: 'M sessions/changed\n', + /** 非空时 git commit / git push 抛错,用于验证失败路径 */ + commitError: '', + pushError: '', gitCalls: [] as Array<{ args: string[]; cwd?: string }>, adaptersByPlatform: {} as Record, /** migrate.js mock 的 preview/migrate 返回值 */ @@ -60,6 +63,8 @@ vi.mock('node:child_process', async (importOriginal) => { } if (args[0] === 'status') return mocks.porcelain; if (args[0] === 'rev-parse') return 'abc123def456\n'; + if (args[0] === 'commit' && mocks.commitError) throw new Error(mocks.commitError); + if (args[0] === 'push' && mocks.pushError) throw new Error(mocks.pushError); return ''; // add / commit / push / pull }, }; @@ -123,14 +128,20 @@ let repoRoot: string; /** console.log + process.stdout.write 的合并捕获(ask 的提示走 stdout.write) */ let out: string[] = []; let warned: string[] = []; +/** console.error 的捕获:失败信息(commit/push/门禁)都走这条通道 */ +let errored: string[] = []; beforeEach(() => { repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-cmd-')); mocks.home = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-cmd-home-')); out = []; warned = []; + errored = []; mocks.remotes = {}; mocks.porcelain = 'M sessions/changed\n'; + mocks.commitError = ''; + mocks.pushError = ''; + process.exitCode = 0; mocks.gitCalls.length = 0; mocks.adaptersByPlatform = {}; mocks.previewResult = null; @@ -142,7 +153,9 @@ beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => { warned.push(a.map(String).join(' ')); }); - vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => { + errored.push(a.map(String).join(' ')); + }); vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { out.push(String(chunk)); return true; @@ -397,6 +410,38 @@ describe('session push --all', () => { expect(fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'))).toHaveLength(1); expect(mocks.gitCalls.some((c) => c.args[0] === 'push')).toBe(false); }); + + it('reports a failed commit instead of "No changes to push" and exits non-zero', async () => { + // A commit hook / gpg / missing identity makes git exit non-zero. It used + // to be swallowed as null, so the archive stayed staged while the command + // printed a clean-tree message and exited 0. + fakeAdapter([mkSession({ sessionId: 'fail-1' })]); + mocks.remotes['/proj/beta'] = 'https://gitlab.com/team/beta.git'; + mocks.commitError = 'Command failed: git commit -m x\nfatal: cannot run hooks/pre-commit: permission denied'; + + await runSession('push', '--source', 'fakeplat', '--repo-root', repoRoot, '--cwd', '/run/dir'); + + const text = [...out, ...warned, ...errored].join('\n'); + expect(text).not.toContain('No changes to push'); + expect(text).not.toContain('✓ Pushed'); + expect(text).toContain('Commit failed'); + expect(process.exitCode).toBe(1); + }); + + it('exits non-zero when the remote push fails, after the local commit succeeded', async () => { + fakeAdapter([mkSession({ sessionId: 'push-fail-1' })]); + mocks.remotes['/proj/beta'] = 'https://gitlab.com/team/beta.git'; + mocks.pushError = 'Command failed: git push origin\nfatal: The current branch main has no upstream branch.'; + + await runSession('push', '--source', 'fakeplat', '--repo-root', repoRoot, '--cwd', '/run/dir'); + + const text = [...out, ...warned, ...errored].join('\n'); + // git's own fatal line, not the contentless first line of the error + expect(text).toContain('no upstream branch'); + expect(text).toContain('committed locally but not pushed'); + expect(text).not.toContain('✓ Pushed'); + expect(process.exitCode).toBe(1); + }); }); describe('session push archive key (native cwd, not the run directory)', () => { diff --git a/src/__tests__/session-sync.test.ts b/src/__tests__/session-sync.test.ts index 416078f82..36f0fa584 100644 --- a/src/__tests__/session-sync.test.ts +++ b/src/__tests__/session-sync.test.ts @@ -311,6 +311,53 @@ describe('rebuildIndex', () => { expect(sessions).toBe(1); expect(new SyncManager(repoRoot).listAllRepoIdentities()).toEqual(['github.com/org/alpha']); }); + + it('repairs a corrupted index instead of overwriting it with the new entry', () => { + // A corrupted index used to be read as empty, so the next upsert wrote a + // one-entry index and every other session of that repo vanished from + // `list` -- while their files were still on disk. + const mgr = new SyncManager(repoRoot); + mgr.saveSession( + mkSession({ sessionId: 'first', title: 'first session' }), + mkMeta({ sessionId: 'first' }), + ); + fs.writeFileSync(path.join(repoDirOf('github.com/org/alpha'), '_index.json'), '{ broken'); + + mgr.saveSession( + mkSession({ sessionId: 'second', title: 'second session' }), + mkMeta({ sessionId: 'second' }), + ); + + const idx = readRepoIndex('github.com/org/alpha'); + expect(idx.sessions.map((s) => s.sessionId).sort()).toEqual(['first', 'second']); + }); + + it('makes a legacy _-folded repository directory readable again', () => { + const mgr = new SyncManager(repoRoot); + mgr.saveSession(mkSession(), mkMeta()); + + // Simulate a directory written by the old '_'-folding encoder. + const reposDir = path.join(repoRoot, 'sessions', 'repos'); + fs.renameSync( + path.join(reposDir, encodeRepoIdentity('github.com/org/alpha')), + path.join(reposDir, 'github.com_org_alpha'), + ); + fs.unlinkSync(path.join(reposDir, 'github.com_org_alpha', '_index.json')); + + const { repos, sessions } = new SyncManager(repoRoot).rebuildAllIndexes(); + expect(repos).toBe(1); + expect(sessions).toBe(1); + + // Reads resolve the encoded name, so the directory has to move there -- + // otherwise the rebuilt index is written somewhere nothing ever reads. + expect(fs.existsSync(path.join(reposDir, 'github.com_org_alpha'))).toBe(false); + expect( + fs.existsSync(path.join(reposDir, encodeRepoIdentity('github.com/org/alpha'), '_index.json')), + ).toBe(true); + const after = new SyncManager(repoRoot); + expect(after.listAllRepoIdentities()).toEqual(['github.com/org/alpha']); + expect(after.listSessions('github.com/org/alpha')).toHaveLength(1); + }); }); describe('canonicalizeRemote', () => { @@ -319,6 +366,14 @@ describe('canonicalizeRemote', () => { // archive index would leak them to the whole team. expect(canonicalizeRemote('https://oauth2:TOKEN@github.com/org/repo.git')).toBe('github.com/org/repo'); expect(canonicalizeRemote('https://user:pass@gitlab.company.com/g/repo.git')).toBe('gitlab.company.com/g/repo'); + // A password containing '/' defeats the "no slash before @" shape, and the + // secret used to survive into meta, _index.json and the SOURCE column. + expect(canonicalizeRemote('https://user:pw/slash@github.com/org/repo.git')).toBe('github.com/org/repo'); + }); + + it('leaves a legitimate @ in the path alone', () => { + expect(canonicalizeRemote('https://github.com/org/@scope/pkg.git')).toBe('github.com/org/@scope/pkg'); + expect(canonicalizeRemote('git@github.com:org/repo.git')).toBe('github.com/org/repo'); }); it('normalizes https, scp and ssh remote forms to the same identity', () => { diff --git a/src/session-flow/adapters/claude-code.ts b/src/session-flow/adapters/claude-code.ts index 05545eb6f..b12d30010 100644 --- a/src/session-flow/adapters/claude-code.ts +++ b/src/session-flow/adapters/claude-code.ts @@ -627,6 +627,9 @@ export class ClaudeCodeAdapter extends AgentAdapter { async writeSession(session: Session, projectPath?: string): Promise { // 确定 session_id(必须是 UUIDv4):已是 v4 则沿用,否则确定性派生—— // 随机生成会让重复迁移产生 id 不同、内容相同的重复会话。 + // 这里**故意不把 cwd 纳入派生**:本平台的会话按项目目录存放 + // (`//.jsonl`),同一 id 落在两个目录就是两份 + // 独立文件,不存在互相覆盖。加上 cwd 只会让已迁移的会话 id 漂移。 const sessionId = isUuidV4(session.sessionId) ? session.sessionId : deriveTargetSessionId(this.platform, session.sessionId); @@ -783,23 +786,45 @@ export class ClaudeCodeAdapter extends AgentAdapter { return records; } + /** + * 找出该 sessionId 的**全部**副本(跨工作区)。 + * + * findSessionFile 命中首个即返回,读取没问题;但删除时只删首个会让别的 + * 工作区里的同名副本变成孤儿——而 rollback 不带 --cwd 的语义正是「全部」。 + */ + private findAllSessionFiles(sessionId: string, projectPath?: string): string[] { + if (projectPath) { + const target = path.join(this.resolveProjectDir(projectPath), `${sessionId}.jsonl`); + return fileExists(target) ? [target] : []; + } + if (!dirExists(this.storageRoot)) return []; + const out: string[] = []; + for (const projDir of fs.readdirSync(this.storageRoot)) { + const candidate = path.join(this.storageRoot, projDir, `${sessionId}.jsonl`); + if (fileExists(candidate)) out.push(candidate); + } + return out; + } + async deleteSession(sessionId: string, projectPath?: string): Promise { - const jsonlPath = this.findSessionFile(sessionId, projectPath); + const jsonlPaths = this.findAllSessionFiles(sessionId, projectPath); // Nothing to delete under this project: report it instead of printing ✓. - if (!jsonlPath) return false; + if (jsonlPaths.length === 0) return false; let deleted = false; - try { - fs.unlinkSync(jsonlPath); - deleted = true; - } catch { - deleted = false; - } + for (const jsonlPath of jsonlPaths) { + try { + fs.unlinkSync(jsonlPath); + deleted = true; + } catch { + // ignore + } - // 删除同名子目录 - const subdir = jsonlPath.replace(/\.jsonl$/, ''); - if (dirExists(subdir)) { - removeDirRecursive(subdir); + // 删除同名子目录 + const subdir = jsonlPath.replace(/\.jsonl$/, ''); + if (dirExists(subdir)) { + removeDirRecursive(subdir); + } } return deleted; } diff --git a/src/session-flow/adapters/codebuddy.ts b/src/session-flow/adapters/codebuddy.ts index dc0e9dcc3..019431a52 100644 --- a/src/session-flow/adapters/codebuddy.ts +++ b/src/session-flow/adapters/codebuddy.ts @@ -487,6 +487,8 @@ export class CodeBuddyAdapter extends AgentAdapter { async writeSession(session: Session, projectPath?: string): Promise { // 非 UUID 源 id 用确定性派生(同一源会话反复迁移命中同一个 id → 不产生重复会话) + // 与 claude-code 同理,故意不纳入 cwd:会话文件按项目目录隔离, + // 同 id 不同目录各是一份,不会互相覆盖。 const sessionId = isUuid(session.sessionId) ? session.sessionId : deriveTargetSessionId('codebuddy', session.sessionId); diff --git a/src/session-flow/adapters/codex.ts b/src/session-flow/adapters/codex.ts index 80dcbe978..1b59c5ef1 100644 --- a/src/session-flow/adapters/codex.ts +++ b/src/session-flow/adapters/codex.ts @@ -340,7 +340,12 @@ export class CodexAdapter extends AgentAdapter { try { const rec = this.readFirstLine(file); const payload = (rec?.payload ?? {}) as Record; - return String(payload.cwd ?? '') === projectPath; + const cwd = String(payload.cwd ?? ''); + // Compare resolved paths: listing resolves the cwd the same way, so a + // bare string compare made `/tmp/x` and `/private/tmp/x` two different + // workspaces -- `rollback --cwd /tmp/x` then reported "not found" and + // deleted nothing. + return cwd === projectPath || resolveRealCwd(cwd) === resolveRealCwd(projectPath); } catch { return false; } diff --git a/src/session-flow/adapters/cursor.ts b/src/session-flow/adapters/cursor.ts index 47ef73466..cb46a6048 100644 --- a/src/session-flow/adapters/cursor.ts +++ b/src/session-flow/adapters/cursor.ts @@ -35,6 +35,7 @@ import { fileExists, dirExists, removeDirRecursive, + resolveRealCwd, } from '../fs.js'; import { cleanTitleText, fallbackTitle, isInjectedText, titleFromCandidates, titleFromUserText, extractUserText, isRenderableText, visibleUserText } from '../title.js'; import { registerCursorComposer, unregisterCursorComposer, type CursorComposerMessage, type CursorComposerTool } from '../cursor-store.js'; @@ -136,7 +137,10 @@ function deriveCursorId(sourcePlatform: string, sourceId: string, targetCwd?: st // The composerId is a global key in state.vscdb: without the target cwd, // migrating one source session into two workspaces reuses one id and the // second copy overwrites the first. - const scope = targetCwd ? `:${targetCwd}` : ''; + // Resolved, like the ids.ts derivation: `/tmp/x` and `/private/tmp/x` are + // one workspace, and two spellings would register two composerHeaders rows + // for one session in Cursor's Agents list. + const scope = targetCwd ? `:${resolveRealCwd(targetCwd)}` : ''; const hex = crypto .createHash('sha256') .update(`teamai:cursor:${sourcePlatform}${scope}:${sourceId}`) diff --git a/src/session-flow/cursor-store.ts b/src/session-flow/cursor-store.ts index 76af834a4..b1caced09 100644 --- a/src/session-flow/cursor-store.ts +++ b/src/session-flow/cursor-store.ts @@ -592,13 +592,13 @@ export function registerCursorComposer(args: RegisterCursorComposerArgs): Regist } stmts.push('COMMIT;'); - const sqlPath = path.join(os.tmpdir(), `teamai-cursor-${process.pid}-${Date.now()}.sql`); + // The script holds the full conversation text. Piping it straight to sqlite3 + // keeps it off the filesystem: a temp file in shared /tmp is world-listed, + // predictable (pid + timestamp) and -- even created 0600 -- would be written + // through a symlink pre-planted at that exact name. try { - // The SQL file holds the full conversation text and lives in shared /tmp: - // default umask would leave it world-readable until we unlink it. - fs.writeFileSync(sqlPath, stmts.join('\n'), { encoding: 'utf-8', mode: 0o600 }); const r = spawnSync(sqlite3, [dbPath], { - input: fs.readFileSync(sqlPath), + input: stmts.join('\n'), maxBuffer: 32 * 1024 * 1024, timeout: 30_000, }); @@ -607,12 +607,6 @@ export function registerCursorComposer(args: RegisterCursorComposerArgs): Regist } } catch (e) { return { ok: false, reason: (e as Error).message }; - } finally { - try { - fs.unlinkSync(sqlPath); - } catch { - // ignore - } } return { ok: true, bubbleCount: records.length }; @@ -634,11 +628,9 @@ export function unregisterCursorComposer(composerId: string): RegisterResult { `DELETE FROM cursorDiskKV WHERE key='composerData:${esc(composerId)}' OR key LIKE 'bubbleId:${esc(composerId)}:%';\n` + 'COMMIT;'; - const sqlPath = path.join(os.tmpdir(), `teamai-cursor-del-${process.pid}-${Date.now()}.sql`); try { - fs.writeFileSync(sqlPath, sql, { encoding: 'utf-8', mode: 0o600 }); const r = spawnSync(sqlite3, [dbPath], { - input: fs.readFileSync(sqlPath), + input: sql, maxBuffer: 32 * 1024 * 1024, timeout: 30_000, }); @@ -647,12 +639,6 @@ export function unregisterCursorComposer(composerId: string): RegisterResult { } } catch (e) { return { ok: false, reason: (e as Error).message }; - } finally { - try { - fs.unlinkSync(sqlPath); - } catch { - // ignore - } } return { ok: true }; } diff --git a/src/session-flow/ids.ts b/src/session-flow/ids.ts index 3cb543f36..7cb11c8f3 100644 --- a/src/session-flow/ids.ts +++ b/src/session-flow/ids.ts @@ -12,21 +12,32 @@ */ import * as crypto from 'node:crypto'; +import { resolveRealCwd } from './fs.js'; /** * Derive a deterministic target session id (UUIDv7 shape) from a source id. * * `targetCwd` participates when known: Cursor/WorkBuddy/Codex key their - * records globally, so migrating one source session into two workspaces with - * the same derived id makes the second copy replace (or redirect) the first. - * Omitting it keeps the previous id, so already-migrated sessions stay stable. + * records globally (one sqlite / threads table for every workspace), so + * migrating one source session into two workspaces with the same derived id + * makes the second copy replace the first. + * + * Only claude-code and codebuddy omit it: they store each session under + * `//.jsonl`, so one id in two workspaces is + * already two files and nothing is overwritten. Adding cwd there would only + * move already-migrated sessions to a new id. Note the consequence: a session + * migrated into a global-key platform before this change gets a different id + * when re-migrated (the old copy stays, orphaned). */ export function deriveTargetSessionId( targetPlatform: string, sourceId: string, targetCwd?: string, ): string { - const scope = targetCwd ? `:${targetCwd}` : ''; + // Resolve before hashing: `/tmp/x` and `/private/tmp/x` are one workspace on + // macOS, and two spellings would derive two ids for the same migration -- + // a re-migration would then add a second copy instead of overwriting. + const scope = targetCwd ? `:${resolveRealCwd(targetCwd)}` : ''; const hex = crypto .createHash('sha256') .update(`teamai:${targetPlatform}${scope}:${sourceId}`) diff --git a/src/session-flow/migrate.ts b/src/session-flow/migrate.ts index 8c370b4ca..019cb82ae 100644 --- a/src/session-flow/migrate.ts +++ b/src/session-flow/migrate.ts @@ -314,14 +314,19 @@ export class MigrationEngine { } const session = await source.readSession(sessionId, projectPath); - const fidelity = fidelityFromSession(session, this.targetPlatform); - // 降级 ThinkingBlock - // 脱敏在 thinking 降级之后、写入之前:--scrub 时整份 IR 过一遍 redact, + // 脱敏在 thinking 降级**之前**:--scrub 时整份 IR 过一遍 redact, // 让敏感内容不会随会话扩散到目标端(以及后续可能的团队归档)。 - const degraded = degradeThinkingBlocks(session, this.targetPlatform); - const scrubbed = scrub ? scrubSession(degraded) : null; - const enhancedSession = scrubbed ? scrubbed.session : degraded; + // 本地→本地迁移保留图片 filePath:目标端仍能读到该文件,去掉只会把图片 + // 变成占位文本(归档路径才必须丢——那里会把家目录布局写进团队仓)。 + const scrubbed = scrub ? scrubSession(session, { dropImagePaths: false }) : null; + const base = scrubbed ? scrubbed.session : session; + // fidelity 取「脱敏后、降级前」:fidelityFromSession 统计的正是 thinking + // 被降级成 text 的块,降级之后再算,它们已经是 text 了——评分虚高、 + // degradedBlocks 告警消失,还会与 --dry-run 的 preview(用未降级 IR) + // 互相打架。此时 scrub 造成的丢块已经计入。 + const fidelity = fidelityFromSession(base, this.targetPlatform); + const enhancedSession = degradeThinkingBlocks(base, this.targetPlatform); // 目标工作区语义:**默认保持源会话的工作区**。 // 迁移是「把 thpc 的会话搬到 Codex/WorkBuddy」,而不是「搬到我当前所在的目录」; diff --git a/src/session-flow/scrub.ts b/src/session-flow/scrub.ts index e2c45f3f6..2d6ecbd63 100644 --- a/src/session-flow/scrub.ts +++ b/src/session-flow/scrub.ts @@ -42,7 +42,10 @@ function countRedactions(before: string, after: string): number { return count; } -function scrubBlock(block: ContentBlock): { block: ContentBlock; count: number } { +function scrubBlock( + block: ContentBlock, + dropImagePaths: boolean, +): { block: ContentBlock; count: number } { switch (block.type) { case 'text': { const next = redactWithEnv(block.text); @@ -87,7 +90,10 @@ function scrubBlock(block: ContentBlock): { block: ContentBlock; count: number } // `filePath` is an absolute local path (`/Users/alice/...`) and lands in // the archive verbatim -- scrub has to drop it, otherwise redacting the // transcript still publishes the user's home directory layout. - if (!block.filePath) return { block, count: 0 }; + // A local -> local migration keeps it: the target can still read the + // file, and dropping it would silently replace the image with a + // placeholder on a machine that never leaves. + if (!block.filePath || !dropImagePaths) return { block, count: 0 }; if (block.data) { return { block: { type: 'image', mimeType: block.mimeType, data: block.data, label: block.label }, count: 1 }; } @@ -105,14 +111,22 @@ function scrubBlock(block: ContentBlock): { block: ContentBlock; count: number } * * The title is scrubbed too: the first prompt often carries a token, and the * title is what shows up in the target client's session list. + * + * `dropImagePaths` (default true) removes the absolute local path of image + * blocks -- required before archiving to a team repo, unwanted for a local + * migration where the target can still read the file. */ -export function scrubSession(session: Session): ScrubResult { +export function scrubSession( + session: Session, + opts: { dropImagePaths?: boolean } = {}, +): ScrubResult { + const dropImagePaths = opts.dropImagePaths !== false; let redactedCount = 0; const messages: Message[] = session.messages.map((msg) => { let contentChanged = false; const content = msg.content.map((block) => { - const { block: next, count } = scrubBlock(block); + const { block: next, count } = scrubBlock(block, dropImagePaths); redactedCount += count; if (next !== block) contentChanged = true; return next; diff --git a/src/session-flow/session-cmd.ts b/src/session-flow/session-cmd.ts index 491424605..93f4400de 100644 --- a/src/session-flow/session-cmd.ts +++ b/src/session-flow/session-cmd.ts @@ -25,6 +25,7 @@ import { scrubSession } from './scrub.js'; import { MigrationEngine } from './migrate.js'; import { SyncManager, getRepoIdentity, getGitAuthor, defaultSyncMeta } from './sync.js'; import { SessionSearchEngine, type LoadedSession } from './search.js'; +import { isInteractive } from '../utils/prompt.js'; // --------------------------------------------------------------------------- // 辅助 @@ -319,9 +320,12 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { // 之后的 rollback 再把原件删掉——不可恢复。必须显式指定目标工作区 // (--target-cwd)或明确 -y 才放行。 if (source === target && !opts.targetCwd) { + // isInteractive() (not process.stdin.isTTY): CI runners and agent + // sandboxes hand out a pseudo-terminal with nobody behind it, where + // ask() would block forever on a prompt no one can answer. const confirmed = opts.yes || - (process.stdin.isTTY && + (isInteractive() && /^y(es)?$/i.test( ( await ask( @@ -342,7 +346,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { let crossDirExpanded = false; // --all 的语义是"这个源的全部会话":无 --cwd 时跨所有工作区枚举, // 而不是只看当前目录(那会让 --all 静默变成"当前目录的全部")。 - if (opts.all && !opts.cwd && metas.length >= 0 && !sessionId) { + if (opts.all && !opts.cwd && !sessionId) { const allMetas = await sourceAdapter.listConversations(); if (allMetas.length > 0) { metas = allMetas; @@ -395,6 +399,14 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { const title = safeText(m.title); console.log(` ${m.sessionId.slice(0, 8)} ${title.length > 50 ? title.slice(0, 50) + '...' : title} (${m.messageCount} msgs)`); } + // Nobody can answer in a non-interactive run. Cancelling with exit 0 + // there would tell a script that every session was migrated; refuse + // instead and let the caller opt in explicitly. + if (!isInteractive()) { + console.error(`\nRefusing to migrate ${targets.length} session(s) without confirmation.`); + console.error('Pass -y to confirm, or --limit to migrate fewer.'); + process.exit(1); + } const ans = await ask('\nMigrate all of the above? (y/N): '); if (ans.toLowerCase() !== 'y' && ans.toLowerCase() !== 'yes') { console.log('Cancelled.'); @@ -545,7 +557,13 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { meta.migration.sourcePlatform = source; meta.migration.targetPlatform = target; meta.migration.fidelityScore = t.fidelityScore; - syncMgr.saveSession(session, meta); + try { + syncMgr.saveSession(session, meta); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } saved++; } // [已修] gitCommit 失败(非 git 目录 / index.lock 竞态)此前裸堆栈崩溃 @@ -660,7 +678,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { // 归档的是完整原文、团队可读:交互场景下先征得同意再落盘。 // 写完之后再提醒等于马后炮——文件已经进仓库、commit 已经建好了。 // 非交互(脚本/CI)没有 TTY,保持原行为:只警告,不阻断。 - if (!opts.scrub && !opts.yes && process.stdin.isTTY) { + if (!opts.scrub && !opts.yes && isInteractive()) { console.log( `\n ⚠ About to archive ${selected.length} unredacted session(s) from ${source}.`, ); @@ -702,7 +720,16 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { }, session.createdAt, ); - syncMgr.saveSession(session, meta); + // A corrupted index is repaired from disk, or the save throws: failing + // mid-batch must not fall through to a commit that publishes a + // half-archive. + try { + syncMgr.saveSession(session, meta); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } saved++; } if (opts.scrub) { @@ -957,7 +984,15 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { } const adapter = safeGetAdapter(opts.platform); - const deleted = await adapter.deleteSession(sessionId, opts.cwd); + let deleted: boolean; + try { + deleted = await adapter.deleteSession(sessionId, opts.cwd); + } catch (err) { + // Same contract as the other subcommands: one English line, no stack. + console.error(`\n ✗ Rollback failed: ${err instanceof Error ? err.message : String(err)}\n`); + process.exitCode = 1; + return; + } // 适配器返回 false 表示确认没删到任何东西(会话不存在)。 // 之前无论是否存在都打印 ✓,静默 no-op 却报成功,脚本无法判断是否生效。 if (deleted === false) { diff --git a/src/session-flow/sync.ts b/src/session-flow/sync.ts index b7114ba1b..7460a33a6 100644 --- a/src/session-flow/sync.ts +++ b/src/session-flow/sync.ts @@ -102,12 +102,28 @@ 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, ''); + // Drop userinfo even when the password contains '/' (`user:pw/slash@host/…`): + // the "no slash before @" rule below cannot match that shape, so the secret + // survived into meta, _index.json and the SOURCE column of `list --all`. + // Cut at the first '@' when what precedes it looks like user:password -- a + // legitimate '@' in the path (`host/org/@scope/pkg`) has no ':' before it. + const at = s.indexOf('@'); + if (at > 0 && s.slice(0, at).includes(':')) s = s.slice(at + 1); // Drop userinfo: oauth2:TOKEN@host/... and git@host (both scp and ssh:// forms). s = s.replace(/^[^/@]+@/, ''); // git@github.com:org/repo → github.com/org/repo (scp-style colon separator) s = s.replace(/^([^/:]+):(?!\d+(?:\/|$))/, '$1/'); // 去前导 / s = s.replace(/^\/+/, ''); + // Hosts are case-insensitive: GitHub.com/Org/Repo and github.com/org/repo are + // one repository, and two spellings would mean two archive directories and + // two indexes for it. The path keeps its case (git paths are case-sensitive). + const slash = s.indexOf('/'); + if (slash > 0) { + s = s.slice(0, slash).toLowerCase() + s.slice(slash); + } else { + s = s.toLowerCase(); + } return s; } @@ -123,17 +139,25 @@ export function canonicalizeRemote(remote: string): string { * Reversible via decodeRepoIdentity. */ export function encodeRepoIdentity(identity: string): string { - return identity.replace( - /[^a-zA-Z0-9._-]/g, - (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`, - ); + // Per character: charCodeAt().toString(16) emitted %4E2D for '中', which the + // 2-hex-digit decoder read back as 'N' + '2D'. encodeURIComponent gives the + // UTF-8 bytes (%E4%B8%AD) that decodeURIComponent reverses exactly. + let out = ''; + for (const ch of identity) { + out += /^[A-Za-z0-9._-]$/.test(ch) ? ch : encodeURIComponent(ch); + } + return out; } /** Undo encodeRepoIdentity (used for display; the canonical id stays in meta). */ export function decodeRepoIdentity(encoded: string): string { - return encoded.replace(/%([0-9A-F]{2})/g, (_, hex: string) => - String.fromCharCode(parseInt(hex, 16)), - ); + try { + return decodeURIComponent(encoded); + } catch { + // A malformed escape (hand-edited index, legacy '_'-folded name): fall back + // to the raw string rather than throwing out of a listing command. + return encoded; + } } /** @@ -398,12 +422,21 @@ export class SyncManager { return { version: 1, repoIdentity, updatedAt: utcNow(), sessions: [] }; } - private writeIndex(repoIdentity: string | null, index: RepoIndex): void { - const dir = this.repoDir(repoIdentity); + /** + * `dirOverride` writes the index into an explicit directory (a legacy + * `_`-folded one) instead of the directory encoded from `repoIdentity` -- + * otherwise rebuilding an old directory would write its index into the new + * name and leave the old one unindexed forever. + */ + private writeIndex(repoIdentity: string | null, index: RepoIndex, dirOverride?: string): void { + const dir = dirOverride ?? this.repoDir(repoIdentity); + // Guard before mkdir: `mkdir -p` through a symlinked ancestor would create + // the directory outside the archive first, and the guard after it would + // then be checking a path that already exists on the wrong side. + this.guardWrite(path.join(dir, '_index.json')); fs.mkdirSync(dir, { recursive: true }); index.updatedAt = utcNow(); - const target = this.indexPath(repoIdentity); - this.guardWrite(target); + const target = path.join(dir, '_index.json'); // 原子写:先落临时文件再 rename。直接 writeFileSync 时,两个并发 push 会 // 互相截断,留下半截(甚至空)的 _index.json——那会让整个仓库的会话 // 看起来凭空消失。rename 在同一文件系统上是原子的,读者只会看到旧值或新值。 @@ -421,8 +454,34 @@ export class SyncManager { } } + /** + * 写入路径专用的索引读取:索引损坏时先按磁盘重建,救不回来就报错。 + * + * readIndex() 对解析失败返回空数组,upsert 随即用「本次这 1 条」覆盖写回, + * 该仓库其余会话就从索引里整体消失了(并发 push 留下冲突标记时最易触发)。 + * 会话文件本身还在,所以重建能救回来——重建不了说明问题更大,宁可中止。 + */ + private readIndexForWrite(repoIdentity: string | null): RepoIndex { + const p = this.indexPath(repoIdentity); + if (!fs.existsSync(p)) { + return { version: 1, repoIdentity, updatedAt: utcNow(), sessions: [] }; + } + try { + return JSON.parse(fs.readFileSync(p, 'utf-8')) as RepoIndex; + } catch { + this.rebuildIndex(repoIdentity); + try { + return JSON.parse(fs.readFileSync(p, 'utf-8')) as RepoIndex; + } catch { + throw new Error( + `corrupted session index at ${p}; run 'teamai session pull --all --repo-root ' to rebuild it`, + ); + } + } + } + private upsertIndexEntry(repoIdentity: string | null, entry: IndexEntry): void { - const index = this.readIndex(repoIdentity); + const index = this.readIndexForWrite(repoIdentity); const key = `${entry.sessionName}:${entry.author}`; const idx = index.sessions.findIndex((s) => `${s.sessionName}:${s.author}` === key); if (idx >= 0) { @@ -434,7 +493,9 @@ export class SyncManager { } private removeIndexEntry(repoIdentity: string | null, sessionName: string, author: string): void { - const index = this.readIndex(repoIdentity); + // Same reasoning as upsert: a corrupted index must not be overwritten with + // a filtered copy of "nothing". + const index = this.readIndexForWrite(repoIdentity); index.sessions = index.sessions.filter( (s) => !(s.sessionName === sessionName && s.author === author), ); @@ -471,7 +532,10 @@ export class SyncManager { author?: string, platform?: string, ): IndexEntry | undefined { - const index = this.readIndex(repoIdentity); + // Strict read: a corrupted index would come back empty here, so the dedup + // lookup misses and the session is written as an extra `_1` copy before + // the upsert repairs the index. Repair first, then look. + const index = this.readIndexForWrite(repoIdentity); return index.sessions.find( (s) => s.sessionId === sessionId && @@ -544,6 +608,11 @@ export class SyncManager { sessionName: string, author?: string, ): { session: Session; meta: SessionSyncMeta } { + // The name reaches the filesystem: reject anything that is not a plain + // file name, so `--session ../../x` cannot read outside the archive. + if (sessionName !== path.basename(sessionName) || /^\.\.?$/.test(sessionName)) { + throw new Error(`Invalid session name: ${sessionName}`); + } const resolvedAuthor = author ?? this.findAuthor(repoIdentity, sessionName); const paths = this.sessionPaths(repoIdentity, resolvedAuthor, sessionName); @@ -751,9 +820,11 @@ export class SyncManager { /** * 扫描 repo 目录,幂等重建 _index.json。 + * + * `dirOverride` 指向实际目录(旧编码目录名的兜底重建用)。 */ - rebuildIndex(repoIdentity: string | null): number { - const dir = this.repoDir(repoIdentity); + rebuildIndex(repoIdentity: string | null, dirOverride?: string): number { + const dir = dirOverride ?? this.repoDir(repoIdentity); if (!fs.existsSync(dir)) return 0; const entries: IndexEntry[] = []; @@ -799,12 +870,16 @@ export class SyncManager { } } - this.writeIndex(repoIdentity, { - version: 1, + this.writeIndex( repoIdentity, - updatedAt: utcNow(), - sessions: entries, - }); + { + version: 1, + repoIdentity, + updatedAt: utcNow(), + sessions: entries, + }, + dirOverride, + ); return entries.length; } @@ -817,7 +892,8 @@ export class SyncManager { * 所以这里按目录遍历:identity 优先取索引原文,取不到再从目录名解码兜底。 */ rebuildAllIndexes(): { repos: number; sessions: number } { - const identities: Array = []; + /** identity → 实际目录(旧编码目录名与新编码不一致,必须带目录走)。 */ + const targets: Array<{ identity: string | null; dir?: string }> = []; const seen = new Set(); const reposDir = path.join(this.sessionsDir, 'repos'); @@ -830,21 +906,45 @@ export class SyncManager { continue; } const identity = this.identityOfRepoDir(full) ?? decodeLegacyRepoDirName(dir); - const key = identity ?? '_unattributed'; + const key = identity ?? '\u0000_unattributed'; if (seen.has(key)) continue; seen.add(key); - identities.push(identity); + // A legacy `_`-folded directory decodes to the canonical identity, but + // every read path resolves the *encoded* name -- so a rebuilt index + // under the old name would still list nothing. Move the directory to + // the encoded name once, then both sides agree. + let dirForIdentity = full; + if (identity) { + const expected = path.join(reposDir, encodeRepoIdentity(identity)); + if (expected !== full) { + if (fs.existsSync(expected)) { + console.warn( + `Warning: ${path.basename(full)} and ${path.basename(expected)} both hold ${identity}; keeping ${path.basename(expected)} and leaving ${path.basename(full)} in place.`, + ); + } else { + try { + fs.renameSync(full, expected); + dirForIdentity = expected; + } catch (err) { + console.warn( + `Warning: could not rename legacy archive directory ${path.basename(full)}: ${(err as Error).message}`, + ); + } + } + } + } + targets.push({ identity, dir: dirForIdentity }); } } const unattrDir = path.join(this.sessionsDir, '_unattributed'); - if (fs.existsSync(unattrDir) && !seen.has('_unattributed')) identities.push(null); + if (fs.existsSync(unattrDir) && !seen.has('\u0000_unattributed')) targets.push({ identity: null }); let sessions = 0; - for (const identity of identities) { - sessions += this.rebuildIndex(identity); + for (const t of targets) { + sessions += this.rebuildIndex(t.identity, t.dir); } - return { repos: identities.length, sessions }; + return { repos: targets.length, sessions }; } /** 目录索引里的 canonical identity;无索引/损坏时返回 null。 */ @@ -938,4 +1038,4 @@ export class SyncManager { return { uncommitted, ahead, behind }; } -} +} \ No newline at end of file diff --git a/src/session-flow/workbuddy-store.ts b/src/session-flow/workbuddy-store.ts index 022cbac5b..da8f3ac62 100644 --- a/src/session-flow/workbuddy-store.ts +++ b/src/session-flow/workbuddy-store.ts @@ -20,7 +20,6 @@ */ import * as fs from 'node:fs'; -import * as os from 'node:os'; import * as path from 'node:path'; import { spawnSync } from 'node:child_process'; import { getWorkBuddyProjectsDir } from './fs.js'; @@ -39,16 +38,14 @@ function esc(value: string): string { return value.replace(/'/g, "''"); } - /** Run a SQL script through the sqlite3 CLI (temp file, mode 0600, deleted after). */ +/** Run a SQL script through the sqlite3 CLI (piped in; never written to disk). */ function runSql(dbPath: string, sql: string, timeoutMs = 30_000): { ok: boolean; reason?: string } { const sqlite3 = findSqlite3(); if (!sqlite3) return { ok: false, reason: 'sqlite3 CLI not found' }; - const sqlPath = path.join(os.tmpdir(), `teamai-workbuddy-${process.pid}-${Date.now()}.sql`); try { - fs.writeFileSync(sqlPath, sql, { encoding: 'utf-8', mode: 0o600 }); const r = spawnSync(sqlite3, [dbPath], { - input: fs.readFileSync(sqlPath), + input: sql, maxBuffer: 32 * 1024 * 1024, timeout: timeoutMs, }); @@ -58,12 +55,6 @@ function runSql(dbPath: string, sql: string, timeoutMs = 30_000): { ok: boolean; } } catch (e) { return { ok: false, reason: (e as Error).message }; - } finally { - try { - fs.unlinkSync(sqlPath); - } catch { - // ignore - } } return { ok: true }; } From 74cf5a542f8113edfd7e679b59e349a4b35c9842 Mon Sep 17 00:00:00 2001 From: lurkacai Date: Tue, 29 Sep 2026 14:44:01 +0800 Subject: [PATCH 24/28] feat(session): tell the interactive picker there are more sessions than it shows The interactive migrate picker lists the 10 most recent sessions and says nothing else, so a library of 19 reads like a library of 10 and older sessions look unmigratable. When there are more, the picker now says how many it is showing and how to reach the rest: --all (optionally --limit N) or an explicit session-id prefix. --- src/session-flow/session-cmd.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/session-flow/session-cmd.ts b/src/session-flow/session-cmd.ts index 93f4400de..37d5e75d4 100644 --- a/src/session-flow/session-cmd.ts +++ b/src/session-flow/session-cmd.ts @@ -442,6 +442,11 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { const titleShown = title.length > 50 ? title.slice(0, 50) + '...' : title; console.log(` [${i + 1}] ${m.sessionId.slice(0, 8)} ${titleShown} (${m.messageCount} msgs, ${formatBytes(m.sizeBytes)})`); } + // 只列 10 条却不说明还有更多、怎么选到更多,用户会以为一共就 10 条。 + if (metas.length > recent.length) { + console.log(`\n Showing the ${recent.length} most recent of ${metas.length}. To migrate older ones:`); + console.log(` --all (every session, optionally --limit N) or migrate `); + } 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) { From a5d185abc09edbd39b46d9827485b74227e818e1 Mon Sep 17 00:00:00 2001 From: lurkacai Date: Tue, 29 Sep 2026 14:53:30 +0800 Subject: [PATCH 25/28] feat(session): paginate the interactive session picker The picker listed the 10 most recent sessions and nothing else, so a library of 19 read like a library of 10 and older sessions looked unreachable. It is now paginated: 10 per screen with a 'page 1/2, 19 total' header, n / p to move between pages, global numbering (page 2 starts at 11), and Enter still cancels -- an empty answer must keep cancelling so a non-interactive run cannot end up paging forever. Tests: two new cases (page to page 2 and pick #11; empty answer cancels), 48 passing across the session suites. Verified against the built CLI with 13 sessions: page 1 shows 1-10, 'n' shows 11-12, '12' migrates it. --- CHANGELOG.md | 1 + docs/usage-guide.md | 2 + docs/usage-guide.zh-CN.md | 2 + src/__tests__/session-cmd.test.ts | 79 +++++++++++++++++++++++++++++++ src/session-flow/session-cmd.ts | 76 +++++++++++++++++++++-------- 5 files changed, 140 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1444d7e2..50f66c8ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,7 @@ All notable changes to this project will be documented in this file. See [standa - Repository identities are encoded byte-wise (`encodeURIComponent`), so a non-ASCII identity survives the round trip; it previously came back as a mangled prefix. Git hosts are lower-cased, so `GitHub.com/Org/Repo` and `github.com/org/repo` are one repository instead of two archive directories. - Derived target session ids hash the resolved cwd (`/tmp/x` and `/private/tmp/x` are one workspace on macOS), so a re-migration overwrites instead of adding a second copy. Only `claude-code` and `codebuddy` keep the cwd out of the hash, since they store one file per project directory; for `codex` / `cursor` / `workbuddy`, whose records are keyed globally, a session migrated before this change gets a different id when re-migrated and the earlier copy stays behind. - `session rollback` without `--cwd` deletes every workspace's copy of the session, as its help promises, instead of only the first one `findSessionFile` happened to return. +- `session migrate` no longer presents its 10-entry picker as if the platform had only 10 sessions. The picker is paginated: `n` / `p` move between pages, numbering is global (page 2 starts at 11), Enter still cancels, and the header shows `page 1/2, 19 total`. `--all` (optionally `--limit `) and a session-id prefix remain the non-interactive ways to reach older sessions. - `session rollback --cwd` compares Codex rollout paths after resolving them, so it deletes the copy in the workspace you named instead of reporting "not found" and leaving it. - `migrate --scrub` keeps an image block's local file path (the target can still read it); only archiving to the team repo drops it. Fidelity is now computed from what is actually written, so degraded blocks no longer count as preserved. - `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. diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 5687e29a3..392786ada 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1743,6 +1743,8 @@ All of these accept `--dry-run` and `-v`. `migrate --push` migrates and archives **Confirmations.** `--all` asks for confirmation once it passes 10 sessions (push: more than 5) and refuses outright in a non-interactive run, so a script cannot silently skip the batch — pass `-y` to confirm. Migrating a platform onto itself reuses the native session id and overwrites the source transcript, so it needs `--target-cwd ` (write a copy elsewhere) or `-y`. `push` also asks before archiving unredacted transcripts; `--scrub` redacts them first. +**Picking a session.** `migrate` without a session id lists 10 at a time: `n` / `p` move to the next / previous page, a number selects (numbering is global, so page 2 starts at 11), Enter cancels. Use `--all` (optionally `--limit `) to migrate without picking, or pass a session-id prefix to select one directly. + **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. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 1122f6d68..54cf3b849 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1633,6 +1633,8 @@ teamai session resume --platform claude-code # 恢复到本地 **确认提示。** `--all` 超过 10 条(push 为超过 5 条)会先列清单要求确认;非交互运行(CI / 脚本)直接拒绝并以非 0 退出,避免脚本以为整批都跑完了——用 `-y` 明确确认。同平台迁移会复用原生会话 id 覆盖源会话,必须给 `--target-cwd `(写到别处)或 `-y`。`push` 在归档未脱敏原文前也会先问一次,`--scrub` 可先脱敏。 +**选择会话。** `migrate` 不带会话 id 时每屏列出 10 条:`n` / `p` 翻到下一页 / 上一页,数字直接选择(编号是全局的,所以第二页从 11 开始),回车取消。用 `--all`(可配 `--limit `)无需挑选直接迁移,或给出会话 id 前缀指定某一条。 + **归档布局。** 会话按其工作目录的 git 标识归档到团队仓库的 `sessions/repos///`;非 git 目录的会话落入 `_unattributed`。归档键取自会话自身的工作区——而不是执行命令时所在的目录——从别的目录迁入也会归到正确的项目名下。CodeBuddy IDE 中无法还原工作区的会话会带警告归入 `_unattributed`。 **项目级 vs 用户级仓库。** `list` / `pull` / `resume` 按当前目录的 git remote 过滤,项目级团队仓库因此只显示本项目的会话。传入 `--repo-root <任意 clone>`(例如个人仓库)并配合 `--all`,即可跨全部归档项目读取。 diff --git a/src/__tests__/session-cmd.test.ts b/src/__tests__/session-cmd.test.ts index 8ed456ef6..c5fd34d00 100644 --- a/src/__tests__/session-cmd.test.ts +++ b/src/__tests__/session-cmd.test.ts @@ -483,6 +483,85 @@ describe('session push archive key (native cwd, not the run directory)', () => { }); }); +describe('session migrate interactive picker', () => { + /** + * 预先把答案排进 ask() 的队列。 + * + * readline 接口一个模块只建一次,所以 'line' 回调是复用的:答案可以直接 + * 灌进去,ask() 会按序消费(ask 还没调到时先进 lineQueue)。 + */ + function feedAnswers(answers: string[]): void { + const push = () => { + for (const a of answers) mocks.lineCb?.(a); + }; + if (mocks.lineCb) { + push(); + return; + } + mocks.lineArmed = push; // 等第一次 createInterface + } + + it('pages with n and migrates a session from the second page', async () => { + // 12 sessions: the picker shows 10, the 11th is reachable only after paging. + const sessions = Array.from({ length: 12 }, (_, i) => + mkSession({ + sessionId: `s-${String(i).padStart(2, '0')}`, + title: `task ${i}`, + updatedAt: `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00.000Z`, + }), + ); + // Newest first, so [11] is the 11th in the list = sessions[1]. + const expected = sessions[1].sessionId; + fakeAdapter(sessions, 'fakeplat'); + mocks.adaptersByPlatform['tgtplat'] = { + platform: 'tgtplat', + listConversations: vi.fn(async () => []), + readSession: vi.fn(async () => sessions[0]), + }; + mocks.previewResult = { + sourcePlatform: 'fakeplat', + targetPlatform: 'tgtplat', + sessionTitle: 'task 1', + sessionId: expected, + cwd: '/run/dir', + messageCount: 1, + fidelity: { score: 1, mode: 1, preservedBlocks: 1, totalBlocks: 1, degradedBlocks: 0, degradations: [], warnings: [] }, + }; + mocks.migrateResult = { + success: true, + targetSessionId: 'tgt-1', + targetFilePath: '/tmp/tgt.jsonl', + preview: { fidelity: { score: 1 } }, + }; + const { MigrationEngine } = await import('../session-flow/migrate.js'); + const previewSpy = vi.spyOn(MigrationEngine.prototype, 'preview'); + + feedAnswers(['n', '11']); + await runSession('migrate', '-s', 'fakeplat', '-t', 'tgtplat', '--cwd', '/run/dir'); + + // The picker is on page 2 by the time the number is entered, and the + // selected id is the 11th newest -- unreachable before paging existed. + expect(out.join('\n')).toContain('page 2/2'); + expect(previewSpy).toHaveBeenCalledWith(expected, '/run/dir'); + }); + + it('cancels on an empty answer instead of paging forever', async () => { + fakeAdapter([mkSession({ sessionId: 's-1' })], 'fakeplat'); + mocks.adaptersByPlatform['tgtplat'] = { + platform: 'tgtplat', + listConversations: vi.fn(async () => []), + readSession: vi.fn(async () => mkSession()), + }; + + // EOF (piped input) reads as an empty answer: the picker must stop instead + // of paging forever. + feedAnswers(['']); + await runSession('migrate', '-s', 'fakeplat', '-t', 'tgtplat', '--cwd', '/run/dir'); + + expect(out.join('\n')).toContain('Cancelled.'); + }); +}); + describe('session migrate --push archive key', () => { it('archives under the target session native cwd identity', async () => { mocks.previewResult = { diff --git a/src/session-flow/session-cmd.ts b/src/session-flow/session-cmd.ts index 37d5e75d4..cacd3a754 100644 --- a/src/session-flow/session-cmd.ts +++ b/src/session-flow/session-cmd.ts @@ -66,6 +66,9 @@ let sharedRl: readline.Interface | null = null; /** --all 批量迁移时,超过这个条数先列清单要求确认(-y 跳过)。 */ const BATCH_CONFIRM_THRESHOLD = 10; +/** 交互式选择会话时每页显示多少条(n/p 翻页)。 */ +const SESSION_PICKER_PAGE_SIZE = 10; + function startLineReader(): void { if (lineReaderStarted) return; @@ -433,27 +436,60 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { } targets = matches; } 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 = safeText(m.title); - const titleShown = title.length > 50 ? title.slice(0, 50) + '...' : title; - console.log(` [${i + 1}] ${m.sessionId.slice(0, 8)} ${titleShown} (${m.messageCount} msgs, ${formatBytes(m.sizeBytes)})`); - } - // 只列 10 条却不说明还有更多、怎么选到更多,用户会以为一共就 10 条。 - if (metas.length > recent.length) { - console.log(`\n Showing the ${recent.length} most recent of ${metas.length}. To migrate older ones:`); - console.log(` --all (every session, optionally --limit N) or migrate `); - } - 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; + // 交互式分页:10 条一屏,n/p 翻页,编号是全局序号(翻页后仍可直选)。 + // 只显示前 10 条又不给翻页手段时,会话一多用户就以为只有 10 条。 + const sorted = metas.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + const pageSize = SESSION_PICKER_PAGE_SIZE; + const pages = Math.max(1, Math.ceil(sorted.length / pageSize)); + let page = 0; + + for (;;) { + const start = page * pageSize; + const slice = sorted.slice(start, start + pageSize); + console.log( + `\nSessions on ${source} — ${pages > 1 ? `page ${page + 1}/${pages}, ` : ''}${sorted.length} total:`, + ); + for (let i = 0; i < slice.length; i++) { + const m = slice[i]; + const title = safeText(m.title); + const titleShown = title.length > 50 ? title.slice(0, 50) + '...' : title; + console.log( + ` [${start + i + 1}] ${m.sessionId.slice(0, 8)} ${titleShown} (${m.messageCount} msgs, ${formatBytes(m.sizeBytes)})`, + ); + } + + const ans = ( + await ask( + pages > 1 + ? '\nSelect a number, n = next page, p = previous page, Enter = cancel: ' + : '\nSelect session (number) or Enter to cancel: ', + ) + ) + .trim() + .toLowerCase(); + // Enter (and EOF, which reads as an empty answer) still cancels: + // a non-interactive run must never sit in a paging loop. + if (!ans || ans === 'q' || ans === 'quit' || ans === 'exit') { + console.log('Cancelled.'); + return; + } + if (pages > 1 && (ans === 'n' || ans === 'next' || ans === 'd')) { + page = (page + 1) % pages; + continue; + } + if (pages > 1 && (ans === 'p' || ans === 'prev' || ans === 'previous' || ans === 'u')) { + page = (page - 1 + pages) % pages; + continue; + } + const num = parseInt(ans, 10); + if (!Number.isNaN(num) && num >= 1 && num <= sorted.length) { + targets = [sorted[num - 1]]; + break; + } + console.log( + `Enter a number between 1 and ${sorted.length}${pages > 1 ? ', or n/p to change page' : ''}.`, + ); } - targets = [recent[num - 1]]; } const engine = new MigrationEngine(source, target); From 05ad839fe6ce8ac708b91ae795bf5ee704240804 Mon Sep 17 00:00:00 2001 From: lurkacai Date: Tue, 29 Sep 2026 16:14:40 +0800 Subject: [PATCH 26/28] fix(session): index Cursor bubble deletes and load session commands only for session A case-insensitive LIKE on cursorDiskKV scanned the whole state database, so registration timed out and the migrated session never appeared. Range deletes use the unique index, and a non-UUID id is rejected before it can act as a LIKE wildcard. Session command registration no longer sits on the startup path of every other command. Co-authored-by: Cursor --- src/__tests__/cursor-bubble-range.test.ts | 34 ++++++++++++++++++ src/index.ts | 13 +++++-- src/session-flow/cursor-store.ts | 43 ++++++++++++++++++++--- 3 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/cursor-bubble-range.test.ts diff --git a/src/__tests__/cursor-bubble-range.test.ts b/src/__tests__/cursor-bubble-range.test.ts new file mode 100644 index 000000000..90ef57651 --- /dev/null +++ b/src/__tests__/cursor-bubble-range.test.ts @@ -0,0 +1,34 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { assertCursorSessionId, cursorBubbleCleanupWhere } from '../session-flow/cursor-store.js'; + +describe('cursor bubble cleanup', () => { + it('uses an index range and rejects wildcard ids', () => { + const id = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const where = cursorBubbleCleanupWhere(id); + expect(where.toUpperCase()).not.toContain('LIKE'); + expect(assertCursorSessionId('%')).toBe('invalid session id'); + expect(assertCursorSessionId(id)).toBeNull(); + + const db = path.join(os.tmpdir(), `teamai-bubble-range-${process.pid}.db`); + const sql = ` + CREATE TABLE cursorDiskKV (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB); + INSERT INTO cursorDiskKV (key, value) VALUES + ('bubbleId:${id}:ffffffff-ffff-4fff-8fff-ffffffffffff', 'own'), + ('bubbleId:${id}-extra:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'longer'), + ('bubbleId:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb:aaa', 'other'); + DELETE FROM cursorDiskKV WHERE ${where}; + SELECT key FROM cursorDiskKV ORDER BY key; + `; + const r = spawnSync('sqlite3', [db], { input: sql, encoding: 'utf-8' }); + fs.rmSync(db, { force: true }); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout.trim().split('\n').sort()).toEqual([ + 'bubbleId:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb:aaa', + `bubbleId:${id}-extra:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa`, + ].sort()); + }); +}); diff --git a/src/index.ts b/src/index.ts index b73fc9ebd..100c68e5e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -926,9 +926,16 @@ sessionCmd await saveSession({ ...globalOpts, ...cmdOpts }); }); -// SessionFlow: cross-platform session migration / sync / search / resume -const { registerSessionFlowCommands } = await import('./session-flow/session-cmd.js'); -registerSessionFlowCommands(sessionCmd); +// Load session migration commands only for `teamai session`. A failure must +// not skip digest, recall, and every command registered after this point. +if (process.argv[2] === 'session') { + try { + const { registerSessionFlowCommands } = await import('./session-flow/session-cmd.js'); + registerSessionFlowCommands(sessionCmd); + } catch (e) { + log.warn(`Session commands unavailable: ${(e as Error).message}`); + } +} program .command('digest') diff --git a/src/session-flow/cursor-store.ts b/src/session-flow/cursor-store.ts index b1caced09..3d549b8b6 100644 --- a/src/session-flow/cursor-store.ts +++ b/src/session-flow/cursor-store.ts @@ -525,6 +525,25 @@ function esc(value: string): string { return value.replace(/'/g, "''"); } +const CURSOR_SESSION_ID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Null when `id` is a UUID; otherwise a reason. Callers must not interpolate it into SQL. */ +export function assertCursorSessionId(id: string): string | null { + return CURSOR_SESSION_ID_RE.test(id) ? null : 'invalid session id'; +} + +/** + * Indexed delete of one composer's bubbles. LIKE cannot use the unique index + * (it is case-insensitive) and treats `%` / `_` as wildcards. + * The upper bound bumps the separating `:` to `;`, which sorts after every + * `bubbleId::`, including bubble ids that start with a-f. + */ +export function cursorBubbleCleanupWhere(composerId: string): string { + const id = esc(composerId); + return `key >= 'bubbleId:${id}:' AND key < 'bubbleId:${id};'`; +} + export interface RegisterResult { ok: boolean; /** Failure reason (ok=false; for the CLI debug log). */ @@ -539,6 +558,9 @@ export interface RegisterResult { * migration failure -- the transcript is on disk, a failed registration only */ export function registerCursorComposer(args: RegisterCursorComposerArgs): RegisterResult { + const invalidId = assertCursorSessionId(args.composerId); + if (invalidId) return { ok: false, reason: invalidId }; + const dbPath = getCursorStateDbPath(); if (!dbPath) return { ok: false, reason: 'unsupported platform' }; if (!fs.existsSync(dbPath)) return { ok: false, reason: `state db not found: ${dbPath}` }; @@ -579,7 +601,7 @@ export function registerCursorComposer(args: RegisterCursorComposerArgs): Regist `VALUES ('${esc(args.composerId)}','${esc(ws.id)}',${createdMs},${recencyMs},0,0,${recencyMs},NULL,'${esc(JSON.stringify(head))}',NULL);`, ); // Re-migrating the same session: clear the old bubbles to avoid leftovers - stmts.push(`DELETE FROM cursorDiskKV WHERE key LIKE 'bubbleId:${esc(args.composerId)}:%';`); + stmts.push(`DELETE FROM cursorDiskKV WHERE ${cursorBubbleCleanupWhere(args.composerId)};`); stmts.push( 'INSERT OR REPLACE INTO cursorDiskKV (key, value) VALUES ' + `('composerData:${esc(args.composerId)}','${esc(JSON.stringify(composer))}');`, @@ -603,7 +625,10 @@ export function registerCursorComposer(args: RegisterCursorComposerArgs): Regist timeout: 30_000, }); if (r.status !== 0) { - return { ok: false, reason: (r.stderr?.toString() ?? '').trim().slice(0, 300) || `sqlite3 exit ${r.status}` }; + const stderr = (r.stderr?.toString() ?? '').trim(); + const signal = r.signal ? ` signal ${r.signal}` : ''; + const err = r.error?.message ? ` ${r.error.message}` : ''; + return { ok: false, reason: (stderr || `sqlite3 exit ${r.status}${signal}${err}`).slice(0, 300) }; } } catch (e) { return { ok: false, reason: (e as Error).message }; @@ -617,15 +642,20 @@ export function registerCursorComposer(args: RegisterCursorComposerArgs): Regist * Only rows for our own composerId are touched; best-effort. */ export function unregisterCursorComposer(composerId: string): RegisterResult { + const invalidId = assertCursorSessionId(composerId); + if (invalidId) return { ok: false, reason: invalidId }; + const dbPath = getCursorStateDbPath(); if (!dbPath || !fs.existsSync(dbPath)) return { ok: false, reason: 'state db not found' }; const sqlite3 = findSqlite3(); if (!sqlite3) return { ok: false, reason: 'sqlite3 CLI not found' }; + const id = esc(composerId); const sql = 'BEGIN IMMEDIATE;\n' + - `DELETE FROM composerHeaders WHERE composerId='${esc(composerId)}';\n` + - `DELETE FROM cursorDiskKV WHERE key='composerData:${esc(composerId)}' OR key LIKE 'bubbleId:${esc(composerId)}:%';\n` + + `DELETE FROM composerHeaders WHERE composerId='${id}';\n` + + `DELETE FROM cursorDiskKV WHERE key='composerData:${id}';\n` + + `DELETE FROM cursorDiskKV WHERE ${cursorBubbleCleanupWhere(composerId)};\n` + 'COMMIT;'; try { @@ -635,7 +665,10 @@ export function unregisterCursorComposer(composerId: string): RegisterResult { timeout: 30_000, }); if (r.status !== 0) { - return { ok: false, reason: (r.stderr?.toString() ?? '').trim().slice(0, 300) || `sqlite3 exit ${r.status}` }; + const stderr = (r.stderr?.toString() ?? '').trim(); + const signal = r.signal ? ` signal ${r.signal}` : ''; + const err = r.error?.message ? ` ${r.error.message}` : ''; + return { ok: false, reason: (stderr || `sqlite3 exit ${r.status}${signal}${err}`).slice(0, 300) }; } } catch (e) { return { ok: false, reason: (e as Error).message }; From 43206305b094f473bb11f79e50c6df5f86293444 Mon Sep 17 00:00:00 2001 From: lurkacai Date: Tue, 29 Sep 2026 16:21:30 +0800 Subject: [PATCH 27/28] fix(session): keep archives off the source session and off the working tree Same-platform writes now derive a new id instead of replacing the original transcript. Session sync resolves the team repo from teamai config rather than the current directory, and git push/pull always name that repo's branch. CodeBuddy IDE message directories are moved aside before a rewrite, and Codex no longer reindexes the whole store when the CLI returns no JSON. Co-authored-by: Cursor --- src/session-flow/adapters/claude-code.ts | 14 ++++------- src/session-flow/adapters/codebuddy.ts | 11 ++++----- src/session-flow/adapters/codex.ts | 15 +++++++----- src/session-flow/adapters/cursor.ts | 5 ++-- src/session-flow/adapters/workbuddy.ts | 6 ++--- src/session-flow/ide-history.ts | 7 +++++- src/session-flow/ids.ts | 25 ++++++++++++++++++++ src/session-flow/session-cmd.ts | 30 +++++++++++++++++------- src/session-flow/sync.ts | 16 ++++++++----- 9 files changed, 85 insertions(+), 44 deletions(-) diff --git a/src/session-flow/adapters/claude-code.ts b/src/session-flow/adapters/claude-code.ts index b12d30010..5bd937904 100644 --- a/src/session-flow/adapters/claude-code.ts +++ b/src/session-flow/adapters/claude-code.ts @@ -23,7 +23,7 @@ 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 { deriveTargetSessionId } from '../ids.js'; +import { resolveWriteSessionId } from '../ids.js'; import { imagePlaceholderText } from '../ir.js'; import { getClaudeCodeProjectsDir, @@ -625,14 +625,10 @@ export class ClaudeCodeAdapter extends AgentAdapter { } async writeSession(session: Session, projectPath?: string): Promise { - // 确定 session_id(必须是 UUIDv4):已是 v4 则沿用,否则确定性派生—— - // 随机生成会让重复迁移产生 id 不同、内容相同的重复会话。 - // 这里**故意不把 cwd 纳入派生**:本平台的会话按项目目录存放 - // (`//.jsonl`),同一 id 落在两个目录就是两份 - // 独立文件,不存在互相覆盖。加上 cwd 只会让已迁移的会话 id 漂移。 - const sessionId = isUuidV4(session.sessionId) - ? session.sessionId - : deriveTargetSessionId(this.platform, session.sessionId); + // Same-platform archives derive a new id so the original jsonl is not overwritten. + // cwd is intentionally omitted: this store is per project directory, so one + // id in two directories is already two files. + const sessionId = resolveWriteSessionId(this.platform, session); // 确定目标目录 const cwd = projectPath ?? session.cwd; diff --git a/src/session-flow/adapters/codebuddy.ts b/src/session-flow/adapters/codebuddy.ts index 019431a52..af0eacb24 100644 --- a/src/session-flow/adapters/codebuddy.ts +++ b/src/session-flow/adapters/codebuddy.ts @@ -27,7 +27,7 @@ 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 { imagePlaceholderText } from '../ir.js'; -import { deriveTargetSessionId } from '../ids.js'; +import { resolveWriteSessionId } from '../ids.js'; import { getCodeBuddyProjectsDir, encodeCwdCodeBuddy, @@ -486,12 +486,9 @@ export class CodeBuddyAdapter extends AgentAdapter { } async writeSession(session: Session, projectPath?: string): Promise { - // 非 UUID 源 id 用确定性派生(同一源会话反复迁移命中同一个 id → 不产生重复会话) - // 与 claude-code 同理,故意不纳入 cwd:会话文件按项目目录隔离, - // 同 id 不同目录各是一份,不会互相覆盖。 - const sessionId = isUuid(session.sessionId) - ? session.sessionId - : deriveTargetSessionId('codebuddy', session.sessionId); + // Same-platform archives derive a new id. cwd stays out of the hash: + // sessions live under the project directory, so one id in two dirs is two files. + const sessionId = resolveWriteSessionId(this.platform, session); const cwd = projectPath ?? session.cwd; const projDir = path.join(getCodeBuddyProjectsDir(), encodeCwdCodeBuddy(cwd)); diff --git a/src/session-flow/adapters/codex.ts b/src/session-flow/adapters/codex.ts index 1b59c5ef1..5efd36fd1 100644 --- a/src/session-flow/adapters/codex.ts +++ b/src/session-flow/adapters/codex.ts @@ -44,7 +44,7 @@ import { AgentAdapter, type SessionMeta } from './base.js'; import type { Session, Message, ContentBlock, TextBlock, ThinkingBlock, ToolCallBlock, ToolResultBlock } from '../ir.js'; import { imagePlaceholderText } from '../ir.js'; import { titleFromUserText, visibleUserText } from '../title.js'; -import { deriveTargetSessionId } from '../ids.js'; +import { deriveTargetSessionId, resolveWriteSessionId } from '../ids.js'; import { findSqlite3 } from '../sqlite.js'; import { log } from '../../utils/logger.js'; import { @@ -647,9 +647,7 @@ export class CodexAdapter extends AgentAdapter { // cwd 参与派生:Codex rollout 的 sessionId 是全局键,同一源会话迁到两个 // 工作区若共用 id,第二份会把第一份顶掉。 const cwd = projectPath ?? session.cwd; - const sessionId = isUuidV7(session.sessionId) - ? session.sessionId - : deriveTargetSessionId(this.platform, session.sessionId, cwd); + const sessionId = resolveWriteSessionId(this.platform, session, cwd); // 损坏输入防御:session.createdAt 非法时 new Date(...) 得到 Invalid Date, // 直接 toISOString() 会抛 RangeError 让整个写入崩溃。 @@ -906,8 +904,13 @@ export class CodexAdapter extends AgentAdapter { let status = await runApply(); if (status === 'migrated' || status === 'already_paginated') return; - - // Not indexed (missing_sqlite_metadata etc.) -> register via app-server thread/list, retry + // A missing JSON report (timeout, incompatible CLI) must not scan every rollout. + if (status !== 'missing_sqlite_metadata') { + log.warn( + `Codex indexing skipped for ${sessionId}: ${status ?? 'no JSON report'}. The session may stay invisible until Codex reindexes.`, + ); + return; + } await this.indexThreadViaAppServer(bin, codexHome); await runApply(); } diff --git a/src/session-flow/adapters/cursor.ts b/src/session-flow/adapters/cursor.ts index cb46a6048..a390a48f6 100644 --- a/src/session-flow/adapters/cursor.ts +++ b/src/session-flow/adapters/cursor.ts @@ -39,6 +39,7 @@ import { } from '../fs.js'; import { cleanTitleText, fallbackTitle, isInjectedText, titleFromCandidates, titleFromUserText, extractUserText, isRenderableText, visibleUserText } from '../title.js'; import { registerCursorComposer, unregisterCursorComposer, type CursorComposerMessage, type CursorComposerTool } from '../cursor-store.js'; +import { resolveWriteSessionId } from '../ids.js'; import { log } from '../../utils/logger.js'; // --------------------------------------------------------------------------- @@ -449,9 +450,7 @@ export class CursorAdapter extends AgentAdapter { async writeSession(session: Session, projectPath?: string): Promise { const cwd = projectPath ?? session.cwd; // Deterministic id: re-migrations of the same source session hit the same composerId (no more per-run copies) - const sessionId = isUuid(session.sessionId) - ? session.sessionId - : deriveCursorId(session.platform || 'unknown', session.sessionId, cwd); + const sessionId = resolveWriteSessionId(this.platform, session, cwd); const projDir = path.join(getCursorProjectsDir(), encodeCwdGeneric(cwd)); const transcriptDir = path.join(projDir, 'agent-transcripts', sessionId); const jsonlPath = path.join(transcriptDir, `${sessionId}.jsonl`); diff --git a/src/session-flow/adapters/workbuddy.ts b/src/session-flow/adapters/workbuddy.ts index 4f388f444..07eeeff7d 100644 --- a/src/session-flow/adapters/workbuddy.ts +++ b/src/session-flow/adapters/workbuddy.ts @@ -39,7 +39,7 @@ import { removeDirRecursive, } from '../fs.js'; import { cleanTitleText, fallbackTitle, isInjectedText, titleFromCandidates, titleFromUserText } from '../title.js'; -import { deriveTargetSessionId } from '../ids.js'; +import { resolveWriteSessionId } from '../ids.js'; import { registerWorkBuddySession, unregisterWorkBuddySession } from '../workbuddy-store.js'; import { log } from '../../utils/logger.js'; @@ -512,9 +512,7 @@ export class WorkBuddyAdapter extends AgentAdapter { const cwd = projectPath ?? session.cwd; // 非 UUID 源 id 用确定性派生(同一源会话反复迁移命中同一个 id → 不产生重复会话) // cwd 参与派生:WorkBuddy 的会话记录是全局键,同名 id 迁到两个工作区会互相覆盖。 - const sessionId = isUuidV4(session.sessionId) - ? session.sessionId - : deriveTargetSessionId('workbuddy', session.sessionId, cwd); + const sessionId = resolveWriteSessionId(this.platform, session, cwd); // WorkBuddy 与 CodeBuddy 同构:项目目录名**保留空格**(实测 CodeBuddy 落盘为 // `Users-caiwenzhe-Desktop-Code-teamai cli`)。用 encodeCwdGeneric 会把空格也换成 // `-`,目录名与客户端按当前 cwd 算出的不一致 → 会话不出现在该项目列表里。 diff --git a/src/session-flow/ide-history.ts b/src/session-flow/ide-history.ts index ad3d05171..bdf912bbf 100644 --- a/src/session-flow/ide-history.ts +++ b/src/session-flow/ide-history.ts @@ -628,12 +628,17 @@ export function writeIdeSession(session: Session, cwd: string): IdeSyncResult { // 幂等:重跑迁移必须先清空 messages/。 // 消息文件名 = 消息 id,旧版本残留的文件既不会被覆盖也不会被索引, // 会变成孤儿并让目录随迁移次数无上限增长。 - if (fs.existsSync(msgDir)) { + const marker = path.join(msgDir, '.teamai-migrated'); + if (fs.existsSync(msgDir) && !fs.existsSync(marker)) { + const backup = path.join(convDir, `messages.backup-${Date.now()}`); + fs.renameSync(msgDir, backup); + } else 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 }); + fs.writeFileSync(marker, new Date().toISOString(), 'utf-8'); // 落盘图片资源:与原生存储一致放 /assets/,消息里用 // codebuddy-asset://assets/ 相对引用。某个资源失败只降级该图片 diff --git a/src/session-flow/ids.ts b/src/session-flow/ids.ts index 7cb11c8f3..84fac5ffe 100644 --- a/src/session-flow/ids.ts +++ b/src/session-flow/ids.ts @@ -56,3 +56,28 @@ export function deriveTargetSessionId( hex.slice(20, 32), ].join('-'); } + +const UUID_ANY_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const SAFE_SESSION_FILE_ID_RE = /^[A-Za-z0-9_-]+$/; + +/** + * Id to write under `targetPlatform`. + * Same-platform archives always derive, so the original file is not overwritten. + * A dashed UUID from another platform is reused. `targetCwd` is hashed for + * stores that key sessions globally (Cursor, WorkBuddy, Codex). + */ +export function resolveWriteSessionId( + targetPlatform: string, + session: { sessionId: string; platform: string }, + targetCwd?: string, +): string { + if (session.platform === targetPlatform) { + return deriveTargetSessionId(targetPlatform, session.sessionId, targetCwd); + } + if (UUID_ANY_RE.test(session.sessionId)) return session.sessionId; + return deriveTargetSessionId(targetPlatform, session.sessionId, targetCwd); +} + +export function isSafeSessionFileId(sessionId: string): boolean { + return SAFE_SESSION_FILE_ID_RE.test(sessionId); +} diff --git a/src/session-flow/session-cmd.ts b/src/session-flow/session-cmd.ts index cacd3a754..7d83d6dd0 100644 --- a/src/session-flow/session-cmd.ts +++ b/src/session-flow/session-cmd.ts @@ -212,8 +212,22 @@ function deriveArchiveIdentity(session: { cwd: string }, platform: string): stri * 获取团队仓根目录。 * 优先用 --repo-root;否则用 cwd(假设 cwd 就是团队仓 clone)。 */ -function resolveRepoRoot(repoRoot?: string): string { - return repoRoot ?? process.cwd(); +async function resolveRepoRoot(repoRoot?: string): Promise { + if (repoRoot?.trim()) return repoRoot.trim(); + const { detectProjectConfig } = await import('../config.js'); + const { getReportsDir, isSelfMode } = await import('../types.js'); + const { ensureReportsWorktree } = await import('../utils/reports-branch.js'); + const cfg = await detectProjectConfig(); + if (!cfg || cfg.repo.kind === 'http' || !cfg.repo.localPath?.trim()) { + throw new Error( + 'No teamai project config found. Run `teamai init` or pass --repo-root. Session sync will not use the current working directory.', + ); + } + if (isSelfMode(cfg)) { + await ensureReportsWorktree(cfg); + return getReportsDir(cfg); + } + return cfg.repo.localPath; } /** @@ -572,7 +586,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { // --push: 推送到团队仓 if (opts.push && migrated > 0) { - const repoRoot = resolveRepoRoot(opts.repoRoot); + const repoRoot = await resolveRepoRoot(opts.repoRoot); // 跨目录展开时会话不属于 workCwd,author 应取自会话真实所在的仓库 const authorCwd = migratedTargets.find((t) => t.cwd)?.cwd ?? workCwd; const author = getGitAuthor(authorCwd); @@ -673,7 +687,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { process.exit(1); } const workCwd = opts.cwd ?? process.cwd(); - const repoRoot = resolveRepoRoot(opts.repoRoot); + const repoRoot = await resolveRepoRoot(opts.repoRoot); const author = getGitAuthor(workCwd); const adapter = safeGetAdapter(source); // --all:listConversations() 无参即枚举该平台的全部工作区目录(P5), @@ -817,7 +831,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { .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 repoRoot = await resolveRepoRoot(opts.repoRoot); const syncMgr = new SyncManager(repoRoot); if (isDryRun()) { @@ -851,7 +865,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { .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 repoRoot = await resolveRepoRoot(opts.repoRoot); const repoIdentity = resolveRepoIdentity(workCwd); const syncMgr = new SyncManager(repoRoot); @@ -910,7 +924,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { .option('--author ', 'Author of the session (if ambiguous)') .action(async (sessionName, opts) => { const workCwd = opts.cwd ?? process.cwd(); - const repoRoot = resolveRepoRoot(opts.repoRoot); + const repoRoot = await resolveRepoRoot(opts.repoRoot); const repoIdentity = resolveRepoIdentity(workCwd); const syncMgr = new SyncManager(repoRoot); @@ -959,7 +973,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { .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 repoRoot = await resolveRepoRoot(opts.repoRoot); const repoIdentity = resolveRepoIdentity(workCwd); const limitRaw = parseInt(opts.limit, 10); const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 10; diff --git a/src/session-flow/sync.ts b/src/session-flow/sync.ts index 7460a33a6..693859785 100644 --- a/src/session-flow/sync.ts +++ b/src/session-flow/sync.ts @@ -1008,17 +1008,21 @@ export class SyncManager { } gitPush(remote = 'origin', branch?: string): void { - const args = ['push', remote]; - if (branch) args.push(branch); + const ref = branch?.trim() || this.runGit(['rev-parse', '--abbrev-ref', 'HEAD']); + if (!ref || ref === 'HEAD') { + throw new Error('Refusing to push without an explicit branch refspec.'); + } // Errors must reach the caller: swallowing them here let `push` print // "✓ Pushed" after a rejected or unreachable remote. - this.runGit(args); + this.runGit(['push', remote, ref]); } gitPull(remote = 'origin', branch?: string): void { - const args = ['pull', remote]; - if (branch) args.push(branch); - this.runGit(args); + const ref = branch?.trim() || this.runGit(['rev-parse', '--abbrev-ref', 'HEAD']); + if (!ref || ref === 'HEAD') { + throw new Error('Refusing to pull without an explicit branch refspec.'); + } + this.runGit(['pull', remote, ref]); } getSyncStatus(): { uncommitted: number; ahead: number; behind: number } { From bb1aee5de5c463248e5ad0d6cd6d3cc1f05f6f7f Mon Sep 17 00:00:00 2001 From: lurkacai Date: Tue, 29 Sep 2026 16:56:46 +0800 Subject: [PATCH 28/28] fix(session): register subcommands behind global flags and do not publish reports on read teamai --dry-run session and the command-table import never saw migrate or push because registration required argv[2] to be session. Resolving the team repo also created and pushed teamai-reports before a dry-run or list could return. Co-authored-by: Cursor --- src/__tests__/session-flow-register.test.ts | 31 +++++++++++++++++++++ src/index.ts | 9 ++++-- src/session-flow/register-gate.ts | 8 ++++++ src/session-flow/session-cmd.ts | 23 +++++++++------ 4 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/session-flow-register.test.ts create mode 100644 src/session-flow/register-gate.ts diff --git a/src/__tests__/session-flow-register.test.ts b/src/__tests__/session-flow-register.test.ts new file mode 100644 index 000000000..4ee8c101b --- /dev/null +++ b/src/__tests__/session-flow-register.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { shouldRegisterSessionFlowCommands } from '../session-flow/register-gate.js'; + +describe('shouldRegisterSessionFlowCommands', () => { + const env = {} as NodeJS.ProcessEnv; + + it('registers for a bare session command', () => { + expect(shouldRegisterSessionFlowCommands(['node', 'teamai', 'session', 'migrate'], env)).toBe(true); + }); + + it('registers when a global option precedes session', () => { + expect( + shouldRegisterSessionFlowCommands(['node', 'teamai', '--dry-run', 'session', 'migrate'], env), + ).toBe(true); + expect(shouldRegisterSessionFlowCommands(['node', 'teamai', '-v', 'session', 'push'], env)).toBe(true); + expect(shouldRegisterSessionFlowCommands(['node', 'teamai', 'help', 'session'], env)).toBe(true); + }); + + it('skips unrelated commands', () => { + expect(shouldRegisterSessionFlowCommands(['node', 'teamai', 'digest'], env)).toBe(false); + expect(shouldRegisterSessionFlowCommands(['node', 'teamai', 'push'], env)).toBe(false); + }); + + it('registers when only the command table is being read', () => { + expect( + shouldRegisterSessionFlowCommands(['node', 'vitest'], { + TEAMAI_COMMAND_TABLE_ONLY: '1', + }), + ).toBe(true); + }); +}); diff --git a/src/index.ts b/src/index.ts index 100c68e5e..535026d5d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import type { GlobalOptions, LocalConfig } from './types.js'; import type { MaintenancePaths } from './maintenance/paths.js'; import { TEAMAI_HOOK_SUBCOMMANDS } from './hooks.js'; import { registerPackagesCommand } from './pkg/register-command.js'; +import { shouldRegisterSessionFlowCommands } from './session-flow/register-gate.js'; // Commands that migrate a legacy `/.teamai/` into the partition on first // run (issue #374 P1-3). Only write commands trigger it; read-only commands rely @@ -926,9 +927,11 @@ sessionCmd await saveSession({ ...globalOpts, ...cmdOpts }); }); -// Load session migration commands only for `teamai session`. A failure must -// not skip digest, recall, and every command registered after this point. -if (process.argv[2] === 'session') { +// Load session migration commands when this process is actually about session, +// or when another tool is only reading the command table. +// `argv[2] === 'session'` misses `teamai --dry-run session …`, `teamai -v session …`, +// and `teamai help session`. Digest, recall, push, and pull still skip this import. +if (shouldRegisterSessionFlowCommands()) { try { const { registerSessionFlowCommands } = await import('./session-flow/session-cmd.js'); registerSessionFlowCommands(sessionCmd); diff --git a/src/session-flow/register-gate.ts b/src/session-flow/register-gate.ts new file mode 100644 index 000000000..0516da97a --- /dev/null +++ b/src/session-flow/register-gate.ts @@ -0,0 +1,8 @@ +/** Whether this process should register `teamai session` migration subcommands. */ +export function shouldRegisterSessionFlowCommands( + argv: readonly string[] = process.argv, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (env.TEAMAI_COMMAND_TABLE_ONLY) return true; + return argv.includes('session'); +} diff --git a/src/session-flow/session-cmd.ts b/src/session-flow/session-cmd.ts index 7d83d6dd0..9413621b4 100644 --- a/src/session-flow/session-cmd.ts +++ b/src/session-flow/session-cmd.ts @@ -212,7 +212,10 @@ function deriveArchiveIdentity(session: { cwd: string }, platform: string): stri * 获取团队仓根目录。 * 优先用 --repo-root;否则用 cwd(假设 cwd 就是团队仓 clone)。 */ -async function resolveRepoRoot(repoRoot?: string): Promise { +async function resolveRepoRoot( + repoRoot?: string, + options?: { materialize?: boolean }, +): Promise { if (repoRoot?.trim()) return repoRoot.trim(); const { detectProjectConfig } = await import('../config.js'); const { getReportsDir, isSelfMode } = await import('../types.js'); @@ -224,7 +227,11 @@ async function resolveRepoRoot(repoRoot?: string): Promise { ); } if (isSelfMode(cfg)) { - await ensureReportsWorktree(cfg); + // Readers and --dry-run must not publish a brand-new teamai-reports branch. + // The default ensure() pushes the orphan branch as soon as it creates it. + if (options?.materialize !== false) { + await ensureReportsWorktree(cfg, { pushIfCreated: false }); + } return getReportsDir(cfg); } return cfg.repo.localPath; @@ -586,7 +593,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { // --push: 推送到团队仓 if (opts.push && migrated > 0) { - const repoRoot = await resolveRepoRoot(opts.repoRoot); + const repoRoot = await resolveRepoRoot(opts.repoRoot, { materialize: !isDryRun() }); // 跨目录展开时会话不属于 workCwd,author 应取自会话真实所在的仓库 const authorCwd = migratedTargets.find((t) => t.cwd)?.cwd ?? workCwd; const author = getGitAuthor(authorCwd); @@ -687,7 +694,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { process.exit(1); } const workCwd = opts.cwd ?? process.cwd(); - const repoRoot = await resolveRepoRoot(opts.repoRoot); + const repoRoot = await resolveRepoRoot(opts.repoRoot, { materialize: !isDryRun() }); const author = getGitAuthor(workCwd); const adapter = safeGetAdapter(source); // --all:listConversations() 无参即枚举该平台的全部工作区目录(P5), @@ -831,7 +838,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { .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 = await resolveRepoRoot(opts.repoRoot); + const repoRoot = await resolveRepoRoot(opts.repoRoot, { materialize: !isDryRun() }); const syncMgr = new SyncManager(repoRoot); if (isDryRun()) { @@ -865,7 +872,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { .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 = await resolveRepoRoot(opts.repoRoot); + const repoRoot = await resolveRepoRoot(opts.repoRoot, { materialize: !isDryRun() }); const repoIdentity = resolveRepoIdentity(workCwd); const syncMgr = new SyncManager(repoRoot); @@ -924,7 +931,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { .option('--author ', 'Author of the session (if ambiguous)') .action(async (sessionName, opts) => { const workCwd = opts.cwd ?? process.cwd(); - const repoRoot = await resolveRepoRoot(opts.repoRoot); + const repoRoot = await resolveRepoRoot(opts.repoRoot, { materialize: !isDryRun() }); const repoIdentity = resolveRepoIdentity(workCwd); const syncMgr = new SyncManager(repoRoot); @@ -973,7 +980,7 @@ export function registerSessionFlowCommands(sessionCmd: Command): void { .option('--all', 'Search across all projects (not just current)') .action(async (query, opts) => { const workCwd = opts.cwd ?? process.cwd(); - const repoRoot = await resolveRepoRoot(opts.repoRoot); + const repoRoot = await resolveRepoRoot(opts.repoRoot, { materialize: !isDryRun() }); const repoIdentity = resolveRepoIdentity(workCwd); const limitRaw = parseInt(opts.limit, 10); const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 10;