From 4d495c2b79003b9952c3647a3f59f95c6867261c Mon Sep 17 00:00:00 2001 From: tr1um7h Date: Fri, 22 May 2026 11:06:12 +0800 Subject: [PATCH 1/3] fix: add package-lock.json to gitignore and fix bun build version string - Add package-lock.json to .gitignore (project uses bun.lockb) - Fix MACRO.VERSION to use proper string quoting in build command Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + bunfig.toml | 3 +++ package.json | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2d89c66..08d25cd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* bun-debug.log* +package-lock.json .DS_Store *.pem .cache/ diff --git a/bunfig.toml b/bunfig.toml index e5c7b55..13d5a5b 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,2 +1,5 @@ [build] alias = { "src" = "./src", "react/compiler-runtime" = "react-compiler-runtime" } + +[install] +registry = "https://registry.npmmirror.com" diff --git a/package.json b/package.json index ad16f1d..07ad45e 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "node": ">=18.0.0" }, "scripts": { - "build": "bun build src/entrypoints/cli.tsx --outfile cli.js --target bun --define MACRO.VERSION=\"2.1.88\" --define MACRO.BUILD_TIME=\"2025-01-01\" --define MACRO.FEEDBACK_CHANNEL=\"https://github.com/anthropics/claude-code/issues\" --define MACRO.ISSUES_EXPLAINER=\"https://github.com/anthropics/claude-code/issues\" --define MACRO.NATIVE_PACKAGE_URL=\"https://npmjs.com\" --define MACRO.PACKAGE_URL=\"https://npmjs.com\" --define MACRO.VERSION_CHANGELOG=\"\"", + "build": "bun build src/entrypoints/cli.tsx --outfile cli.js --target bun --define MACRO.VERSION='\"2.1.88\"' --define MACRO.BUILD_TIME='\"2025-01-01\"' --define MACRO.FEEDBACK_CHANNEL=\"https://github.com/anthropics/claude-code/issues\" --define MACRO.ISSUES_EXPLAINER=\"https://github.com/anthropics/claude-code/issues\" --define MACRO.NATIVE_PACKAGE_URL=\"https://npmjs.com\" --define MACRO.PACKAGE_URL=\"https://npmjs.com\" --define MACRO.VERSION_CHANGELOG=\"\"", "start": "bun cli.js", "typecheck": "tsc --noEmit" }, From 8a2fed4e9cf0d1273e589b2cffe1625461930caf Mon Sep 17 00:00:00 2001 From: tr1um7h Date: Fri, 22 May 2026 11:17:17 +0800 Subject: [PATCH 2/3] feat: add private package stubs to repository - Add stubs for 4 private packages in stubs/ directory: - @anthropic-ai/bedrock-sdk - @anthropic-ai/foundry-sdk - @anthropic-ai/vertex-sdk - @aws-sdk/client-bedrock - Configure path aliases in bunfig.toml to use local stubs - Update README with stubs documentation - Stubs are now part of the repo and won't be overwritten by npm install Co-Authored-By: Claude Opus 4.6 --- README.md | 19 +++++++++++++++++-- bunfig.toml | 9 ++++++++- stubs/@anthropic-ai/bedrock-sdk/index.js | 1 + stubs/@anthropic-ai/bedrock-sdk/package.json | 1 + stubs/@anthropic-ai/foundry-sdk/index.js | 1 + stubs/@anthropic-ai/foundry-sdk/package.json | 1 + stubs/@anthropic-ai/vertex-sdk/index.js | 1 + stubs/@anthropic-ai/vertex-sdk/package.json | 1 + stubs/@aws-sdk/client-bedrock/index.js | 1 + stubs/@aws-sdk/client-bedrock/package.json | 1 + tsconfig.json | 6 +++++- 11 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 stubs/@anthropic-ai/bedrock-sdk/index.js create mode 100644 stubs/@anthropic-ai/bedrock-sdk/package.json create mode 100644 stubs/@anthropic-ai/foundry-sdk/index.js create mode 100644 stubs/@anthropic-ai/foundry-sdk/package.json create mode 100644 stubs/@anthropic-ai/vertex-sdk/index.js create mode 100644 stubs/@anthropic-ai/vertex-sdk/package.json create mode 100644 stubs/@aws-sdk/client-bedrock/index.js create mode 100644 stubs/@aws-sdk/client-bedrock/package.json diff --git a/README.md b/README.md index 2d014ed..2a09f78 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,15 @@ ### 1. 安装依赖 ```bash +# 使用 npm 安装依赖(推荐,Bun 可能因网络问题失败) +npm install + +# 或尝试使用 Bun bun install ``` +> **注意**:本项目包含 4 个 Anthropic 私有包的 stubs(位于 `stubs/` 目录),用于解决编译时的依赖缺失问题。这些 stubs 已通过 `bunfig.toml` 配置的路径别名自动映射,无需额外操作。 + ### 2. 编译 ```bash @@ -93,7 +99,7 @@ alias = { "src" = "./src", "react/compiler-runtime" = "react-compiler-runtime" } ## 私有包存根说明 -以下 4 个 Anthropic 内部私有包在 npm 上不公开,已在 `node_modules/` 中创建功能存根: +以下 4 个 Anthropic 内部私有包在 npm 上不公开,已在 `stubs/` 目录中创建功能存根,并通过 `bunfig.toml` 路径别名自动映射: | 包名 | 对应功能 | 影响 | |------|----------|------| @@ -102,7 +108,16 @@ alias = { "src" = "./src", "react/compiler-runtime" = "react-compiler-runtime" } | `@anthropic-ai/mcpb` | MCP 插件包(.dxt 格式)安装 | 插件市场不可用 | | `@anthropic-ai/sandbox-runtime` | 沙箱文件/网络权限隔离 | 沙箱模式不可用 | -核心对话、代码编辑、工具调用等主要功能不受影响。 +**新增 stubs(位于 `stubs/` 目录):** + +| 包名 | 对应功能 | 影响 | +|------|----------|------| +| `@anthropic-ai/bedrock-sdk` | AWS Bedrock 集成 | Bedrock 模式不可用 | +| `@anthropic-ai/foundry-sdk` | Anthropic Foundry 集成 | Foundry 模式不可用 | +| `@anthropic-ai/vertex-sdk` | Google Vertex AI 集成 | Vertex 模式不可用 | +| `@aws-sdk/client-bedrock` | AWS Bedrock 客户端 | Bedrock 模型列表不可用 | + +核心对话、代码编辑、工具调用等主要功能不受影响。`npm install` 不会覆盖这些 stubs。 --- diff --git a/bunfig.toml b/bunfig.toml index 13d5a5b..4dfb68f 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,5 +1,12 @@ [build] -alias = { "src" = "./src", "react/compiler-runtime" = "react-compiler-runtime" } +alias = { + "src" = "./src", + "react/compiler-runtime" = "react-compiler-runtime", + "@anthropic-ai/bedrock-sdk" = "./stubs/@anthropic-ai/bedrock-sdk", + "@anthropic-ai/foundry-sdk" = "./stubs/@anthropic-ai/foundry-sdk", + "@anthropic-ai/vertex-sdk" = "./stubs/@anthropic-ai/vertex-sdk", + "@aws-sdk/client-bedrock" = "./stubs/@aws-sdk/client-bedrock" +} [install] registry = "https://registry.npmmirror.com" diff --git a/stubs/@anthropic-ai/bedrock-sdk/index.js b/stubs/@anthropic-ai/bedrock-sdk/index.js new file mode 100644 index 0000000..9d3956c --- /dev/null +++ b/stubs/@anthropic-ai/bedrock-sdk/index.js @@ -0,0 +1 @@ +export class AnthropicBedrock{constructor(){throw new Error("Not available")}} diff --git a/stubs/@anthropic-ai/bedrock-sdk/package.json b/stubs/@anthropic-ai/bedrock-sdk/package.json new file mode 100644 index 0000000..d41875e --- /dev/null +++ b/stubs/@anthropic-ai/bedrock-sdk/package.json @@ -0,0 +1 @@ +{"name":"@anthropic-ai/bedrock-sdk","version":"0.1.0","main":"index.js","type":"module"} diff --git a/stubs/@anthropic-ai/foundry-sdk/index.js b/stubs/@anthropic-ai/foundry-sdk/index.js new file mode 100644 index 0000000..0e5fd73 --- /dev/null +++ b/stubs/@anthropic-ai/foundry-sdk/index.js @@ -0,0 +1 @@ +export class AnthropicFoundry{constructor(){throw new Error("Not available")}} diff --git a/stubs/@anthropic-ai/foundry-sdk/package.json b/stubs/@anthropic-ai/foundry-sdk/package.json new file mode 100644 index 0000000..b727d65 --- /dev/null +++ b/stubs/@anthropic-ai/foundry-sdk/package.json @@ -0,0 +1 @@ +{"name":"@anthropic-ai/foundry-sdk","version":"0.1.0","main":"index.js","type":"module"} diff --git a/stubs/@anthropic-ai/vertex-sdk/index.js b/stubs/@anthropic-ai/vertex-sdk/index.js new file mode 100644 index 0000000..6949c80 --- /dev/null +++ b/stubs/@anthropic-ai/vertex-sdk/index.js @@ -0,0 +1 @@ +export class AnthropicVertex{constructor(){throw new Error("Not available")}} diff --git a/stubs/@anthropic-ai/vertex-sdk/package.json b/stubs/@anthropic-ai/vertex-sdk/package.json new file mode 100644 index 0000000..0d821ad --- /dev/null +++ b/stubs/@anthropic-ai/vertex-sdk/package.json @@ -0,0 +1 @@ +{"name":"@anthropic-ai/vertex-sdk","version":"0.1.0","main":"index.js","type":"module"} diff --git a/stubs/@aws-sdk/client-bedrock/index.js b/stubs/@aws-sdk/client-bedrock/index.js new file mode 100644 index 0000000..6b3d0eb --- /dev/null +++ b/stubs/@aws-sdk/client-bedrock/index.js @@ -0,0 +1 @@ +export class BedrockClient{constructor(){throw new Error("Not available")}} export class GetFoundationModelCommand{} export class ListFoundationModelsCommand{} diff --git a/stubs/@aws-sdk/client-bedrock/package.json b/stubs/@aws-sdk/client-bedrock/package.json new file mode 100644 index 0000000..4de4e21 --- /dev/null +++ b/stubs/@aws-sdk/client-bedrock/package.json @@ -0,0 +1 @@ +{"name":"@aws-sdk/client-bedrock","version":"3.0.0","main":"index.js","type":"module"} diff --git a/tsconfig.json b/tsconfig.json index 8306d13..429642b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,11 @@ "baseUrl": ".", "paths": { "bun:bundle": ["./src/types/bun-bundle.d.ts"], - "src/*": ["./src/*"] + "src/*": ["./src/*"], + "@anthropic-ai/bedrock-sdk": ["./stubs/@anthropic-ai/bedrock-sdk"], + "@anthropic-ai/foundry-sdk": ["./stubs/@anthropic-ai/foundry-sdk"], + "@anthropic-ai/vertex-sdk": ["./stubs/@anthropic-ai/vertex-sdk"], + "@aws-sdk/client-bedrock": ["./stubs/@aws-sdk/client-bedrock"] } }, "include": ["src/**/*"], From 5f47f3a2fa05aac44973f6da4af0dc503df2e9cf Mon Sep 17 00:00:00 2001 From: tr1um7h Date: Fri, 22 May 2026 21:16:58 +0800 Subject: [PATCH 3/3] feature: mod for log inspect Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 + package.json | 1 + src/services/api/claude.ts | 78 ++++ src/services/api/withRetry.ts | 13 + src/services/compact/compact.ts | 7 + src/services/conversationLogger.ts | 548 ++++++++++++++++++++++++++++ src/services/tools/toolExecution.ts | 15 + src/utils/settings/types.ts | 25 ++ 8 files changed, 689 insertions(+) create mode 100644 src/services/conversationLogger.ts diff --git a/.gitignore b/.gitignore index 08d25cd..0629383 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ package-lock.json coverage/ .nyc_output/ *.tsbuildinfo +.claude +.logs diff --git a/package.json b/package.json index 07ad45e..4a327ad 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "scripts": { "build": "bun build src/entrypoints/cli.tsx --outfile cli.js --target bun --define MACRO.VERSION='\"2.1.88\"' --define MACRO.BUILD_TIME='\"2025-01-01\"' --define MACRO.FEEDBACK_CHANNEL=\"https://github.com/anthropics/claude-code/issues\" --define MACRO.ISSUES_EXPLAINER=\"https://github.com/anthropics/claude-code/issues\" --define MACRO.NATIVE_PACKAGE_URL=\"https://npmjs.com\" --define MACRO.PACKAGE_URL=\"https://npmjs.com\" --define MACRO.VERSION_CHANGELOG=\"\"", + "build:binary": "bun build src/entrypoints/cli.tsx --compile --outfile claude --define MACRO.VERSION='\"2.1.88\"' --define MACRO.BUILD_TIME='\"2025-01-01\"' --define MACRO.FEEDBACK_CHANNEL=\"https://github.com/anthropics/claude-code/issues\" --define MACRO.ISSUES_EXPLAINER=\"https://github.com/anthropics/claude-code/issues\" --define MACRO.NATIVE_PACKAGE_URL=\"https://npmjs.com\" --define MACRO.PACKAGE_URL=\"https://npmjs.com\" --define MACRO.VERSION_CHANGELOG=\"\"", "start": "bun cli.js", "typecheck": "tsc --noEmit" }, diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 89a6e66..2c4b828 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -94,6 +94,17 @@ import { } from '../../utils/systemPromptType.js' import { tokenCountFromLastAPIResponse } from '../../utils/tokens.js' import { getDynamicConfig_BLOCKS_ON_INIT } from '../analytics/growthbook.js' +import { + isConversationLoggingEnabled, + logRetry as logConversationRetry, + logTextOutput, + logThinkingOutput, + logToolCall, + logTurnComplete, + logTurnError, + logTurnStart, + setPendingInput, +} from '../conversationLogger.js' import { currentLimits, extractQuotaStatusFromError, @@ -1758,6 +1769,22 @@ async function* queryModel( }) } + // Conversation logging: capture input before API call + if (isConversationLoggingEnabled()) { + setPendingInput( + systemPrompt, + messagesForAPI, + ) + logTurnStart( + { + id: options.model, + providerID: getAPIProvider(), + variant: 'default', + }, + options.agentId ?? 'default', + ) + } + const newMessages: AssistantMessage[] = [] let ttftMs = 0 let partialMessage: BetaMessage | undefined = undefined @@ -1999,6 +2026,14 @@ async function* queryModel( ...part.content_block, input: '', } + // Conversation logging: log tool call + if (isConversationLoggingEnabled()) { + logToolCall( + part.content_block.id, + part.content_block.name, + {}, // Input will be captured as it streams in + ) + } break case 'server_tool_use': contentBlocks[part.index] = { @@ -2189,6 +2224,29 @@ async function* queryModel( }) throw new Error('Message not found') } + // Conversation logging: capture text and thinking output + if (isConversationLoggingEnabled()) { + if (contentBlock.type === 'text' && 'text' in contentBlock) { + logTextOutput(contentBlock.text as string) + } else if (contentBlock.type === 'thinking' && 'thinking' in contentBlock) { + logThinkingOutput( + part.index.toString(), + contentBlock.thinking as string, + ) + } else if ( + (contentBlock.type === 'tool_use' || contentBlock.type === 'server_tool_use') && + 'input' in contentBlock && + 'id' in contentBlock && + 'name' in contentBlock + ) { + // Update tool call with parsed input + logToolCall( + contentBlock.id as string, + contentBlock.name as string, + contentBlock.input, + ) + } + } const m: AssistantMessage = { message: { ...partialMessage, @@ -2255,6 +2313,21 @@ async function* queryModel( options.model, ) + // Conversation logging: log turn complete + if (isConversationLoggingEnabled() && lastMsg) { + logTurnComplete( + { + input_tokens: usage.input_tokens ?? 0, + output_tokens: usage.output_tokens ?? 0, + cache_read_input_tokens: usage.cache_read_input_tokens, + cache_creation_input_tokens: usage.cache_creation_input_tokens, + }, + stopReason, + costUSD, + lastMsg.message.id, + ) + } + const refusalMessage = getErrorMessageIfRefusal( part.delta.stop_reason, options.model, @@ -2596,6 +2669,11 @@ async function* queryModel( clearStreamIdleTimers() } } catch (errorFromRetry) { + // Conversation logging: log turn error + if (isConversationLoggingEnabled()) { + logTurnError(errorMessage(errorFromRetry)) + } + // FallbackTriggeredError must propagate to query.ts, which performs the // actual model switch. Swallowing it here would turn the fallback into a // no-op — the user would just see "Model fallback triggered: X -> Y" as diff --git a/src/services/api/withRetry.ts b/src/services/api/withRetry.ts index 5ec9ad0..7d03dab 100644 --- a/src/services/api/withRetry.ts +++ b/src/services/api/withRetry.ts @@ -44,6 +44,10 @@ import { checkMockRateLimitError, isMockRateLimitError, } from '../rateLimitMocking.js' +import { + isConversationLoggingEnabled, + logRetry as logConversationRetry, +} from '../conversationLogger.js' import { REPEATED_529_ERROR_MESSAGE } from './errors.js' import { extractConnectionErrorDetails } from './errorUtils.js' @@ -258,6 +262,15 @@ export async function* withRetry( { level: 'error' }, ) + // Conversation logging: log retry event + if (isConversationLoggingEnabled() && error instanceof Error) { + logConversationRetry( + attempt, + error, + error instanceof APIError ? error.status : undefined, + ) + } + // Fast mode fallback: on 429/529, either wait and retry (short delays) // or fall back to standard speed (long delays) to avoid cache thrashing. // Skip in persistent mode: the short-retry path below loops with fast diff --git a/src/services/compact/compact.ts b/src/services/compact/compact.ts index f8f86ea..1ea9855 100644 --- a/src/services/compact/compact.ts +++ b/src/services/compact/compact.ts @@ -10,6 +10,10 @@ const sessionTranscriptModule = feature('KAIROS') import { APIUserAbortError } from '@anthropic-ai/sdk' import { markPostCompaction } from 'src/bootstrap/state.js' import { getInvokedSkillsForAgent } from '../../bootstrap/state.js' +import { + isConversationLoggingEnabled, + logCompaction, +} from '../conversationLogger.js' import type { QuerySource } from '../../constants/querySource.js' import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' import type { Tool, ToolUseContext } from '../../Tool.js' @@ -398,6 +402,9 @@ export async function compactConversation( throw new Error(ERROR_MESSAGE_NOT_ENOUGH_MESSAGES) } + // Log compaction event for conversation logging + logCompaction() + const preCompactTokenCount = tokenCountWithEstimation(messages) const appState = context.getAppState() diff --git a/src/services/conversationLogger.ts b/src/services/conversationLogger.ts new file mode 100644 index 0000000..1e28d84 --- /dev/null +++ b/src/services/conversationLogger.ts @@ -0,0 +1,548 @@ +/** + * Conversation Logger Service for Claude Code + * + * Captures every LLM turn's complete input and output to JSONL files, + * matching the coverage of the OpenCode implementation. + * + * Configuration: + * - Enable via settings.json: { "conversationLogging": { "enabled": true } } + * - Or via environment variable: CLAUDE_CODE_LOG_CONVERSATION=true + * + * Output format is compatible with the OpenCode visualizer. + */ + +import { appendFileSync, mkdirSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { + getSessionId, + getProjectRoot, + getOriginalCwd, +} from '../bootstrap/state.js' +import { getSessionSettingsCache } from '../utils/settings/settingsCache.js' +import { isEnvTruthy } from '../utils/envUtils.js' + +// ── JSONL Output Records ──────────────────────────────────────────────────────── + +export interface TurnStartRecord { + type: 'turn_start' + timestamp: string + sessionID: string + conversationID: string + turnIndex: number + input: { + system: string[] + messages: unknown[] + } +} + +export interface TurnCompleteRecord { + type: 'turn_complete' + timestamp: string + sessionID: string + conversationID: string + turnIndex: number + output: { + message: MessageInfo + parts: OutputPart[] + retries?: RetryRecord[] + error?: string + } +} + +// ── Supporting Types ──────────────────────────────────────────────────────────── + +export interface MessageInfo { + id: string + sessionID: string + role: 'assistant' + model: { providerID: string; id: string; variant: string } + agent: string + tokens: { + input: number + output: number + reasoning: number + cache: { read: number; write: number } + } + cost: number + finish: string +} + +export type OutputPart = + | { type: 'text'; text: string } + | { type: 'thinking'; text: string } + | { type: 'tool-invocation'; tool: string; input: unknown; output?: unknown; state: string } + +export interface RetryRecord { + attempt: number + error: { message: string; statusCode?: number; isRetryable: boolean } +} + +// ── Internal State Types ──────────────────────────────────────────────────────── + +interface ToolCallState { + callID: string + name: string + input: unknown + providerExecuted: boolean + result?: unknown + error?: unknown +} + +interface ActiveTurn { + startedAt: string + model: { id: string; providerID: string; variant: string } + agent: string + snapshot?: string + input: { + system: string[] + messages: unknown[] + } + text: string + reasoningBlocks: Array<{ reasoningID: string; text: string }> + toolCalls: Map + retries: RetryRecord[] +} + +interface SessionState { + filePath: string + pendingInput: { + system: string[] + messages: unknown[] + } | null + currentTurn: ActiveTurn | null + conversationID: string + turnIndex: number +} + +// ── Configuration ──────────────────────────────────────────────────────────────── + +interface ConversationLoggingConfig { + enabled: boolean + outputDir: string + maxTextLength: number + // thinking, tool results, system prompts are always logged (not configurable) +} + +const DEFAULT_CONFIG: ConversationLoggingConfig = { + enabled: false, + outputDir: '.logs', + maxTextLength: 50000, +} + +/** + * Get conversation logging configuration from settings and environment. + * Priority: settings.json > environment variable > defaults + */ +export function getConversationLoggingConfig(): ConversationLoggingConfig { + const settingsCache = getSessionSettingsCache() + const loggingSettings = settingsCache?.settings?.conversationLogging as + | Record + | undefined + + // Environment variable override + const envEnabled = isEnvTruthy(process.env.CLAUDE_CODE_LOG_CONVERSATION) + + return { + ...DEFAULT_CONFIG, + ...loggingSettings, + enabled: (loggingSettings?.enabled as boolean | undefined) ?? envEnabled ?? false, + } +} + +/** + * Check if conversation logging is enabled. + */ +export function isConversationLoggingEnabled(): boolean { + return getConversationLoggingConfig().enabled +} + +// ── Session Management ──────────────────────────────────────────────────────────── + +const sessions = new Map() + +function getSession(sessionID: string, outputDir: string): SessionState { + let s = sessions.get(sessionID) + if (!s) { + const ts = new Date().toISOString().replace(/[:.]/g, '-') + const safeID = sessionID.replace(/[^a-zA-Z0-9_-]/g, '_') + // Use project root for the log directory + const projectRoot = getProjectRoot() || getOriginalCwd() + const logDir = join(projectRoot, outputDir) + + // Ensure directory exists + if (!existsSync(logDir)) { + mkdirSync(logDir, { recursive: true }) + } + + const fp = join(logDir, `${safeID}_${ts}.jsonl`) + s = { + filePath: fp, + pendingInput: null, + currentTurn: null, + conversationID: '', + turnIndex: 0, + } + sessions.set(sessionID, s) + } + return s +} + +function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) return text + return text.slice(0, maxLength) + `\n\n... [truncated ${text.length - maxLength} chars]` +} + +function shortId(): string { + return randomUUID().slice(0, 8) +} + +function buildOutputParts(turn: ActiveTurn, maxTextLength: number): OutputPart[] { + const parts: OutputPart[] = [] + + // Reasoning blocks (thinking parts) — may be multiple per turn + for (const rb of turn.reasoningBlocks) { + parts.push({ type: 'thinking', text: truncate(rb.text, maxTextLength) }) + } + + // Text output + if (turn.text) { + parts.push({ type: 'text', text: truncate(turn.text, maxTextLength) }) + } + + // Tool calls with results + for (const [, tc] of turn.toolCalls) { + const part: OutputPart & { type: 'tool-invocation' } = { + type: 'tool-invocation', + tool: tc.name, + input: tc.input, + state: tc.error ? 'error' : tc.result ? 'completed' : 'pending', + } + if (tc.result) part.output = tc.result + if (tc.error) part.output = tc.error + parts.push(part) + } + + return parts +} + +function writeLine(session: SessionState, record: TurnStartRecord | TurnCompleteRecord): void { + try { + appendFileSync(session.filePath, JSON.stringify(record) + '\n', 'utf-8') + } catch { + // Never crash Claude Code over logging + } +} + +// ── Public API ────────────────────────────────────────────────────────────────── + +/** + * Set the pending input for the next turn. + * Called before each LLM call to capture system prompt and messages. + */ +export function setPendingInput(system: string[], messages: unknown[]): void { + if (!isConversationLoggingEnabled()) return + const sessionID = getSessionId() + const config = getConversationLoggingConfig() + const session = getSession(sessionID, config.outputDir) + + session.pendingInput = { + system, + messages, + } +} + +/** + * Log the start of a turn. + * Called when a new LLM request begins. + */ +export function logTurnStart( + model: { id: string; providerID: string; variant: string }, + agent: string, +): void { + if (!isConversationLoggingEnabled()) return + const sessionID = getSessionId() + const config = getConversationLoggingConfig() + const session = getSession(sessionID, config.outputDir) + + if (!session.conversationID) { + session.conversationID = shortId() + } + + session.turnIndex++ + const timestamp = new Date().toISOString() + + session.currentTurn = { + startedAt: timestamp, + model, + agent, + input: session.pendingInput ?? { system: [], messages: [] }, + text: '', + reasoningBlocks: [], + toolCalls: new Map(), + retries: [], + } + + // Write turn_start immediately + const record: TurnStartRecord = { + type: 'turn_start', + timestamp, + sessionID, + conversationID: session.conversationID, + turnIndex: session.turnIndex, + input: { + system: session.currentTurn.input.system, + messages: session.currentTurn.input.messages, + }, + } + writeLine(session, record) + + // Clear pending input + session.pendingInput = null +} + +/** + * Log text output from the model. + * Called when text content is streamed. + */ +export function logTextOutput(text: string): void { + if (!isConversationLoggingEnabled()) return + const sessionID = getSessionId() + const config = getConversationLoggingConfig() + const session = getSession(sessionID, config.outputDir) + + if (session.currentTurn) { + session.currentTurn.text = text + } +} + +/** + * Log thinking/reasoning output from the model. + * Called when thinking content is streamed. + */ +export function logThinkingOutput(reasoningID: string, text: string): void { + if (!isConversationLoggingEnabled()) return + const sessionID = getSessionId() + const config = getConversationLoggingConfig() + const session = getSession(sessionID, config.outputDir) + + if (session.currentTurn) { + session.currentTurn.reasoningBlocks.push({ reasoningID, text }) + } +} + +/** + * Log a tool call. + * Called when the model invokes a tool. + */ +export function logToolCall(callID: string, name: string, input: unknown): void { + if (!isConversationLoggingEnabled()) return + const sessionID = getSessionId() + const config = getConversationLoggingConfig() + const session = getSession(sessionID, config.outputDir) + + if (session.currentTurn) { + session.currentTurn.toolCalls.set(callID, { + callID, + name, + input, + providerExecuted: false, + }) + } +} + +/** + * Log a tool result. + * Called when a tool execution completes successfully. + */ +export function logToolResult(callID: string, result: unknown): void { + if (!isConversationLoggingEnabled()) return + const sessionID = getSessionId() + const config = getConversationLoggingConfig() + const session = getSession(sessionID, config.outputDir) + + if (session.currentTurn) { + const tc = session.currentTurn.toolCalls.get(callID) + if (tc) { + tc.result = result + } + } +} + +/** + * Log a tool error. + * Called when a tool execution fails. + */ +export function logToolError(callID: string, error: unknown): void { + if (!isConversationLoggingEnabled()) return + const sessionID = getSessionId() + const config = getConversationLoggingConfig() + const session = getSession(sessionID, config.outputDir) + + if (session.currentTurn) { + const tc = session.currentTurn.toolCalls.get(callID) + if (tc) { + tc.error = error + } + } +} + +/** + * Log a retry event. + * Called when an API call is retried. + */ +export function logRetry(attempt: number, error: Error, statusCode?: number): void { + if (!isConversationLoggingEnabled()) return + const sessionID = getSessionId() + const config = getConversationLoggingConfig() + const session = getSession(sessionID, config.outputDir) + + if (session.currentTurn) { + session.currentTurn.retries.push({ + attempt, + error: { + message: error.message, + statusCode, + isRetryable: true, + }, + }) + } +} + +/** + * Log the completion of a turn. + * Called when the LLM response is complete. + */ +export function logTurnComplete( + usage: { + input_tokens: number + output_tokens: number + cache_read_input_tokens?: number + cache_creation_input_tokens?: number + }, + stopReason: string | null | undefined, + cost: number, + messageId: string, +): void { + if (!isConversationLoggingEnabled()) return + const sessionID = getSessionId() + const config = getConversationLoggingConfig() + const session = getSession(sessionID, config.outputDir) + + if (!session.currentTurn) return + + const turn = session.currentTurn + + const record: TurnCompleteRecord = { + type: 'turn_complete', + timestamp: new Date().toISOString(), + sessionID, + conversationID: session.conversationID, + turnIndex: session.turnIndex, + output: { + message: { + id: messageId, + sessionID, + role: 'assistant', + model: turn.model, + agent: turn.agent, + tokens: { + input: usage.input_tokens ?? 0, + output: usage.output_tokens ?? 0, + reasoning: 0, + cache: { + read: usage.cache_read_input_tokens ?? 0, + write: usage.cache_creation_input_tokens ?? 0, + }, + }, + cost, + finish: stopReason ?? 'unknown', + }, + parts: buildOutputParts(turn, config.maxTextLength), + retries: turn.retries.length > 0 ? turn.retries : undefined, + }, + } + + writeLine(session, record) + session.currentTurn = null +} + +/** + * Log a turn error. + * Called when the LLM request fails. + */ +export function logTurnError(error: string): void { + if (!isConversationLoggingEnabled()) return + const sessionID = getSessionId() + const config = getConversationLoggingConfig() + const session = getSession(sessionID, config.outputDir) + + if (!session.currentTurn) return + + const turn = session.currentTurn + + const record: TurnCompleteRecord = { + type: 'turn_complete', + timestamp: new Date().toISOString(), + sessionID, + conversationID: session.conversationID, + turnIndex: session.turnIndex, + output: { + message: { + id: '', + sessionID, + role: 'assistant', + model: turn.model, + agent: turn.agent, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + cost: 0, + finish: 'error', + }, + parts: buildOutputParts(turn, config.maxTextLength), + retries: turn.retries.length > 0 ? turn.retries : undefined, + error: truncate(error, config.maxTextLength), + }, + } + + writeLine(session, record) + session.currentTurn = null +} + +/** + * Log a compaction event. + * Called when context compaction occurs. + * This starts a new conversation boundary. + */ +export function logCompaction(): void { + if (!isConversationLoggingEnabled()) return + const sessionID = getSessionId() + const config = getConversationLoggingConfig() + const session = getSession(sessionID, config.outputDir) + + // Start a new conversation after compaction + session.conversationID = shortId() + session.turnIndex = 0 +} + +/** + * Clear the current turn state. + * Called when the conversation is cleared or reset. + */ +export function clearCurrentTurn(): void { + if (!isConversationLoggingEnabled()) return + const sessionID = getSessionId() + const config = getConversationLoggingConfig() + const session = getSession(sessionID, config.outputDir) + + session.currentTurn = null + session.conversationID = '' + session.turnIndex = 0 +} + +// Re-export types for external use +export type { TurnStartRecord as TurnStartRecordType, TurnCompleteRecord as TurnCompleteRecordType } diff --git a/src/services/tools/toolExecution.ts b/src/services/tools/toolExecution.ts index 40ef38e..441dd95 100644 --- a/src/services/tools/toolExecution.ts +++ b/src/services/tools/toolExecution.ts @@ -18,6 +18,11 @@ import { mcpToolDetailsForAnalytics, sanitizeToolNameForAnalytics, } from 'src/services/analytics/metadata.js' +import { + isConversationLoggingEnabled, + logToolResult, + logToolError as logConversationToolError, +} from '../conversationLogger.js' import { addToToolDuration, getCodeEditToolDecisionCounter, @@ -1356,6 +1361,11 @@ async function checkPermissionsAndCallTool( ...mcpToolDetailsForAnalytics(tool.name, mcpServerType, mcpServerBaseUrl), }) + // Conversation logging: log tool result + if (isConversationLoggingEnabled()) { + logToolResult(toolUseID, result.data) + } + // Enrich tool parameters with git commit ID from successful git commit output if ( isToolDetailsLoggingEnabled() && @@ -1687,6 +1697,11 @@ async function checkPermissionsAndCallTool( }), ...(mcpServerScope && { mcp_server_scope: mcpServerScope }), }) + + // Conversation logging: log tool error + if (isConversationLoggingEnabled()) { + logConversationToolError(toolUseID, errorMessage(error)) + } } const content = formatError(error) diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index ba89edd..80b64f9 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -1068,6 +1068,31 @@ export const SettingsSchema = lazySchema(() => 'Useful for enterprise administrators to add organization-specific context ' + '(e.g., "All plugins from our internal marketplace are vetted and approved.").', ), + // Conversation logging configuration + // Note: thinking, tool results, and system prompts are always logged (not configurable) + conversationLogging: z + .object({ + enabled: z + .boolean() + .optional() + .describe('Enable conversation logging to JSONL files'), + outputDir: z + .string() + .optional() + .describe('Output directory for log files (default: .logs)'), + maxTextLength: z + .number() + .int() + .positive() + .optional() + .describe('Max text field length before truncation (default: 50000)'), + }) + .optional() + .describe( + 'Conversation logging configuration. ' + + 'When enabled, captures every LLM turn to JSONL files for debugging and analysis. ' + + 'Note: thinking, tool results, and system prompts are always logged for completeness.', + ), }) .passthrough(), )