From 7ae2c9ba087db8e7adfc030a6fb28aa2106d7d95 Mon Sep 17 00:00:00 2001 From: zbc Date: Mon, 25 May 2026 19:13:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DLLM=20response=E4=B8=AD?= =?UTF-8?q?=E6=9E=84=E5=BB=BAdiff=E4=B8=8D=E5=AE=8C=E5=96=84=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 10 +- package.json | 10 +- src/constants/prompts.ts | 6 +- src/services/rawDump/README.md | 16 +- src/services/rawDump/worker.ts | 500 +++++++++++++++++++++++++++------ 5 files changed, 446 insertions(+), 96 deletions(-) diff --git a/bun.lock b/bun.lock index fa25df5ff..69896574f 100644 --- a/bun.lock +++ b/bun.lock @@ -6,10 +6,14 @@ "name": "@costrict/csc", "dependencies": { "@agentclientprotocol/sdk": "^0.19.0", + "@anthropic-ai/sandbox-runtime": "0.0.44", "@claude-code-best/mcp-chrome-bridge": "^2.0.8", + "@protobufjs/utf8": "1.1.1", + "baseline-browser-mapping": "2.10.29", "gray-matter": "^4.0.3", "hono": "^4.12.15", "partial-json": "^0.1.7", + "pngjs": "5.0.0", "semver": "^7.7.4", "undici": "^7.24.6", "ws": "^8.20.0", @@ -25,7 +29,6 @@ "@anthropic-ai/claude-agent-sdk": "^0.2.87", "@anthropic-ai/foundry-sdk": "^0.2.3", "@anthropic-ai/mcpb": "^2.1.2", - "@anthropic-ai/sandbox-runtime": "^0.0.44", "@anthropic-ai/sdk": "^0.80.0", "@anthropic-ai/vertex-sdk": "^0.14.4", "@anthropic/ink": "workspace:*", @@ -136,7 +139,6 @@ "tree-kill": "^1.2.2", "turndown": "^7.2.2", "type-fest": "^5.5.0", - "typescript": "^6.0.2", "url-handler-napi": "workspace:*", "usehooks-ts": "^3.1.1", "vite": "^8.0.8", @@ -2696,7 +2698,7 @@ "type-is": ["type-is@2.0.1", "https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - "typescript": ["typescript@6.0.3", "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "unbash": ["unbash@3.0.0", "https://registry.npmmirror.com/unbash/-/unbash-3.0.0.tgz", {}, "sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA=="], @@ -2828,8 +2830,6 @@ "@anthropic-ai/vertex-sdk/google-auth-library": ["google-auth-library@9.15.1", "https://registry.npmmirror.com/google-auth-library/-/google-auth-library-9.15.1.tgz", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], - "@anthropic/remote-control-server/typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "@anthropic/remote-control-server/vite": ["vite@6.4.2", "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], "@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "https://registry.npmmirror.com/@aws-crypto/util/-/util-5.2.0.tgz", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], diff --git a/package.json b/package.json index 999adb7a1..6d736a030 100644 --- a/package.json +++ b/package.json @@ -82,26 +82,29 @@ }, "dependencies": { "@agentclientprotocol/sdk": "^0.19.0", + "@anthropic-ai/sandbox-runtime": "0.0.44", "@claude-code-best/mcp-chrome-bridge": "^2.0.8", + "@protobufjs/utf8": "1.1.1", + "baseline-browser-mapping": "2.10.29", "gray-matter": "^4.0.3", "hono": "^4.12.15", "partial-json": "^0.1.7", + "pngjs": "5.0.0", "semver": "^7.7.4", "undici": "^7.24.6", "ws": "^8.20.0" }, "devDependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", - "@ant/model-provider": "workspace:*", "@ant/claude-for-chrome-mcp": "workspace:*", "@ant/computer-use-input": "workspace:*", "@ant/computer-use-mcp": "workspace:*", "@ant/computer-use-swift": "workspace:*", + "@ant/model-provider": "workspace:*", "@anthropic-ai/bedrock-sdk": "^0.26.4", "@anthropic-ai/claude-agent-sdk": "^0.2.87", "@anthropic-ai/foundry-sdk": "^0.2.3", "@anthropic-ai/mcpb": "^2.1.2", - "@anthropic-ai/sandbox-runtime": "^0.0.44", "@anthropic-ai/sdk": "^0.80.0", "@anthropic-ai/vertex-sdk": "^0.14.4", "@anthropic/ink": "workspace:*", @@ -178,12 +181,12 @@ "fflate": "^0.8.2", "figures": "^6.1.0", "fuse.js": "^7.1.0", - "husky": "^9.1.7", "get-east-asian-width": "^1.5.0", "google-auth-library": "^10.6.2", "he": "^1.2.0", "highlight.js": "^11.11.1", "https-proxy-agent": "^8.0.0", + "husky": "^9.1.7", "ignore": "^7.0.5", "image-processor-napi": "workspace:*", "indent-string": "^5.0.0", @@ -212,7 +215,6 @@ "tree-kill": "^1.2.2", "turndown": "^7.2.2", "type-fest": "^5.5.0", - "typescript": "^6.0.2", "url-handler-napi": "workspace:*", "usehooks-ts": "^3.1.1", "vite": "^8.0.8", diff --git a/src/constants/prompts.ts b/src/constants/prompts.ts index e72282b76..ba7fd9beb 100644 --- a/src/constants/prompts.ts +++ b/src/constants/prompts.ts @@ -82,10 +82,14 @@ const BRIEF_PROACTIVE_SECTION: string | null = require('@claude-code-best/builtin-tools/tools/BriefTool/prompt.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/prompt.js') ).BRIEF_PROACTIVE_SECTION : null -const briefToolModule = +const _briefToolModule = feature('KAIROS') || feature('KAIROS_BRIEF') ? (require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js')) : null +const briefToolModule = + _briefToolModule && typeof _briefToolModule.isBriefEnabled === 'function' + ? _briefToolModule + : null const DISCOVER_SKILLS_TOOL_NAME: string | null = feature( 'EXPERIMENTAL_SKILL_SEARCH', ) diff --git a/src/services/rawDump/README.md b/src/services/rawDump/README.md index a2c48177d..c83ac6324 100644 --- a/src/services/rawDump/README.md +++ b/src/services/rawDump/README.md @@ -188,8 +188,20 @@ reportTurn(sessionId, assistantMessage.uuid, cwd) csc 没有 opencode 中的 `step-start`/`step-finish` snapshot 机制,采用以下策略: ### Conversation diff -- **唯一来源**:从当前 assistant message 的 `tool_use` blocks 中提取 `input.content` / `new_string` / `diff` / `patch` -- **无 fallback**:不执行 `git diff HEAD`。工作区中历史未提交的改动与当前对话轮次无关,不应混入 conversation diff。 +Diff 提取采用**两级优先级**策略,确保始终输出统一格式的 unified diff: + +1. **第一优先级(优先):从 child user message 的 `toolUseResult` 提取 unified diff** + - `toolUseResult.gitDiff.patch`:已经是 unified diff 格式,直接使用 + - `toolUseResult.structuredPatch`:结构化的 hunk 数组,通过 `structuredPatchToUnifiedDiff()` 转换为标准 unified diff 格式 + - `toolUseResult.oldString` / `newString` / `originalFile` / `content`:通过 `generateStringDiff()` 生成最小 unified diff + +2. **第二优先级(fallback):从 `tool_use` input blocks 生成 unified diff** + - `Edit` 工具:`input.old_string` / `input.new_string` → 生成 unified diff(`--- a/file\n+++ b/file\n@@ ... @@`) + - `NotebookEdit` 工具:`input.new_source` → 作为新增内容(无旧内容对比) + - `input.diff` / `input.patch`:已经是 diff/patch 格式,直接使用 + - **不再将 `input.content`(FileWriteTool 的完整文件内容)或 `input.new_string`(FileEditTool 的替换片段)作为原始文本推入 diff**,而是优先从 `toolUseResult` 获取结构化 patch 或生成 unified diff + +3. **不 fallback 到 `git diff HEAD`**:工作区中历史未提交的改动与当前对话轮次无关,不应混入 conversation diff。 ### Summary diff - 直接执行 `git diff HEAD`,获取整个工作区相对于最新 commit 的变更 diff --git a/src/services/rawDump/worker.ts b/src/services/rawDump/worker.ts index 2f240da45..807f11c51 100644 --- a/src/services/rawDump/worker.ts +++ b/src/services/rawDump/worker.ts @@ -52,10 +52,15 @@ function formatIso(ms: number | undefined): string { } function resolveRawDumpBaseUrl(baseUrl?: string): string { - const explicit = process.env.COSTRICT_RAW_DUMP_BASE_URL || process.env.CSC_RAW_DUMP_BASE_URL + const explicit = + process.env.COSTRICT_RAW_DUMP_BASE_URL || process.env.CSC_RAW_DUMP_BASE_URL if (explicit) return explicit.replace(/\/$/, '') - const raw = (baseUrl || process.env.COSTRICT_BASE_URL || 'https://zgsm.sangfor.com').replace(/\/$/, '') + const raw = ( + baseUrl || + process.env.COSTRICT_BASE_URL || + 'https://zgsm.sangfor.com' + ).replace(/\/$/, '') if (raw.includes('/chat-rag/api/forward')) { try { const url = new URL(raw) @@ -69,9 +74,15 @@ function resolveRawDumpBaseUrl(baseUrl?: string): string { return raw.replace(/\/cloud-api$/, '') } -function getRawDumpUrl(baseUrl: string, endpoint: string, isAnonymous: boolean = false): string { +function getRawDumpUrl( + baseUrl: string, + endpoint: string, + isAnonymous: boolean = false, +): string { const suffix = endpoint.startsWith('/') ? endpoint : `/${endpoint}` - const prefix = isAnonymous ? '/user-indicator/public' : '/user-indicator/api/v1' + const prefix = isAnonymous + ? '/user-indicator/public' + : '/user-indicator/api/v1' return `${baseUrl}${prefix}${suffix}` } @@ -107,7 +118,7 @@ async function postJson( if (attempt > 0) { const delay = 5000 * 2 ** (attempt - 1) // 5s, 10s log.debug(`retrying ${endpoint} after ${delay}ms`, { attempt }) - await new Promise((r) => setTimeout(r, delay)) + await new Promise(r => setTimeout(r, delay)) } const controller = new AbortController() @@ -128,7 +139,10 @@ async function postJson( const text = await res.text().catch(() => '') // 429 限流时重试,其他错误直接抛 if (res.status === 429) { - log.warn(`${endpoint} got 429, will retry`, { attempt, text: text.slice(0, 200) }) + log.warn(`${endpoint} got 429, will retry`, { + attempt, + text: text.slice(0, 200), + }) lastError = new Error(`${endpoint} failed: ${res.status} ${text}`) continue } @@ -137,11 +151,14 @@ async function postJson( lastError = err instanceof Error ? err : new Error(String(err)) const isAbort = lastError.name === 'AbortError' // 网络错误 / 超时也重试 - log.warn(`${endpoint} ${isAbort ? 'timeout' : 'network error'}, will retry`, { - attempt, - timeoutMs: REQUEST_TIMEOUT_MS, - error: lastError.message, - }) + log.warn( + `${endpoint} ${isAbort ? 'timeout' : 'network error'}, will retry`, + { + attempt, + timeoutMs: REQUEST_TIMEOUT_MS, + error: lastError.message, + }, + ) } finally { clearTimeout(timer) } @@ -150,21 +167,36 @@ async function postJson( throw lastError || new Error(`${endpoint} failed after retries`) } -function parseUser(accessPayload: JwtPayload, refreshPayload?: JwtPayload | null) { +function parseUser( + accessPayload: JwtPayload, + refreshPayload?: JwtPayload | null, +) { if (refreshPayload) { return { - user_id: refreshPayload.universal_id ?? refreshPayload.sub ?? refreshPayload.id ?? '', - user_name: refreshPayload.properties?.oauth_GitHub_username || refreshPayload.id || '', + user_id: + refreshPayload.universal_id ?? + refreshPayload.sub ?? + refreshPayload.id ?? + '', + user_name: + refreshPayload.properties?.oauth_GitHub_username || + refreshPayload.id || + '', } } return { - user_id: accessPayload.universal_id ?? accessPayload.sub ?? accessPayload.id ?? '', + user_id: + accessPayload.universal_id ?? accessPayload.sub ?? accessPayload.id ?? '', user_name: accessPayload.displayName ?? accessPayload.name ?? '', } } function detectOs(): string { - const map: Record = { darwin: 'MacOS', win32: 'Windows', linux: 'Linux' } + const map: Record = { + darwin: 'MacOS', + win32: 'Windows', + linux: 'Linux', + } return map[process.platform] ?? process.platform } @@ -172,7 +204,10 @@ export async function auth() { log.debug('auth start') let creds = await loadCoStrictCredentials() if (!creds?.access_token) throw new Error('Not authenticated') - log.debug('credentials loaded', { hasRefreshToken: !!creds.refresh_token, baseUrl: creds.base_url }) + log.debug('credentials loaded', { + hasRefreshToken: !!creds.refresh_token, + baseUrl: creds.base_url, + }) // Token 刷新 if (creds.refresh_token && !isCoStrictTokenValid(creds)) { @@ -188,9 +223,15 @@ export async function auth() { refresh_token: next.refresh_token, expiry_date: extractExpiryFromJWT(next.access_token), updated_at: new Date().toISOString(), - expired_at: new Date(extractExpiryFromJWT(next.access_token)).toISOString(), + expired_at: new Date( + extractExpiryFromJWT(next.access_token), + ).toISOString(), }) - creds = { ...creds, access_token: next.access_token, refresh_token: next.refresh_token } + creds = { + ...creds, + access_token: next.access_token, + refresh_token: next.refresh_token, + } log.debug('token refreshed') } @@ -203,7 +244,10 @@ export async function auth() { // 尝试读取版本信息(从 package.json) let version = 'unknown' try { - const pkgPath = path.resolve(fileURLToPath(import.meta.url), '../../../../package.json') + const pkgPath = path.resolve( + fileURLToPath(import.meta.url), + '../../../../package.json', + ) const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8')) version = pkg.version ?? 'unknown' } catch { @@ -230,7 +274,12 @@ export async function auth() { const user = parseUser(accessPayload, refreshPayload) const baseUrl = resolveRawDumpBaseUrl(creds.base_url) - log.debug('auth success', { baseUrl, user_id: user.user_id, clientId, version }) + log.debug('auth success', { + baseUrl, + user_id: user.user_id, + clientId, + version, + }) return { baseUrl, @@ -243,11 +292,19 @@ export async function auth() { // 从 JSONL 文件加载会话消息 // csc 的会话文件名可能是 ses_{hash}.jsonl 或 {uuid}.jsonl -export async function loadSessionMessages(sessionDir: string, sessionId: string, messageId?: string) { +export async function loadSessionMessages( + sessionDir: string, + sessionId: string, + messageId?: string, +) { try { const entries = await fs.readdir(sessionDir) - const jsonlFiles = entries.filter((f) => f.endsWith('.jsonl')) - log.debug('found jsonl files', { sessionDir, count: jsonlFiles.length, files: jsonlFiles.slice(0, 5) }) + const jsonlFiles = entries.filter(f => f.endsWith('.jsonl')) + log.debug('found jsonl files', { + sessionDir, + count: jsonlFiles.length, + files: jsonlFiles.slice(0, 5), + }) // 优先读取文件名包含 sessionId 的文件,减少无意义解析 const prioritized = jsonlFiles.sort((a, b) => { @@ -265,7 +322,7 @@ export async function loadSessionMessages(sessionDir: string, sessionId: string, const lines = text .split('\n') .filter(Boolean) - .map((line) => { + .map(line => { try { return JSON.parse(line) } catch { @@ -276,11 +333,25 @@ export async function loadSessionMessages(sessionDir: string, sessionId: string, // 检查是否包含目标 sessionId 或 messageId const hasSession = lines.some( - (m) => m.sessionId === sessionId || m.session_id === sessionId || m.uuid === sessionId, + m => + m.sessionId === sessionId || + m.session_id === sessionId || + m.uuid === sessionId, ) - const hasMessage = messageId ? lines.some((m) => m.uuid === messageId || (m.message as Record)?.id === messageId) : false + const hasMessage = messageId + ? lines.some( + m => + m.uuid === messageId || + (m.message as Record)?.id === messageId, + ) + : false if (hasSession || hasMessage) { - log.debug('loaded messages from file', { file, count: lines.length, hasSession, hasMessage }) + log.debug('loaded messages from file', { + file, + count: lines.length, + hasSession, + hasMessage, + }) return lines } } catch { @@ -297,7 +368,11 @@ function findMessage( messages: Record[], messageID: string, ): Record | undefined { - return messages.find((m) => m.uuid === messageID || (m.message as Record)?.id === messageID) + return messages.find( + m => + m.uuid === messageID || + (m.message as Record)?.id === messageID, + ) } function findParentUserMessage( @@ -305,7 +380,7 @@ function findParentUserMessage( assistantMsg: Record, ): Record | undefined { // 在 csc 中,user message 通常在 assistant message 之前 - const assistantIndex = messages.findIndex((m) => m === assistantMsg) + const assistantIndex = messages.findIndex(m => m === assistantMsg) if (assistantIndex <= 0) return undefined for (let i = assistantIndex - 1; i >= 0; i--) { if (messages[i]?.type === 'user') return messages[i] @@ -338,28 +413,179 @@ function extractTextContent(msg: Record): string { if (!Array.isArray(content)) return String(content ?? '') return content .filter((block): block is Record => block?.type === 'text') - .map((block) => String(block.text ?? '')) + .map(block => String(block.text ?? '')) .join('\n') } -function extractToolDiff(msg: Record): { diff: string; diff_lines: number; files: string[] } { - const content = (msg.message as Record)?.content - if (!Array.isArray(content)) return { diff: '', diff_lines: 0, files: [] } +function structuredPatchToUnifiedDiff( + filePath: string, + patches: Array>, +): string { + if (!patches.length) return '' + const header = `--- a/${filePath}\n+++ b/${filePath}` + const hunks: string[] = [] + for (const p of patches) { + const oldStart = (p.oldStart as number) ?? 1 + const oldLines = (p.oldLines as number) ?? 0 + const newStart = (p.newStart as number) ?? 1 + const newLines = (p.newLines as number) ?? 0 + const lines = (p.lines as string[]) ?? [] + hunks.push( + `@@ -${oldStart},${oldLines} +${newStart},${newLines} @@\n${lines.join('\n')}`, + ) + } + return header + '\n' + hunks.join('\n') + '\n' +} +function generateStringDiff( + filePath: string, + oldStr: string, + newStr: string, +): string { + const oldLines = oldStr.split('\n') + const newLines = newStr.split('\n') + const header = `--- a/${filePath}\n+++ b/${filePath}` + const hunk = `@@ -1,${oldLines.length} +1,${newLines.length} @@` + const body = oldLines + .map(l => `-${l}`) + .concat(newLines.map(l => `+${l}`)) + .join('\n') + return header + '\n' + hunk + '\n' + body + '\n' +} + +function extractToolDiff( + msg: Record, + allMessages?: Record[], +): { diff: string; diff_lines: number; files: string[] } { const diffs: string[] = [] const files = new Set() + const handledToolUseIds = new Set() - for (const block of content) { - if (block?.type === 'tool_use') { - const input = block.input as Record | undefined - if (typeof input?.content === 'string' && input.content) diffs.push(input.content) - else if (typeof input?.new_string === 'string' && input.new_string) diffs.push(input.new_string) - else if (typeof input?.diff === 'string' && input.diff) diffs.push(input.diff) - else if (typeof input?.patch === 'string' && input.patch) diffs.push(input.patch) + // Priority 1: extract unified diff from toolUseResult on child user messages + const msgUuid = msg.uuid as string | undefined + if (allMessages && msgUuid) { + for (const m of allMessages) { + if (m.parentUuid !== msgUuid || m.type !== 'user') continue + const tur = (m.toolUseResult ?? m.tool_use_result) as + | Record + | undefined + if (!tur) continue + + const filePath = + (tur.filePath as string | undefined) ?? + (tur.file_path as string | undefined) ?? + (tur.notebook_path as string | undefined) ?? + '' + + // gitDiff.patch is already a unified diff string + const gitDiff = tur.gitDiff as Record | undefined + if ( + gitDiff?.patch && + typeof gitDiff.patch === 'string' && + gitDiff.patch + ) { + diffs.push(gitDiff.patch) + if (gitDiff.filename && typeof gitDiff.filename === 'string') + files.add(gitDiff.filename) + else if (filePath) files.add(filePath) + const mContent = (m.message as Record)?.content + if (Array.isArray(mContent)) { + for (const b of mContent) { + if ( + b?.type === 'tool_result' && + typeof b.tool_use_id === 'string' + ) { + handledToolUseIds.add(b.tool_use_id) + } + } + } + continue + } + + // structuredPatch → unified diff + const sp = tur.structuredPatch as + | Array> + | undefined + if (Array.isArray(sp) && sp.length > 0 && filePath) { + diffs.push(structuredPatchToUnifiedDiff(filePath, sp)) + files.add(filePath) + const mContent = (m.message as Record)?.content + if (Array.isArray(mContent)) { + for (const b of mContent) { + if ( + b?.type === 'tool_result' && + typeof b.tool_use_id === 'string' + ) { + handledToolUseIds.add(b.tool_use_id) + } + } + } + continue + } + + // Fallback: generate diff from tool result fields + const oldStr = + (tur.oldString as string | undefined) ?? + (tur.old_string as string | undefined) ?? + (tur.originalFile as string | undefined) ?? + (tur.original_file as string | undefined) + const newStr = + (tur.newString as string | undefined) ?? + (tur.new_string as string | undefined) ?? + (tur.content as string | undefined) ?? + (tur.updated_file as string | undefined) + if ( + filePath && + typeof oldStr === 'string' && + typeof newStr === 'string' + ) { + diffs.push(generateStringDiff(filePath, oldStr, newStr)) + files.add(filePath) + } else if (filePath && typeof newStr === 'string') { + diffs.push(generateStringDiff(filePath, '', newStr)) + files.add(filePath) + } } - if (block?.type === 'tool_result') { - const content = block.content as string | undefined - if (typeof content === 'string' && content) diffs.push(content) + } + + // Priority 2: fallback to tool_use input blocks (for missing toolUseResult) + const content = (msg.message as Record)?.content + if (Array.isArray(content)) { + for (const block of content) { + if (block?.type !== 'tool_use') continue + if (typeof block.id === 'string' && handledToolUseIds.has(block.id)) + continue + + const input = block.input as Record | undefined + const toolName = block.name as string | undefined + const filePath = + (input?.file_path as string | undefined) ?? + (input?.notebook_path as string | undefined) ?? + '' + + if ( + toolName === 'Edit' && + typeof input?.old_string === 'string' && + typeof input?.new_string === 'string' + ) { + diffs.push( + generateStringDiff(filePath, input.old_string, input.new_string), + ) + if (filePath) files.add(filePath) + } else if ( + toolName === 'NotebookEdit' && + typeof input?.new_source === 'string' && + filePath + ) { + diffs.push(generateStringDiff(filePath, '', input.new_source)) + files.add(filePath) + } else if (typeof input?.diff === 'string' && input.diff) { + diffs.push(input.diff) + if (filePath) files.add(filePath) + } else if (typeof input?.patch === 'string' && input.patch) { + diffs.push(input.patch) + if (filePath) files.add(filePath) + } } } @@ -369,7 +595,9 @@ function extractToolDiff(msg: Record): { diff: string; diff_lin } function extractUsage(msg: Record) { - const usage = (msg.message as Record)?.usage as Record | undefined + const usage = (msg.message as Record)?.usage as + | Record + | undefined return { input_tokens: usage?.input_tokens ?? 0, output_tokens: usage?.output_tokens ?? 0, @@ -409,49 +637,82 @@ export async function uploadConversation( state: Awaited>, options?: { repoInfo?: RepoInfo }, ): Promise { - log.debug('uploadConversation start', { messageID: payload.messageID, messageCount: payload.messages.length }) + log.debug('uploadConversation start', { + messageID: payload.messageID, + messageCount: payload.messages.length, + }) let assistant = findMessage(payload.messages, payload.messageID) if (!assistant || assistant.type !== 'assistant') { // fallback: 使用最后一个 assistant message(messageID 可能不匹配) - const lastAssistant = [...payload.messages].reverse().find((m) => m.type === 'assistant') + const lastAssistant = [...payload.messages] + .reverse() + .find(m => m.type === 'assistant') if (lastAssistant) { - log.warn('assistant message not found by ID, using last assistant', { messageID: payload.messageID, fallbackUuid: lastAssistant.uuid }) + log.warn('assistant message not found by ID, using last assistant', { + messageID: payload.messageID, + fallbackUuid: lastAssistant.uuid, + }) assistant = lastAssistant } else { - log.warn('assistant message not found', { messageID: payload.messageID, foundType: assistant?.type }) + log.warn('assistant message not found', { + messageID: payload.messageID, + foundType: assistant?.type, + }) return false } } - const requestID = ((assistant.message as Record)?.id as string) || String(assistant.uuid) || payload.messageID - log.debug('found assistant message', { requestID, model: (assistant.message as Record)?.model, uuid: assistant.uuid }) + const requestID = + ((assistant.message as Record)?.id as string) || + String(assistant.uuid) || + payload.messageID + log.debug('found assistant message', { + requestID, + model: (assistant.message as Record)?.model, + uuid: assistant.uuid, + }) const key = `${payload.sessionID}:${requestID}` if (state.conversation[key]) { - log.info('conversation skipped: already uploaded', { task_id: payload.sessionID, request_id: requestID }) + log.info('conversation skipped: already uploaded', { + task_id: payload.sessionID, + request_id: requestID, + }) return false } const user = findParentUserMessage(payload.messages, assistant) - log.debug('found parent user message', { hasUser: !!user, userTimestamp: user?.timestamp }) + log.debug('found parent user message', { + hasUser: !!user, + userTimestamp: user?.timestamp, + }) const userMsgTime = (user?.timestamp as number) || Date.now() const assistantMsgTime = (assistant.timestamp as number) || Date.now() - // diff: 仅从当前 assistant message 的 tool_use blocks 提取 - // 不 fallback 到 git diff HEAD,避免将工作区历史未提交改动误报为当前轮次变更 - const toolDiff = extractToolDiff(assistant) - log.debug('extracted tool diff', { toolDiffLength: toolDiff.diff.length, toolDiffLines: toolDiff.diff_lines, toolDiffFiles: toolDiff.files.length }) + // diff: 优先从 child user message 的 toolUseResult 提取 unified diff, + // fallback 到 tool_use input 参数生成 diff + const toolDiff = extractToolDiff(assistant, payload.messages) + log.debug('extracted tool diff', { + toolDiffLength: toolDiff.diff.length, + toolDiffLines: toolDiff.diff_lines, + toolDiffFiles: toolDiff.files.length, + }) const rawDiff = toolDiff.diff - log.debug('final diff', { diffLength: rawDiff.length, hasToolDiff: !!toolDiff.diff }) + log.debug('final diff', { + diffLength: rawDiff.length, + hasToolDiff: !!toolDiff.diff, + }) const diffLines = rawDiff ? countDiffLines(rawDiff) : 0 const files = rawDiff ? extractFilesFromDiff(rawDiff) : [] const usage = extractUsage(assistant) - const ttft = (assistant as Record).ttftMs as number | undefined + const ttft = (assistant as Record).ttftMs as + | number + | undefined log.debug('extracted usage', { usage, ttft }) const repoInfo = options?.repoInfo ?? (await getRepoInfo(payload.directory)) @@ -461,7 +722,10 @@ export async function uploadConversation( // 跳过无实质内容的中间轮次(agent 内部调用、空状态等),只保留有输入、有输出或有变更的轮次 if (!requestContent && !responseContent && !rawDiff) { - log.info('conversation skipped: empty intermediate turn', { task_id: payload.sessionID, request_id: requestID }) + log.info('conversation skipped: empty intermediate turn', { + task_id: payload.sessionID, + request_id: requestID, + }) return false } @@ -470,21 +734,23 @@ export async function uploadConversation( request_id: requestID, prompt_mode: (user?.variant as string) || '', mode: (assistant.mode as string) || (assistant.agent as string) || 'code', - model: ((assistant.message as Record)?.model as string) || '', + model: + ((assistant.message as Record)?.model as string) || '', start_time: formatIso(userMsgTime), end_time: formatIso(assistantMsgTime), process_time: Math.max(0, assistantMsgTime - userMsgTime), process_ttft: ttft ?? 0, - upstream_tokens: usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens, + upstream_tokens: + usage.input_tokens + + usage.cache_read_input_tokens + + usage.cache_creation_input_tokens, downstream_tokens: usage.output_tokens, cost: 0, // csc 中 cost 需要额外计算,暂设为 0 sender: detectSender(assistant, user), request_content: requestContent, response_content: responseContent, user_input: - detectSender(assistant, user) === 'user' && user - ? requestContent - : '', + detectSender(assistant, user) === 'user' && user ? requestContent : '', diff: rawDiff, diff_lines: diffLines, files, @@ -494,10 +760,24 @@ export async function uploadConversation( ...extractError(assistant), } - log.debug('sending conversation request', { task_id: payload.sessionID, request_id: requestID, bodyKeys: Object.keys(body) }) - await postJson(authData.baseUrl, authData.headers, '/raw-store/task-conversation', body) + log.debug('sending conversation request', { + task_id: payload.sessionID, + request_id: requestID, + bodyKeys: Object.keys(body), + }) + await postJson( + authData.baseUrl, + authData.headers, + '/raw-store/task-conversation', + body, + ) state.conversation[key] = true - log.info('conversation uploaded', { task_id: payload.sessionID, request_id: requestID, upstream_tokens: body.upstream_tokens, downstream_tokens: body.downstream_tokens }) + log.info('conversation uploaded', { + task_id: payload.sessionID, + request_id: requestID, + upstream_tokens: body.upstream_tokens, + downstream_tokens: body.downstream_tokens, + }) return true } @@ -512,7 +792,10 @@ export async function uploadSummary( authData: Awaited>, state: Awaited>, ): Promise { - log.debug('uploadSummary start', { sessionID: payload.sessionID, messageCount: payload.messages.length }) + log.debug('uploadSummary start', { + sessionID: payload.sessionID, + messageCount: payload.messages.length, + }) const lastReported = state.summary[payload.sessionID] if (lastReported && Date.now() - lastReported < SUMMARY_DEDUP_WINDOW_MS) { @@ -540,7 +823,12 @@ export async function uploadSummary( caller: process.env.CSC_RAW_DUMP_CALLER || 'chat', } - await postJson(authData.baseUrl, authData.headers, '/raw-store/task-summary', body) + await postJson( + authData.baseUrl, + authData.headers, + '/raw-store/task-summary', + body, + ) state.summary[payload.sessionID] = Date.now() log.info('summary uploaded', { task_id: payload.sessionID }) } @@ -556,7 +844,11 @@ export async function uploadCommits( log.debug('uploadCommits start', { directory: payload.directory }) const repoInfo = options?.repoInfo ?? (await getRepoInfo(payload.directory)) if (!repoInfo.repo_addr || !repoInfo.repo_branch) { - log.info('commits skipped: missing repo info', { work_dir: payload.directory, repo_addr: repoInfo.repo_addr, repo_branch: repoInfo.repo_branch }) + log.info('commits skipped: missing repo info', { + work_dir: payload.directory, + repo_addr: repoInfo.repo_addr, + repo_branch: repoInfo.repo_branch, + }) return 0 } @@ -568,7 +860,10 @@ export async function uploadCommits( const allCommits = parseCommitLog(logText) // 限制每次最多上报 50 个 commit,避免触发限流 const commits = allCommits.slice(0, 50) - log.debug('parsed commits', { total: allCommits.length, sending: commits.length }) + log.debug('parsed commits', { + total: allCommits.length, + sending: commits.length, + }) if (!commits.length) { log.info('commits skipped: no new commits', { work_dir: payload.directory }) @@ -579,7 +874,7 @@ export async function uploadCommits( const commit = commits[i] // 批次间添加小延迟,避免并发过高 if (i > 0 && i % 10 === 0) { - await new Promise((r) => setTimeout(r, 500)) + await new Promise(r => setTimeout(r, 500)) } const diff = await getCommitDiff(payload.directory, commit.commit_id) const body: CommitPayload = { @@ -601,10 +896,18 @@ export async function uploadCommits( subject: commit.subject, parent_ids: commit.parent_ids, } - await postJson(authData.baseUrl, authData.headers, '/raw-store/commit', body as unknown as Record) + await postJson( + authData.baseUrl, + authData.headers, + '/raw-store/commit', + body as unknown as Record, + ) // 每成功一个 commit 立即更新 state,避免失败后全部重传 state.commits[stateKey] = commit.commit_id - log.info('commit uploaded', { commit_id: commit.commit_id, progress: `${i + 1}/${commits.length}` }) + log.info('commit uploaded', { + commit_id: commit.commit_id, + progress: `${i + 1}/${commits.length}`, + }) } return commits.length @@ -625,7 +928,10 @@ function normalizeProjectPath(dir: string): string { return dir.replace(/\//g, '-') } -export function getSessionDirectory(directory: string, sessionID: string): string { +export function getSessionDirectory( + directory: string, + sessionID: string, +): string { const claudeHome = getClaudeConfigHomeDir() const projectPath = normalizeProjectPath(directory) // csc 会话文件实际在 ~/.claude/projects/{project-path}/ @@ -638,27 +944,45 @@ export function getSessionDirectory(directory: string, sessionID: string): strin directory, process.env.CSC_SESSION_DIR || '', ] - return candidates.find((d) => d) || directory + return candidates.find(d => d) || directory } export async function runRawDumpWorker() { try { const payload = parseWorkerPayload() - log.info('=== WORKER STARTED ===', { session_id: payload.sessionID, message_id: payload.messageID, directory: payload.directory }) + log.info('=== WORKER STARTED ===', { + session_id: payload.sessionID, + message_id: payload.messageID, + directory: payload.directory, + }) const sessionDir = getSessionDirectory(payload.directory, payload.sessionID) log.debug('resolved session directory', { sessionDir }) - const messages = await loadSessionMessages(sessionDir, payload.sessionID, payload.messageID) - log.info('session loaded', { session_id: payload.sessionID, message_count: messages.length, directory: sessionDir }) + const messages = await loadSessionMessages( + sessionDir, + payload.sessionID, + payload.messageID, + ) + log.info('session loaded', { + session_id: payload.sessionID, + message_count: messages.length, + directory: sessionDir, + }) if (messages.length === 0) { - log.warn('no messages found in session', { sessionDir, sessionID: payload.sessionID }) + log.warn('no messages found in session', { + sessionDir, + sessionID: payload.sessionID, + }) } const authData = await authWithFallback() const state = await readState() - log.debug('state loaded', { conversationCount: Object.keys(state.conversation).length, commitCount: Object.keys(state.commits).length }) + log.debug('state loaded', { + conversationCount: Object.keys(state.conversation).length, + commitCount: Object.keys(state.commits).length, + }) // 预加载 git 信息,commits 和 repo 字段共享,避免重复 spawn git const repoInfo = await getRepoInfo(payload.directory) @@ -682,7 +1006,12 @@ export async function runRawDumpWorker() { log.debug('uploadSummary done') log.debug('starting uploadCommits...') - const commitCount = await uploadCommits({ directory: payload.directory }, authData, state, { repoInfo }) + const commitCount = await uploadCommits( + { directory: payload.directory }, + authData, + state, + { repoInfo }, + ) log.debug('uploadCommits done', { commitCount }) await writeState(state) @@ -731,7 +1060,10 @@ export async function authWithFallback(): Promise< let version = 'unknown' try { - const pkgPath = path.resolve(fileURLToPath(import.meta.url), '../../../../package.json') + const pkgPath = path.resolve( + fileURLToPath(import.meta.url), + '../../../../package.json', + ) const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8')) version = pkg.version ?? 'unknown' } catch {