mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
558 lines
24 KiB
TypeScript
558 lines
24 KiB
TypeScript
/**
|
|
* Converter from SDK message types to log format (RawJSONLines)
|
|
* Transforms Claude SDK messages into the format expected by session logs
|
|
*/
|
|
|
|
import { randomUUID } from 'node:crypto'
|
|
import { execSync } from 'node:child_process'
|
|
import type {
|
|
SDKMessage,
|
|
SDKUserMessage,
|
|
SDKAssistantMessage,
|
|
SDKSystemMessage,
|
|
SDKResultMessage
|
|
} from '@/claude/sdk'
|
|
import type { RawJSONLines } from '@/claude/types'
|
|
import type { ClaudePermissionMode } from '@hapi/protocol/types'
|
|
import { logger } from '@/lib'
|
|
|
|
/**
|
|
* SDK message types this converter knows how to turn into a transcript line.
|
|
*
|
|
* Anything outside this set is dropped. Claude Code keeps adding out-of-band
|
|
* SDK events (`tool_progress` heartbeats, stream events, control responses),
|
|
* and they are not conversation content — passing them through would stamp
|
|
* them with transcript base fields (parentUuid/sessionId/userType), which
|
|
* makes them indistinguishable from a real log line downstream. The web
|
|
* normalizer can't match them to any known shape and falls back to rendering
|
|
* the raw envelope as message text, leaking JSON into the chat.
|
|
*
|
|
* The local launcher already enforces the same allowlist via
|
|
* `RawJSONLinesSchema.safeParse` in sessionScanner; this keeps the remote
|
|
* (SDK) path at parity instead of leaving it open by default.
|
|
*/
|
|
const CONVERTIBLE_SDK_MESSAGE_TYPES = new Set([
|
|
'user',
|
|
'assistant',
|
|
'system',
|
|
'result',
|
|
'tool_result'
|
|
])
|
|
|
|
/**
|
|
* Context for converting SDK messages to log format
|
|
*/
|
|
export interface ConversionContext {
|
|
sessionId: string
|
|
cwd: string
|
|
version?: string
|
|
gitBranch?: string
|
|
parentUuid?: string | null
|
|
// The model preset the session actually selected at launch time (e.g. "fable[1m]"),
|
|
// with the `[1m]` suffix intact. Some 1M presets (fable[1m]) arrive on system/init
|
|
// with the suffix already dropped ("claude-fable-5"), so this preserved preset is
|
|
// the only turn-1 signal that such a session is 1M. Used only to seed the very first
|
|
// contextWindow estimate before result.modelUsage confirms the real value.
|
|
selectedModel?: string | null
|
|
}
|
|
|
|
type PermissionResponse = {
|
|
approved: boolean
|
|
mode?: ClaudePermissionMode
|
|
reason?: string
|
|
}
|
|
|
|
/**
|
|
* Get current git branch for the working directory
|
|
*/
|
|
function getGitBranch(cwd: string): string | undefined {
|
|
try {
|
|
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'ignore']
|
|
}).trim()
|
|
return branch || undefined
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SDK to Log converter class
|
|
* Maintains state for parent-child relationships between messages
|
|
*/
|
|
export class SDKToLogConverter {
|
|
private lastUuid: string | null = null
|
|
private context: ConversionContext
|
|
private responses?: Map<string, PermissionResponse>
|
|
private sidechainLastUUID = new Map<string, string>();
|
|
// The raw model id from the most recent system/init (the session's authoritative model).
|
|
private resolvedModel: string | null = null
|
|
// The cache key for the current session model's contextWindow. Usually equal to
|
|
// resolvedModel, but for presets whose "[1m]" variant the CLI still reports with the
|
|
// bare id (fable[1m] arrives as "claude-fable-5", same as plain fable) it folds the
|
|
// selectedModel's "[1m]" back in so the two variants don't collide. See
|
|
// computeContextWindowKey.
|
|
private resolvedContextWindowKey: string | null = null
|
|
// Per-model contextWindow cache. Keys are the CLI's model id, except that for a
|
|
// preset whose "[1m]" variant shares the bare id of its plain form (fable) the "[1m]"
|
|
// is folded back into the key (computeContextWindowKey) so a 1M variant and a
|
|
// potentially-smaller plain variant stay on distinct entries. opus[1m]/sonnet[1m]
|
|
// already arrive suffixed from the CLI, so their keys are unchanged. Keying per model
|
|
// — rather than a single sticky number — means a mid-session model switch picks up the
|
|
// new model's own window immediately instead of inheriting the previous model's.
|
|
private modelContextWindows = new Map<string, number>()
|
|
|
|
constructor(
|
|
context: Omit<ConversionContext, 'parentUuid'>,
|
|
responses?: Map<string, PermissionResponse>
|
|
) {
|
|
this.context = {
|
|
...context,
|
|
gitBranch: context.gitBranch ?? getGitBranch(context.cwd),
|
|
version: context.version ?? process.env.npm_package_version ?? '0.0.0',
|
|
parentUuid: null
|
|
}
|
|
this.responses = responses
|
|
}
|
|
|
|
/**
|
|
* Compute the contextWindow cache key for an init model id.
|
|
*
|
|
* The CLI reports opus[1m]/sonnet[1m] with the "[1m]" suffix already on the id, but
|
|
* reports fable[1m] with the same bare id as plain fable ("claude-fable-5"). To keep a
|
|
* 1M variant from colliding with its (possibly-smaller) plain form, we fold the "[1m]"
|
|
* back onto the bare id when the session's selected preset asks for 1M. Ids that
|
|
* already carry the suffix are returned unchanged.
|
|
*/
|
|
private computeContextWindowKey(model: string): string {
|
|
if (model.endsWith('[1m]')) {
|
|
return model
|
|
}
|
|
const wants1m = this.context.selectedModel?.endsWith('[1m]') ?? false
|
|
return wants1m ? `${model}[1m]` : model
|
|
}
|
|
|
|
/**
|
|
* Update the originally-selected model hint (for when the session's model
|
|
* changes mid-conversation, e.g. via the web model picker). `context.selectedModel`
|
|
* is only a turn-1 seed hint (see the system/init handler in `convert()`), but the
|
|
* caller (claudeRemoteLauncher) re-resolves the active mode -- including its model --
|
|
* on every turn, not just the first, since a single long-running `claudeRemote()`
|
|
* call keeps accepting new turns with a live-updatable mode. Without this update,
|
|
* a mid-session switch would seed new models from a stale, session-start value:
|
|
* switching *to* an 1M preset would under-seed (still guess 200k for its first
|
|
* turn), and switching *away from* one would over-seed (guess 1M for a model that
|
|
* isn't 1M-capable) until a result message corrects it.
|
|
*/
|
|
updateSelectedModel(model: string | null | undefined): void {
|
|
this.context.selectedModel = model ?? null
|
|
}
|
|
|
|
/**
|
|
* Update session ID (for when session changes during resume)
|
|
*/
|
|
updateSessionId(sessionId: string): void {
|
|
this.context.sessionId = sessionId
|
|
}
|
|
|
|
/**
|
|
* Reset parent chain (useful when starting new conversation)
|
|
*/
|
|
resetParentChain(): void {
|
|
this.lastUuid = null
|
|
this.context.parentUuid = null
|
|
}
|
|
|
|
/**
|
|
* Convert rate_limit_event to pipe-delimited text matching the ACP path format,
|
|
* or suppress if the status does not need display (e.g. 'allowed').
|
|
* Must not mutate converter state (UUID chain) so dropped events are invisible.
|
|
*/
|
|
private convertRateLimitEvent(sdkMessage: SDKMessage): RawJSONLines | null {
|
|
const info = (sdkMessage as any).rate_limit_info
|
|
if (typeof info !== 'object' || info === null) return null
|
|
|
|
const { status, resetsAt, utilization, rateLimitType } = info
|
|
|
|
if (status === 'allowed') return null
|
|
if (typeof resetsAt !== 'number') return null
|
|
|
|
const resetsAtInt = Math.round(resetsAt)
|
|
let text: string
|
|
|
|
if (status === 'allowed_warning') {
|
|
const pct = typeof utilization === 'number' ? Math.round(utilization * 100) : 0
|
|
const limitType = typeof rateLimitType === 'string' ? rateLimitType : ''
|
|
text = `Claude AI usage limit warning|${resetsAtInt}|${pct}|${limitType}`
|
|
} else if (status === 'rejected') {
|
|
const limitType = typeof rateLimitType === 'string' ? rateLimitType : ''
|
|
text = `Claude AI usage limit reached|${resetsAtInt}|${limitType}`
|
|
} else {
|
|
return null
|
|
}
|
|
|
|
const parentUuid = this.lastUuid
|
|
const uuid = randomUUID()
|
|
const timestamp = new Date().toISOString()
|
|
this.lastUuid = uuid
|
|
|
|
return {
|
|
parentUuid,
|
|
isSidechain: false,
|
|
userType: 'external' as const,
|
|
cwd: this.context.cwd,
|
|
sessionId: this.context.sessionId,
|
|
version: this.context.version,
|
|
gitBranch: this.context.gitBranch,
|
|
uuid,
|
|
timestamp,
|
|
type: 'assistant',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text }]
|
|
}
|
|
} as RawJSONLines
|
|
}
|
|
|
|
/**
|
|
* Convert SDK message to log format
|
|
*/
|
|
convert(sdkMessage: SDKMessage): RawJSONLines | null {
|
|
if (sdkMessage.type === 'rate_limit_event') {
|
|
return this.convertRateLimitEvent(sdkMessage)
|
|
}
|
|
|
|
// Bail before allocating a uuid or touching sidechain/parent tracking —
|
|
// an unknown event must not advance the transcript chain it never joins.
|
|
if (!CONVERTIBLE_SDK_MESSAGE_TYPES.has(sdkMessage.type)) {
|
|
logger.debug(`[sdkToLogConverter] dropping unsupported SDK message type: ${sdkMessage.type}`)
|
|
return null
|
|
}
|
|
|
|
const uuid = randomUUID()
|
|
const timestamp = new Date().toISOString()
|
|
let parentUuid = this.lastUuid;
|
|
let isSidechain = false;
|
|
// Preserved (not just consumed) so the web tracer can group sidechain
|
|
// messages directly by the spawning Agent tool_use id, instead of relying
|
|
// solely on the SDK emitting a prompt-holding sidechain root to exact-match
|
|
// against. Some subagents (e.g. background/task_started) never emit that
|
|
// root, orphaning every child that only carries this id.
|
|
let parentToolUseId: string | undefined;
|
|
if (sdkMessage.parent_tool_use_id) {
|
|
isSidechain = true;
|
|
parentToolUseId = (sdkMessage as any).parent_tool_use_id;
|
|
parentUuid = this.sidechainLastUUID.get((sdkMessage as any).parent_tool_use_id) ?? null;
|
|
this.sidechainLastUUID.set((sdkMessage as any).parent_tool_use_id!, uuid);
|
|
}
|
|
const baseFields = {
|
|
parentUuid: parentUuid,
|
|
isSidechain: isSidechain,
|
|
parentToolUseId,
|
|
userType: 'external' as const,
|
|
cwd: this.context.cwd,
|
|
sessionId: this.context.sessionId,
|
|
version: this.context.version,
|
|
gitBranch: this.context.gitBranch,
|
|
uuid,
|
|
timestamp
|
|
}
|
|
|
|
let logMessage: RawJSONLines | null = null
|
|
|
|
switch (sdkMessage.type) {
|
|
case 'user': {
|
|
const userMsg = sdkMessage as SDKUserMessage
|
|
logMessage = {
|
|
...baseFields,
|
|
type: 'user',
|
|
message: userMsg.message
|
|
}
|
|
|
|
// Check if this is a tool result and add mode if available
|
|
if (Array.isArray(userMsg.message.content)) {
|
|
for (const content of userMsg.message.content) {
|
|
if (content.type === 'tool_result' && content.tool_use_id && this.responses?.has(content.tool_use_id)) {
|
|
const response = this.responses.get(content.tool_use_id)
|
|
if (response?.mode) {
|
|
(logMessage as any).mode = response.mode
|
|
}
|
|
}
|
|
}
|
|
} else if (typeof userMsg.message.content === 'string') {
|
|
// Simple string content, no tool result
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'assistant': {
|
|
const assistantMsg = sdkMessage as SDKAssistantMessage
|
|
const message = assistantMsg.message as Record<string, unknown>
|
|
// Look up the contextWindow by the session's resolved cache key (derived
|
|
// from the last system/init model), NOT the assistant message's own `model`
|
|
// field. The message's model is always reported bare (no "[1m]"), so it
|
|
// can't tell a 200k plain preset apart from its 1M "[1m]" variant when they
|
|
// share a base id; resolvedContextWindowKey carries the disambiguated key.
|
|
// Using the resolved key also means sidechain (Task subagent) messages carry
|
|
// the MAIN session window rather than the subagent's own — the web status
|
|
// bar's latestUsage picks the most recent usage message without filtering
|
|
// sidechains (Claude usage carries no scope_role), so a subagent's smaller
|
|
// window would otherwise make the footer denominator visibly drop while it
|
|
// runs.
|
|
const contextWindow = this.resolvedContextWindowKey
|
|
? this.modelContextWindows.get(this.resolvedContextWindowKey)
|
|
: undefined
|
|
if (contextWindow !== undefined && message && typeof message.usage === 'object' && message.usage !== null) {
|
|
const usage = message.usage as Record<string, unknown>
|
|
if (usage.context_window === undefined) {
|
|
usage.context_window = contextWindow
|
|
}
|
|
}
|
|
logMessage = {
|
|
...baseFields,
|
|
type: 'assistant',
|
|
message: assistantMsg.message,
|
|
// Assistant messages often have additional fields
|
|
requestId: (assistantMsg as any).requestId
|
|
}
|
|
// if (assistantMsg.message.content && Array.isArray(assistantMsg.message.content)) {
|
|
// for (const content of assistantMsg.message.content) {
|
|
// if (content.type === 'tool_use' && content.id) {
|
|
// this.sidechainLastUUID.set(content.id, uuid);
|
|
// }
|
|
// }
|
|
// }
|
|
break
|
|
}
|
|
|
|
case 'system': {
|
|
const systemMsg = sdkMessage as SDKSystemMessage
|
|
|
|
// System messages with subtype 'init' might update session ID
|
|
if (systemMsg.subtype === 'init' && systemMsg.session_id) {
|
|
this.updateSessionId(systemMsg.session_id)
|
|
}
|
|
|
|
// Capture the resolved model name on init. The remote launcher re-emits
|
|
// system/init on every turn for the lifetime of this converter, so if we
|
|
// already learned this model's real contextWindow from a previous result
|
|
// message, leave it alone — recomputing a heuristic guess here would
|
|
// downgrade an already-known-good value and is the exact cause of the
|
|
// 200k<->1M flicker. Only seed a heuristic when this model has no cached
|
|
// value yet (first time we see it in this session).
|
|
if (systemMsg.subtype === 'init' && typeof systemMsg.model === 'string') {
|
|
this.resolvedModel = systemMsg.model
|
|
this.resolvedContextWindowKey = this.computeContextWindowKey(systemMsg.model)
|
|
if (!this.modelContextWindows.has(this.resolvedContextWindowKey)) {
|
|
// Best-effort 1M-vs-200k seed for turn 1, before any authoritative
|
|
// result has arrived. `systemMsg.model` only tells us it's a 1M
|
|
// model for the presets whose init keeps the "[1m]" suffix
|
|
// (opus[1m]/sonnet[1m]); for others the init model is bare even
|
|
// when it's a 1M preset (fable[1m] -> "claude-fable-5"). So we
|
|
// primarily consult the originally-selected preset, which always
|
|
// preserves the suffix (e.g. "fable[1m]"), and fall back to the
|
|
// init model string. This selectedModel seed is load-bearing —
|
|
// without it, a fresh fable[1m] turn would flash 200k until the
|
|
// first result lands. Guarding/seeding on resolvedContextWindowKey
|
|
// (not the bare init id) is what forces a re-seed when switching
|
|
// fable[1m] <-> fable, whose bare ids would otherwise be identical.
|
|
const seedIs1m = (this.context.selectedModel?.endsWith('[1m]') ?? false)
|
|
|| systemMsg.model.endsWith('[1m]')
|
|
this.modelContextWindows.set(this.resolvedContextWindowKey, seedIs1m ? 1_000_000 : 200_000)
|
|
}
|
|
}
|
|
|
|
// System messages are typically not sent to logs
|
|
// but we can convert them if needed
|
|
logMessage = {
|
|
...baseFields,
|
|
type: 'system',
|
|
subtype: systemMsg.subtype,
|
|
model: systemMsg.model,
|
|
tools: systemMsg.tools,
|
|
// Include all other fields
|
|
...(systemMsg as any)
|
|
}
|
|
break
|
|
}
|
|
|
|
case 'result': {
|
|
// Result messages are not converted to log messages
|
|
// They're SDK-specific messages that indicate session completion
|
|
// Not part of the actual conversation log.
|
|
//
|
|
// But they carry the authoritative per-model contextWindow. modelUsage is
|
|
// keyed by the same raw model id the CLI reports on system/init, so the
|
|
// entry for the current session model is stored under resolvedContextWindowKey
|
|
// (which folds in the "[1m]" for fable), matching what assistant lookups use.
|
|
// Other entries — Task subagents like haiku — are stored under their own raw
|
|
// id (never fold the session's "[1m]" onto a subagent; haiku is 200k). Always
|
|
// overwrite on result — it is ground truth — for every model reported, so a
|
|
// model switched away from earlier this session keeps its real value cached
|
|
// for if/when the session switches back to it.
|
|
const resultMsg = sdkMessage as SDKResultMessage
|
|
if (resultMsg.modelUsage) {
|
|
for (const [model, usage] of Object.entries(resultMsg.modelUsage)) {
|
|
const cw = usage?.contextWindow
|
|
if (typeof cw === 'number' && cw > 0) {
|
|
const key = (this.resolvedModel && model === this.resolvedModel)
|
|
? (this.resolvedContextWindowKey ?? model)
|
|
: model
|
|
this.modelContextWindows.set(key, cw)
|
|
}
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
// Handle tool use results (often comes as user messages)
|
|
case 'tool_result': {
|
|
const toolMsg = sdkMessage as any
|
|
const baseLogMessage: any = {
|
|
...baseFields,
|
|
type: 'user',
|
|
message: {
|
|
role: 'user',
|
|
content: [{
|
|
type: 'tool_result',
|
|
tool_use_id: toolMsg.tool_use_id,
|
|
content: toolMsg.content
|
|
}]
|
|
},
|
|
toolUseResult: toolMsg.content
|
|
}
|
|
|
|
// Add mode if available from responses
|
|
if (toolMsg.tool_use_id && this.responses?.has(toolMsg.tool_use_id)) {
|
|
const response = this.responses.get(toolMsg.tool_use_id)
|
|
if (response?.mode) {
|
|
baseLogMessage.mode = response.mode
|
|
}
|
|
}
|
|
|
|
logMessage = baseLogMessage
|
|
break
|
|
}
|
|
|
|
default:
|
|
// Unreachable: CONVERTIBLE_SDK_MESSAGE_TYPES gates this switch.
|
|
// Kept as a fail-closed guard so that adding a type to the set
|
|
// without a matching case here drops the message instead of
|
|
// passing an unshaped envelope through to the chat.
|
|
logger.debug(`[sdkToLogConverter] no case for allowlisted type: ${(sdkMessage as any).type}`)
|
|
break
|
|
}
|
|
|
|
// Update last UUID for parent tracking
|
|
if (logMessage && logMessage.type !== 'summary') {
|
|
this.lastUuid = uuid
|
|
}
|
|
|
|
return logMessage
|
|
}
|
|
|
|
/**
|
|
* Convert multiple SDK messages to log format
|
|
*/
|
|
convertMany(sdkMessages: SDKMessage[]): RawJSONLines[] {
|
|
return sdkMessages
|
|
.map(msg => this.convert(msg))
|
|
.filter((msg): msg is RawJSONLines => msg !== null)
|
|
}
|
|
|
|
/**
|
|
* Convert a simple string content to a sidechain user message
|
|
* Used for Task tool sub-agent prompts
|
|
*/
|
|
convertSidechainUserMessage(toolUseId: string, content: string): RawJSONLines {
|
|
const uuid = randomUUID()
|
|
const timestamp = new Date().toISOString()
|
|
this.sidechainLastUUID.set(toolUseId, uuid);
|
|
return {
|
|
parentUuid: null,
|
|
isSidechain: true,
|
|
userType: 'external' as const,
|
|
cwd: this.context.cwd,
|
|
sessionId: this.context.sessionId,
|
|
version: this.context.version,
|
|
gitBranch: this.context.gitBranch,
|
|
type: 'user',
|
|
message: {
|
|
role: 'user',
|
|
content: content
|
|
},
|
|
uuid,
|
|
timestamp
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate an interrupted tool result message
|
|
* Used when a tool call is interrupted by the user
|
|
* @param toolUseId - The ID of the tool that was interrupted
|
|
* @param parentToolUseId - Optional parent tool ID if this is a sidechain tool
|
|
*/
|
|
generateInterruptedToolResult(toolUseId: string, parentToolUseId?: string | null): RawJSONLines {
|
|
const uuid = randomUUID()
|
|
const timestamp = new Date().toISOString()
|
|
const errorMessage = "[Request interrupted by user for tool use]"
|
|
|
|
// Determine if this is a sidechain and get parent UUID
|
|
let isSidechain = false
|
|
let parentUuid: string | null = this.lastUuid
|
|
|
|
if (parentToolUseId) {
|
|
isSidechain = true
|
|
// Look up the parent tool's UUID
|
|
parentUuid = this.sidechainLastUUID.get(parentToolUseId) ?? null
|
|
// Track this tool in the sidechain map
|
|
this.sidechainLastUUID.set(parentToolUseId, uuid)
|
|
}
|
|
|
|
const logMessage: RawJSONLines = {
|
|
type: 'user',
|
|
isSidechain: isSidechain,
|
|
parentToolUseId: parentToolUseId ?? undefined,
|
|
uuid,
|
|
message: {
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
content: errorMessage,
|
|
is_error: true,
|
|
tool_use_id: toolUseId
|
|
}
|
|
]
|
|
},
|
|
parentUuid: parentUuid,
|
|
userType: 'external' as const,
|
|
cwd: this.context.cwd,
|
|
sessionId: this.context.sessionId,
|
|
version: this.context.version,
|
|
gitBranch: this.context.gitBranch,
|
|
timestamp,
|
|
toolUseResult: `Error: ${errorMessage}`
|
|
} as any
|
|
|
|
// Update last UUID for tracking
|
|
this.lastUuid = uuid
|
|
|
|
return logMessage
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convenience function for one-off conversions
|
|
*/
|
|
export function convertSDKToLog(
|
|
sdkMessage: SDKMessage,
|
|
context: Omit<ConversionContext, 'parentUuid'>,
|
|
responses?: Map<string, PermissionResponse>
|
|
): RawJSONLines | null {
|
|
const converter = new SDKToLogConverter(context, responses)
|
|
return converter.convert(sdkMessage)
|
|
}
|