@dreb/coding-agent 2.6.2 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +18 -0
- package/dist/cli/args.d.ts +1 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +3 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/core/buddy/buddy-controller.d.ts +3 -0
- package/dist/core/buddy/buddy-controller.d.ts.map +1 -1
- package/dist/core/buddy/buddy-controller.js +51 -16
- package/dist/core/buddy/buddy-controller.js.map +1 -1
- package/dist/core/session-manager.d.ts +8 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +11 -0
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/tools/subagent.d.ts.map +1 -1
- package/dist/core/tools/subagent.js +33 -15
- package/dist/core/tools/subagent.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +4 -0
- package/dist/main.js.map +1 -1
- package/docs/json.md +2 -1
- package/docs/rpc.md +1 -1
- package/docs/session.md +6 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"buddy-controller.js","sourceRoot":"","sources":["../../../src/core/buddy/buddy-controller.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * BuddyController — Frontend-agnostic controller for the buddy companion.\n *\n * Owns: context buffer, idle timer, reaction throttle, name-call detection.\n * Extracted from InteractiveMode so both TUI and Telegram can compose it\n * without duplicating ~150 lines of buddy wiring logic.\n *\n * The host (TUI or Telegram) provides callbacks for frontend-specific rendering:\n * - onSpeech(text) — display a speech bubble / message\n * - onThinkingStart() / onThinkingEnd() — show/hide thinking indicator\n *\n * Policies are configurable via BuddyControllerConfig so the TUI (no limits)\n * and Telegram (activity gating + reaction budget) can use different strategies.\n */\n\nimport { type BuddyManager, checkOllama } from \"./buddy-manager.js\";\nimport type { BuddyState } from \"./buddy-types.js\";\n\n/** Frontend-provided callbacks for buddy rendering */\nexport interface BuddyCallbacks {\n\t/** Display a speech/reaction message from the buddy */\n\tonSpeech: (text: string) => void;\n\t/** Show a thinking/loading indicator */\n\tonThinkingStart: () => void;\n\t/** Hide the thinking/loading indicator */\n\tonThinkingEnd: () => void;\n\t/** Hatch a new buddy — frontend resolves API key and calls manager.hatch() */\n\tonHatch: (manager: BuddyManager) => Promise<BuddyState>;\n\t/** Reroll the buddy — frontend resolves API key and calls manager.reroll() */\n\tonReroll: (manager: BuddyManager) => Promise<BuddyState>;\n}\n\n/** Configuration for buddy behavior — differs between TUI and Telegram */\nexport interface BuddyControllerConfig {\n\t/** Max entries in the context buffer (default: 20) */\n\tcontextMaxEntries?: number;\n\t/** Idle timeout in ms before buddy reacts to silence (default: 30000) */\n\tidleTimeoutMs?: number;\n\t/** Minimum ms between reactions (default: 60000) */\n\treactionCooldownMs?: number;\n\t/** If set, pause idle timer when user has been inactive this many ms */\n\tactivityGateMs?: number;\n\t/** If set, cap reactions to this many per hour */\n\treactionsPerHour?: number;\n}\n\n/** Subcommand result for frontend to render */\nexport type BuddyCommandResult =\n\t| { type: \"hatch\"; state: BuddyState }\n\t| { type: \"show\"; state: BuddyState }\n\t| { type: \"reroll\"; state: BuddyState }\n\t| { type: \"pet\" }\n\t| { type: \"stats\"; state: BuddyState }\n\t| { type: \"off\" }\n\t| { type: \"model\"; message: string }\n\t| { type: \"warning\"; message: string }\n\t| { type: \"error\"; message: string };\n\nexport class BuddyController {\n\tprivate contextBuffer: string[] = [];\n\tprivate lastReactionTime = 0;\n\tprivate idleTimer: ReturnType<typeof setTimeout> | null = null;\n\tprivate lastActivityTime = 0;\n\tprivate reactionTimestamps: number[] = []; // for budget tracking\n\t/** When false, all active functionality is disabled: no reactions, name-calls,\n\t * idle timer, or Ollama calls. Context capture (passive) still happens. */\n\tenabled = true;\n\n\treadonly manager: BuddyManager;\n\tprivate readonly callbacks: BuddyCallbacks;\n\tprivate readonly config: Required<BuddyControllerConfig>;\n\n\tconstructor(manager: BuddyManager, callbacks: BuddyCallbacks, config?: BuddyControllerConfig) {\n\t\tthis.manager = manager;\n\t\tthis.callbacks = callbacks;\n\t\tthis.config = {\n\t\t\tcontextMaxEntries: config?.contextMaxEntries ?? 20,\n\t\t\tidleTimeoutMs: config?.idleTimeoutMs ?? 30_000,\n\t\t\treactionCooldownMs: config?.reactionCooldownMs ?? 60_000,\n\t\t\tactivityGateMs: config?.activityGateMs ?? 0, // 0 = no gating (TUI default)\n\t\t\treactionsPerHour: config?.reactionsPerHour ?? 0, // 0 = unlimited (TUI default)\n\t\t};\n\t}\n\n\t// =========================================================================\n\t// Context buffer\n\t// =========================================================================\n\n\t/** Append an entry to the buddy context buffer (evicts oldest if at capacity) */\n\tappendContext(entry: string): void {\n\t\tthis.contextBuffer.push(entry);\n\t\tif (this.contextBuffer.length > this.config.contextMaxEntries) {\n\t\t\tthis.contextBuffer.shift();\n\t\t}\n\t}\n\n\t/** Build the context buffer into a string for LLM prompts */\n\tbuildContext(): string {\n\t\tif (this.contextBuffer.length === 0) {\n\t\t\treturn \"No recent activity.\";\n\t\t}\n\t\treturn this.contextBuffer.join(\"\\n\");\n\t}\n\n\t// =========================================================================\n\t// Activity & idle timer\n\t// =========================================================================\n\n\t/** Mark that user activity occurred (for activity gating) */\n\tmarkActivity(): void {\n\t\tthis.lastActivityTime = Date.now();\n\t}\n\n\t/** Reset the idle timer — called on every user message */\n\tresetIdleTimer(): void {\n\t\tif (this.idleTimer) {\n\t\t\tclearTimeout(this.idleTimer);\n\t\t}\n\n\t\tif (!this.enabled) return;\n\n\t\tif (!this.manager.getState()) return; // No buddy loaded, skip idle timer\n\n\t\t// Activity gating: skip idle timer if user has been inactive too long\n\t\tif (this.config.activityGateMs > 0 && this.lastActivityTime > 0) {\n\t\t\tconst elapsed = Date.now() - this.lastActivityTime;\n\t\t\tif (elapsed > this.config.activityGateMs) {\n\t\t\t\tthis.idleTimer = null;\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tthis.idleTimer = setTimeout(() => {\n\t\t\tconst ctx = this.buildContext();\n\t\t\tthis.triggerReaction(`It's been quiet for a moment. Recent activity:\\n${ctx}`).catch(() => {\n\t\t\t\t/* triggerReaction() logs errors internally — prevents unhandled rejection */\n\t\t\t});\n\t\t}, this.config.idleTimeoutMs);\n\t}\n\n\t// =========================================================================\n\t// Reactions\n\t// =========================================================================\n\n\t/** Check if a reaction is allowed under current throttle and budget */\n\tprivate canReact(): boolean {\n\t\tif (!this.enabled) return false;\n\n\t\tconst now = Date.now();\n\n\t\t// Cooldown throttle\n\t\tif (now - this.lastReactionTime < this.config.reactionCooldownMs) {\n\t\t\treturn false;\n\t\t}\n\n\t\t// Reaction budget (per hour)\n\t\tif (this.config.reactionsPerHour > 0) {\n\t\t\tconst oneHourAgo = now - 3_600_000;\n\t\t\tthis.reactionTimestamps = this.reactionTimestamps.filter((t) => t > oneHourAgo);\n\t\t\tif (this.reactionTimestamps.length >= this.config.reactionsPerHour) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\treturn true;\n\t}\n\n\t/**\n\t * Trigger a buddy reaction. Throttled by cooldown and budget.\n\t * Calls onThinkingStart/End and onSpeech callbacks.\n\t * No-op if disabled.\n\t */\n\tasync triggerReaction(event: string): Promise<void> {\n\t\tif (!this.canReact()) return;\n\n\t\tthis.callbacks.onThinkingStart();\n\t\ttry {\n\t\t\tconst quip = await this.manager.react(event);\n\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\tif (quip) {\n\t\t\t\tthis.lastReactionTime = Date.now();\n\t\t\t\tthis.reactionTimestamps.push(Date.now());\n\t\t\t\tthis.callbacks.onSpeech(quip);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\tconsole.error(\"[buddy] triggerReaction failed:\", err instanceof Error ? err.message : err);\n\t\t}\n\t}\n\n\t/**\n\t * Handle a name-call from the user.\n\t * No-op if disabled — returns immediately without calling Ollama.\n\t */\n\tasync handleNameCall(userMessage: string): Promise<void> {\n\t\tif (!this.enabled) return;\n\t\tlet state = this.manager.getState();\n\t\tif (!state) {\n\t\t\t// Try loading from disk (buddy may have been hatched in another frontend)\n\t\t\tstate = this.manager.load();\n\t\t}\n\t\tif (!state) return;\n\n\t\tthis.callbacks.onThinkingStart();\n\t\ttry {\n\t\t\tconst response = await this.manager.respondToNameCall(userMessage, this.buildContext());\n\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\tif (response) {\n\t\t\t\tthis.callbacks.onSpeech(response);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\tconsole.error(\"[buddy] handleNameCall failed:\", err instanceof Error ? err.message : err);\n\t\t}\n\t}\n\n\t/** Check if a message contains the buddy's name (word-boundary matching).\n\t * Returns false if disabled. */\n\tdetectNameCall(text: string): boolean {\n\t\tif (!this.enabled) return false;\n\t\tconst name = this.manager.getName();\n\t\tif (!name) return false;\n\t\ttry {\n\t\t\tconst escaped = name.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\t\t\tconst regex = new RegExp(`\\\\b${escaped}\\\\b`, \"i\");\n\t\t\treturn regex.test(text);\n\t\t} catch {\n\t\t\t/* Invalid regex from buddy name — safe to return false */\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Event handling\n\t// =========================================================================\n\n\t/**\n\t * Process an agent event for buddy context capture and reaction triggers.\n\t * The host calls this from its event handler.\n\t *\n\t * Context capture always happens. Reactions are gated by `enabled`.\n\t */\n\thandleEvent(event: { type: string; [key: string]: any }): void {\n\t\tconst state = this.manager.getState();\n\t\tif (!state) return; // No buddy loaded\n\n\t\tswitch (event.type) {\n\t\t\tcase \"message_end\": {\n\t\t\t\tif (event.message?.role === \"assistant\") {\n\t\t\t\t\tconst textParts = event.message.content\n\t\t\t\t\t\t?.filter((c: any) => c.type === \"text\")\n\t\t\t\t\t\t?.map((c: any) => c.text)\n\t\t\t\t\t\t?.join(\"\")\n\t\t\t\t\t\t?.slice(0, 200);\n\t\t\t\t\tif (textParts) {\n\t\t\t\t\t\tthis.appendContext(`Assistant: ${textParts}`);\n\t\t\t\t\t}\n\t\t\t\t\tconst toolCalls = event.message.content?.filter((c: any) => c.type === \"toolCall\") ?? [];\n\t\t\t\t\tif (toolCalls.length > 0) {\n\t\t\t\t\t\tconst tools = toolCalls.map((c: any) => c.name).join(\", \");\n\t\t\t\t\t\tthis.appendContext(`Called tools: ${tools}`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"tool_execution_end\": {\n\t\t\t\t// Context capture (always)\n\t\t\t\tconst status = event.isError ? \"failed\" : \"completed\";\n\t\t\t\tconst output = event.result?.output || event.result?.content;\n\t\t\t\tconst outputText =\n\t\t\t\t\ttypeof output === \"string\"\n\t\t\t\t\t\t? output.slice(0, 100)\n\t\t\t\t\t\t: Array.isArray(output)\n\t\t\t\t\t\t\t? output\n\t\t\t\t\t\t\t\t\t.filter((c: any) => c.type === \"text\")\n\t\t\t\t\t\t\t\t\t.map((c: any) => c.text)\n\t\t\t\t\t\t\t\t\t.join(\"\")\n\t\t\t\t\t\t\t\t\t.slice(0, 100)\n\t\t\t\t\t\t\t: \"\";\n\t\t\t\tthis.appendContext(`Tool ${event.toolName} ${status}${outputText ? `: ${outputText}` : \"\"}`);\n\n\t\t\t\t// Reaction on error (gated by enabled)\n\t\t\t\tif (event.isError && this.enabled) {\n\t\t\t\t\tlet errorText = \"unknown error\";\n\t\t\t\t\tconst result = event.result;\n\t\t\t\t\tif (result?.content && Array.isArray(result.content)) {\n\t\t\t\t\t\terrorText = result.content\n\t\t\t\t\t\t\t.filter((c: any) => c.type === \"text\")\n\t\t\t\t\t\t\t.map((c: any) => c.text)\n\t\t\t\t\t\t\t.join(\"\")\n\t\t\t\t\t\t\t.slice(0, 200);\n\t\t\t\t\t} else if (typeof result?.error === \"string\") {\n\t\t\t\t\t\terrorText = result.error.slice(0, 200);\n\t\t\t\t\t}\n\t\t\t\t\tif (!errorText) errorText = \"unknown error\";\n\t\t\t\t\tthis.triggerReaction(`Tool \"${event.toolName}\" failed: ${errorText}`).catch(() => {\n\t\t\t\t\t\t/* triggerReaction() logs errors internally */\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"agent_end\": {\n\t\t\t\tif (this.enabled) {\n\t\t\t\t\tconst ctx = this.buildContext();\n\t\t\t\t\tthis.triggerReaction(`The agent finished responding. Recent activity:\\n${ctx}`).catch(() => {\n\t\t\t\t\t\t/* triggerReaction() logs errors internally */\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Process a user message — captures context, resets idle, checks name-call.\n\t * Context capture always happens. Active features gated by `enabled`.\n\t * Returns true if a name-call was detected and is being handled.\n\t */\n\tprocessUserMessage(text: string): boolean {\n\t\tthis.appendContext(`User: ${text}`);\n\t\tthis.markActivity();\n\t\tthis.resetIdleTimer();\n\n\t\t// Name-call detection (gated by enabled via detectNameCall)\n\t\tif (this.detectNameCall(text)) {\n\t\t\tthis.handleNameCall(text).catch(() => {\n\t\t\t\t/* handleNameCall() logs errors internally — prevents unhandled rejection */\n\t\t\t});\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\t// =========================================================================\n\t// Command handling\n\t// =========================================================================\n\n\t/**\n\t * Handle a /buddy command. Returns a result object for the frontend to render.\n\t * Hatch/reroll are delegated to the frontend via onHatch/onReroll callbacks.\n\t */\n\tasync handleCommand(subcommand: string): Promise<BuddyCommandResult> {\n\t\tswitch (subcommand) {\n\t\t\tcase \"pet\": {\n\t\t\t\tif (!this.manager.getState()) {\n\t\t\t\t\treturn { type: \"warning\", message: \"No buddy to pet! Use /buddy to hatch one first.\" };\n\t\t\t\t}\n\t\t\t\treturn { type: \"pet\" };\n\t\t\t}\n\t\t\tcase \"reroll\": {\n\t\t\t\tif (!this.manager.hasStoredBuddy()) {\n\t\t\t\t\treturn { type: \"warning\", message: \"No buddy to reroll! Use /buddy to hatch one first.\" };\n\t\t\t\t}\n\t\t\t\tthis.callbacks.onThinkingStart();\n\t\t\t\ttry {\n\t\t\t\t\tconst state = await this.callbacks.onReroll(this.manager);\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.manager.setHidden(false);\n\t\t\t\t\treturn { type: \"reroll\", state };\n\t\t\t\t} catch (err) {\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\treturn { type: \"error\", message: `Reroll failed: ${err instanceof Error ? err.message : String(err)}` };\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase \"stats\": {\n\t\t\t\tconst state = this.manager.getState();\n\t\t\t\tif (!state) {\n\t\t\t\t\treturn { type: \"warning\", message: \"No buddy to show stats for! Use /buddy to hatch one first.\" };\n\t\t\t\t}\n\t\t\t\treturn { type: \"stats\", state };\n\t\t\t}\n\t\t\tcase \"off\": {\n\t\t\t\tthis.enabled = false;\n\t\t\t\tthis.manager.setHidden(true);\n\t\t\t\tthis.stop();\n\t\t\t\treturn { type: \"off\" };\n\t\t\t}\n\t\t\tdefault: {\n\t\t\t\t// Handle \"/buddy model\" and \"/buddy model <name>\"\n\t\t\t\tif (subcommand === \"model\" || subcommand.startsWith(\"model \")) {\n\t\t\t\t\treturn this.handleModelCommand(subcommand);\n\t\t\t\t}\n\n\t\t\t\t// No subcommand: hatch or show\n\t\t\t\tconst current = this.manager.getState();\n\t\t\t\tif (current) {\n\t\t\t\t\t// Already showing — just enable and return\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.manager.setHidden(false);\n\t\t\t\t\treturn { type: \"show\", state: current };\n\t\t\t\t}\n\n\t\t\t\t// Try to load existing buddy\n\t\t\t\tconst existing = this.manager.load();\n\t\t\t\tif (existing) {\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.manager.setHidden(false);\n\t\t\t\t\treturn { type: \"show\", state: existing };\n\t\t\t\t}\n\n\t\t\t\t// Hatch new buddy\n\t\t\t\tthis.callbacks.onThinkingStart();\n\t\t\t\ttry {\n\t\t\t\t\tconst hatchState = await this.callbacks.onHatch(this.manager);\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\treturn { type: \"hatch\", state: hatchState };\n\t\t\t\t} catch (err) {\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\treturn { type: \"error\", message: `Hatch failed: ${err instanceof Error ? err.message : String(err)}` };\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Model selection\n\t// =========================================================================\n\n\t/** Handle /buddy model [name] — show current model or set a new one */\n\tprivate async handleModelCommand(subcommand: string): Promise<BuddyCommandResult> {\n\t\tconst modelArg = subcommand.slice(\"model\".length).trim();\n\n\t\tif (!modelArg) {\n\t\t\t// \"/buddy model\" with no argument — show current + available\n\t\t\tconst current = this.manager.getOllamaModel();\n\t\t\tconst status = await checkOllama();\n\t\t\tif (!status.available) {\n\t\t\t\treturn { type: \"model\", message: status.error ?? \"Ollama is not available.\" };\n\t\t\t}\n\t\t\tconst available = status.models.map((m) => ` • ${m}`).join(\"\\n\");\n\t\t\tif (current) {\n\t\t\t\treturn {\n\t\t\t\t\ttype: \"model\",\n\t\t\t\t\tmessage: `Current model: ${current}\\n\\nAvailable models:\\n${available}\\n\\nChange with: /buddy model <name>`,\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn {\n\t\t\t\ttype: \"model\",\n\t\t\t\tmessage: `No model set. Choose one with: /buddy model <name>\\n\\nAvailable models:\\n${available}`,\n\t\t\t};\n\t\t}\n\n\t\t// \"/buddy model <name>\" — set the model\n\t\tif (!this.manager.getState() && !this.manager.hasStoredBuddy()) {\n\t\t\treturn { type: \"warning\", message: \"No buddy yet — hatch one first with /buddy, then set a model.\" };\n\t\t}\n\n\t\tconst status = await checkOllama();\n\t\tif (!status.available) {\n\t\t\treturn { type: \"error\", message: status.error ?? \"Ollama is not available.\" };\n\t\t}\n\n\t\t// Check if the model is installed\n\t\tconst match = status.models.find((m) => m === modelArg || m.startsWith(`${modelArg}:`));\n\t\tif (!match) {\n\t\t\tconst available = status.models.map((m) => ` • ${m}`).join(\"\\n\");\n\t\t\treturn {\n\t\t\t\ttype: \"error\",\n\t\t\t\tmessage: `Model \"${modelArg}\" not found. Available models:\\n${available}\\n\\nPull it first with: ollama pull ${modelArg}`,\n\t\t\t};\n\t\t}\n\n\t\tthis.manager.setOllamaModel(match);\n\t\treturn { type: \"model\", message: `Buddy model set to: ${match}` };\n\t}\n\n\t/** Check if an Ollama model is configured, return a nudge message if not */\n\tgetModelNudge(): string | null {\n\t\tif (this.manager.getOllamaModel()) return null;\n\t\treturn \"No Ollama model set — reactions are disabled. Run /buddy model to choose one.\";\n\t}\n\n\t// =========================================================================\n\t// Lifecycle\n\t// =========================================================================\n\n\t/** Start the controller — auto-load buddy if one exists */\n\tstart(): BuddyState | null {\n\t\tconst existing = this.manager.load();\n\t\tif (existing) {\n\t\t\t// If buddy was hidden (via /buddy off), keep it loaded but disabled\n\t\t\tthis.enabled = !existing.hidden;\n\t\t\treturn existing;\n\t\t}\n\t\treturn null;\n\t}\n\n\t/** Stop the controller — clear timers */\n\tstop(): void {\n\t\tif (this.idleTimer) {\n\t\t\tclearTimeout(this.idleTimer);\n\t\t\tthis.idleTimer = null;\n\t\t}\n\t}\n\n\t/** Full reset — clear context buffer, idle timer, reaction budget.\n\t * Respects persisted hidden state so bridge reconnects don't undo /buddy off. */\n\treset(): void {\n\t\tthis.stop();\n\t\tthis.contextBuffer = [];\n\t\tthis.lastReactionTime = 0;\n\t\tthis.reactionTimestamps = [];\n\t\t// Re-enable unless buddy was explicitly hidden via /buddy off\n\t\tconst state = this.manager.getState() ?? this.manager.load();\n\t\tthis.enabled = state ? !state.hidden : true;\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"buddy-controller.js","sourceRoot":"","sources":["../../../src/core/buddy/buddy-controller.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * BuddyController — Frontend-agnostic controller for the buddy companion.\n *\n * Owns: context buffer, idle timer, reaction throttle, name-call detection.\n * Extracted from InteractiveMode so both TUI and Telegram can compose it\n * without duplicating ~150 lines of buddy wiring logic.\n *\n * The host (TUI or Telegram) provides callbacks for frontend-specific rendering:\n * - onSpeech(text) — display a speech bubble / message\n * - onThinkingStart() / onThinkingEnd() — show/hide thinking indicator\n *\n * Policies are configurable via BuddyControllerConfig so the TUI (no limits)\n * and Telegram (activity gating + reaction budget) can use different strategies.\n */\n\nimport { type BuddyManager, checkOllama } from \"./buddy-manager.js\";\nimport type { BuddyState } from \"./buddy-types.js\";\n\n/** Frontend-provided callbacks for buddy rendering */\nexport interface BuddyCallbacks {\n\t/** Display a speech/reaction message from the buddy */\n\tonSpeech: (text: string) => void;\n\t/** Show a thinking/loading indicator */\n\tonThinkingStart: () => void;\n\t/** Hide the thinking/loading indicator */\n\tonThinkingEnd: () => void;\n\t/** Hatch a new buddy — frontend resolves API key and calls manager.hatch() */\n\tonHatch: (manager: BuddyManager) => Promise<BuddyState>;\n\t/** Reroll the buddy — frontend resolves API key and calls manager.reroll() */\n\tonReroll: (manager: BuddyManager) => Promise<BuddyState>;\n}\n\n/** Configuration for buddy behavior — differs between TUI and Telegram */\nexport interface BuddyControllerConfig {\n\t/** Max entries in the context buffer (default: 20) */\n\tcontextMaxEntries?: number;\n\t/** Idle timeout in ms before buddy reacts to silence (default: 30000) */\n\tidleTimeoutMs?: number;\n\t/** Minimum ms between reactions (default: 60000) */\n\treactionCooldownMs?: number;\n\t/** If set, pause idle timer when user has been inactive this many ms */\n\tactivityGateMs?: number;\n\t/** If set, cap reactions to this many per hour */\n\treactionsPerHour?: number;\n}\n\n/** Subcommand result for frontend to render */\nexport type BuddyCommandResult =\n\t| { type: \"hatch\"; state: BuddyState }\n\t| { type: \"show\"; state: BuddyState }\n\t| { type: \"reroll\"; state: BuddyState }\n\t| { type: \"pet\" }\n\t| { type: \"stats\"; state: BuddyState }\n\t| { type: \"off\" }\n\t| { type: \"model\"; message: string }\n\t| { type: \"warning\"; message: string }\n\t| { type: \"error\"; message: string };\n\nexport class BuddyController {\n\tprivate contextBuffer: string[] = [];\n\tprivate lastReactionTime = 0;\n\tprivate idleTimer: ReturnType<typeof setTimeout> | null = null;\n\tprivate lastActivityTime = 0;\n\tprivate reactionTimestamps: number[] = []; // for budget tracking\n\tprivate pendingUtteranceId = 0;\n\t/** When false, all active functionality is disabled: no reactions, name-calls,\n\t * idle timer, or Ollama calls. Context capture (passive) still happens. */\n\tenabled = true;\n\n\treadonly manager: BuddyManager;\n\tprivate readonly callbacks: BuddyCallbacks;\n\tprivate readonly config: Required<BuddyControllerConfig>;\n\n\tconstructor(manager: BuddyManager, callbacks: BuddyCallbacks, config?: BuddyControllerConfig) {\n\t\tthis.manager = manager;\n\t\tthis.callbacks = callbacks;\n\t\tthis.config = {\n\t\t\tcontextMaxEntries: config?.contextMaxEntries ?? 20,\n\t\t\tidleTimeoutMs: config?.idleTimeoutMs ?? 30_000,\n\t\t\treactionCooldownMs: config?.reactionCooldownMs ?? 60_000,\n\t\t\tactivityGateMs: config?.activityGateMs ?? 0, // 0 = no gating (TUI default)\n\t\t\treactionsPerHour: config?.reactionsPerHour ?? 0, // 0 = unlimited (TUI default)\n\t\t};\n\t}\n\n\tprivate removeContextEntry(entry: string): void {\n\t\tconst idx = this.contextBuffer.indexOf(entry);\n\t\tif (idx !== -1) {\n\t\t\tthis.contextBuffer.splice(idx, 1);\n\t\t}\n\t}\n\n\tprivate replaceContextEntry(oldEntry: string, newEntry: string): void {\n\t\tconst idx = this.contextBuffer.indexOf(oldEntry);\n\t\tif (idx !== -1) {\n\t\t\tthis.contextBuffer[idx] = newEntry.slice(0, 2000);\n\t\t} else {\n\t\t\t// Evicted — append normally\n\t\t\tthis.appendContext(newEntry);\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Context buffer\n\t// =========================================================================\n\n\t/** Append an entry to the buddy context buffer (evicts oldest if at capacity) */\n\tappendContext(entry: string): void {\n\t\tthis.contextBuffer.push(entry.slice(0, 2000));\n\t\tif (this.contextBuffer.length > this.config.contextMaxEntries) {\n\t\t\tthis.contextBuffer.shift();\n\t\t}\n\t}\n\n\t/** Build the context buffer into a string for LLM prompts */\n\tbuildContext(): string {\n\t\tif (this.contextBuffer.length === 0) {\n\t\t\treturn \"No recent activity.\";\n\t\t}\n\t\treturn this.contextBuffer.join(\"\\n\").slice(0, 8000);\n\t}\n\n\t// =========================================================================\n\t// Activity & idle timer\n\t// =========================================================================\n\n\t/** Mark that user activity occurred (for activity gating) */\n\tmarkActivity(): void {\n\t\tthis.lastActivityTime = Date.now();\n\t}\n\n\t/** Reset the idle timer — called on every user message */\n\tresetIdleTimer(): void {\n\t\tif (this.idleTimer) {\n\t\t\tclearTimeout(this.idleTimer);\n\t\t}\n\n\t\tif (!this.enabled) return;\n\n\t\tif (!this.manager.getState()) return; // No buddy loaded, skip idle timer\n\n\t\t// Activity gating: skip idle timer if user has been inactive too long\n\t\tif (this.config.activityGateMs > 0 && this.lastActivityTime > 0) {\n\t\t\tconst elapsed = Date.now() - this.lastActivityTime;\n\t\t\tif (elapsed > this.config.activityGateMs) {\n\t\t\t\tthis.idleTimer = null;\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tthis.idleTimer = setTimeout(() => {\n\t\t\tconst ctx = this.buildContext();\n\t\t\tthis.triggerReaction(`It's been quiet for a moment. Recent activity:\\n${ctx}`).catch(() => {\n\t\t\t\t/* triggerReaction() logs errors internally — prevents unhandled rejection */\n\t\t\t});\n\t\t}, this.config.idleTimeoutMs);\n\t}\n\n\t// =========================================================================\n\t// Reactions\n\t// =========================================================================\n\n\t/** Check if a reaction is allowed under current throttle and budget */\n\tprivate canReact(): boolean {\n\t\tif (!this.enabled) return false;\n\n\t\tconst now = Date.now();\n\n\t\t// Cooldown throttle\n\t\tif (now - this.lastReactionTime < this.config.reactionCooldownMs) {\n\t\t\treturn false;\n\t\t}\n\n\t\t// Reaction budget (per hour)\n\t\tif (this.config.reactionsPerHour > 0) {\n\t\t\tconst oneHourAgo = now - 3_600_000;\n\t\t\tthis.reactionTimestamps = this.reactionTimestamps.filter((t) => t > oneHourAgo);\n\t\t\tif (this.reactionTimestamps.length >= this.config.reactionsPerHour) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\treturn true;\n\t}\n\n\t/**\n\t * Trigger a buddy reaction. Throttled by cooldown and budget.\n\t * Calls onThinkingStart/End and onSpeech callbacks.\n\t * No-op if disabled.\n\t */\n\tasync triggerReaction(event: string): Promise<void> {\n\t\tif (!this.canReact()) return;\n\t\tthis.lastReactionTime = Date.now();\n\n\t\tconst id = ++this.pendingUtteranceId;\n\t\tconst marker = `__BUDDY_PENDING_${id}__`;\n\t\tthis.appendContext(marker);\n\n\t\tlet thinkingEnded = false;\n\t\ttry {\n\t\t\tthis.callbacks.onThinkingStart();\n\t\t\tconst quip = await this.manager.react(event);\n\t\t\tthinkingEnded = true;\n\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\tif (quip) {\n\t\t\t\tthis.reactionTimestamps.push(Date.now());\n\t\t\t\tthis.replaceContextEntry(marker, `Buddy: ${quip}`);\n\t\t\t\tthis.callbacks.onSpeech(quip);\n\t\t\t} else {\n\t\t\t\tthis.removeContextEntry(marker);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tif (!thinkingEnded) this.callbacks.onThinkingEnd();\n\t\t\tthis.removeContextEntry(marker);\n\t\t\tconsole.error(\"[buddy] triggerReaction failed:\", err instanceof Error ? err.message : err);\n\t\t}\n\t}\n\n\t/**\n\t * Handle a name-call from the user.\n\t * No-op if disabled — returns immediately without calling Ollama.\n\t */\n\tasync handleNameCall(userMessage: string): Promise<void> {\n\t\tif (!this.enabled) return;\n\t\tlet state = this.manager.getState();\n\t\tif (!state) {\n\t\t\tstate = this.manager.load();\n\t\t}\n\t\tif (!state) return;\n\n\t\tconst id = ++this.pendingUtteranceId;\n\t\tconst marker = `__BUDDY_PENDING_${id}__`;\n\t\tthis.appendContext(marker);\n\n\t\tlet thinkingEnded = false;\n\t\ttry {\n\t\t\tthis.callbacks.onThinkingStart();\n\t\t\tconst response = await this.manager.respondToNameCall(userMessage, this.buildContext());\n\t\t\tthinkingEnded = true;\n\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\tif (response) {\n\t\t\t\tthis.replaceContextEntry(marker, `Buddy: ${response}`);\n\t\t\t\tthis.callbacks.onSpeech(response);\n\t\t\t} else {\n\t\t\t\tthis.removeContextEntry(marker);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tif (!thinkingEnded) this.callbacks.onThinkingEnd();\n\t\t\tthis.removeContextEntry(marker);\n\t\t\tconsole.error(\"[buddy] handleNameCall failed:\", err instanceof Error ? err.message : err);\n\t\t}\n\t}\n\n\t/** Check if a message contains the buddy's name (word-boundary matching).\n\t * Returns false if disabled. */\n\tdetectNameCall(text: string): boolean {\n\t\tif (!this.enabled) return false;\n\t\tconst name = this.manager.getName();\n\t\tif (!name) return false;\n\t\ttry {\n\t\t\tconst escaped = name.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\t\t\tconst regex = new RegExp(`\\\\b${escaped}\\\\b`, \"i\");\n\t\t\treturn regex.test(text);\n\t\t} catch {\n\t\t\t/* Invalid regex from buddy name — safe to return false */\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Event handling\n\t// =========================================================================\n\n\t/**\n\t * Process an agent event for buddy context capture and reaction triggers.\n\t * The host calls this from its event handler.\n\t *\n\t * Context capture always happens. Reactions are gated by `enabled`.\n\t */\n\thandleEvent(event: { type: string; [key: string]: any }): void {\n\t\tconst state = this.manager.getState();\n\t\tif (!state) return; // No buddy loaded\n\n\t\tswitch (event.type) {\n\t\t\tcase \"message_end\": {\n\t\t\t\tif (event.message?.role === \"assistant\") {\n\t\t\t\t\tconst textParts = event.message.content\n\t\t\t\t\t\t?.filter((c: any) => c.type === \"text\")\n\t\t\t\t\t\t?.map((c: any) => c.text)\n\t\t\t\t\t\t?.join(\"\");\n\t\t\t\t\tif (textParts) {\n\t\t\t\t\t\tthis.appendContext(`Assistant: ${textParts}`);\n\t\t\t\t\t}\n\t\t\t\t\tconst toolCalls = event.message.content?.filter((c: any) => c.type === \"toolCall\") ?? [];\n\t\t\t\t\tif (toolCalls.length > 0) {\n\t\t\t\t\t\tconst tools = toolCalls.map((c: any) => c.name).join(\", \");\n\t\t\t\t\t\tthis.appendContext(`Called tools: ${tools}`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"tool_execution_end\": {\n\t\t\t\t// Context capture (always)\n\t\t\t\tconst status = event.isError ? \"failed\" : \"completed\";\n\t\t\t\tconst output = event.result?.output || event.result?.content;\n\t\t\t\tconst outputText =\n\t\t\t\t\ttypeof output === \"string\"\n\t\t\t\t\t\t? output\n\t\t\t\t\t\t: Array.isArray(output)\n\t\t\t\t\t\t\t? output\n\t\t\t\t\t\t\t\t\t.filter((c: any) => c.type === \"text\")\n\t\t\t\t\t\t\t\t\t.map((c: any) => c.text)\n\t\t\t\t\t\t\t\t\t.join(\"\")\n\t\t\t\t\t\t\t: \"\";\n\t\t\t\tthis.appendContext(`Tool ${event.toolName} ${status}${outputText ? `: ${outputText}` : \"\"}`);\n\n\t\t\t\t// Reaction on error (gated by enabled)\n\t\t\t\tif (event.isError && this.enabled) {\n\t\t\t\t\tlet errorText = \"unknown error\";\n\t\t\t\t\tconst result = event.result;\n\t\t\t\t\tif (result?.content && Array.isArray(result.content)) {\n\t\t\t\t\t\terrorText = result.content\n\t\t\t\t\t\t\t.filter((c: any) => c.type === \"text\")\n\t\t\t\t\t\t\t.map((c: any) => c.text)\n\t\t\t\t\t\t\t.join(\"\");\n\t\t\t\t\t} else if (typeof result?.error === \"string\") {\n\t\t\t\t\t\terrorText = result.error;\n\t\t\t\t\t}\n\t\t\t\t\tif (!errorText) errorText = \"unknown error\";\n\t\t\t\t\tthis.triggerReaction(`Tool \"${event.toolName}\" failed: ${errorText.slice(0, 2000)}`).catch(() => {\n\t\t\t\t\t\t/* triggerReaction() logs errors internally */\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"agent_end\": {\n\t\t\t\tif (this.enabled) {\n\t\t\t\t\tconst ctx = this.buildContext();\n\t\t\t\t\tthis.triggerReaction(`The agent finished responding. Recent activity:\\n${ctx}`).catch(() => {\n\t\t\t\t\t\t/* triggerReaction() logs errors internally */\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Process a user message — captures context, resets idle, checks name-call.\n\t * Context capture always happens. Active features gated by `enabled`.\n\t * Returns true if a name-call was detected and is being handled.\n\t */\n\tprocessUserMessage(text: string): boolean {\n\t\tthis.appendContext(`User: ${text}`);\n\t\tthis.markActivity();\n\t\tthis.resetIdleTimer();\n\n\t\t// Name-call detection (gated by enabled via detectNameCall)\n\t\tif (this.detectNameCall(text)) {\n\t\t\tthis.handleNameCall(text).catch(() => {\n\t\t\t\t/* handleNameCall() logs errors internally — prevents unhandled rejection */\n\t\t\t});\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\t// =========================================================================\n\t// Command handling\n\t// =========================================================================\n\n\t/**\n\t * Handle a /buddy command. Returns a result object for the frontend to render.\n\t * Hatch/reroll are delegated to the frontend via onHatch/onReroll callbacks.\n\t */\n\tasync handleCommand(subcommand: string): Promise<BuddyCommandResult> {\n\t\tswitch (subcommand) {\n\t\t\tcase \"pet\": {\n\t\t\t\tif (!this.manager.getState()) {\n\t\t\t\t\treturn { type: \"warning\", message: \"No buddy to pet! Use /buddy to hatch one first.\" };\n\t\t\t\t}\n\t\t\t\treturn { type: \"pet\" };\n\t\t\t}\n\t\t\tcase \"reroll\": {\n\t\t\t\tif (!this.manager.hasStoredBuddy()) {\n\t\t\t\t\treturn { type: \"warning\", message: \"No buddy to reroll! Use /buddy to hatch one first.\" };\n\t\t\t\t}\n\t\t\t\tthis.callbacks.onThinkingStart();\n\t\t\t\ttry {\n\t\t\t\t\tconst state = await this.callbacks.onReroll(this.manager);\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.manager.setHidden(false);\n\t\t\t\t\treturn { type: \"reroll\", state };\n\t\t\t\t} catch (err) {\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\treturn { type: \"error\", message: `Reroll failed: ${err instanceof Error ? err.message : String(err)}` };\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase \"stats\": {\n\t\t\t\tconst state = this.manager.getState();\n\t\t\t\tif (!state) {\n\t\t\t\t\treturn { type: \"warning\", message: \"No buddy to show stats for! Use /buddy to hatch one first.\" };\n\t\t\t\t}\n\t\t\t\treturn { type: \"stats\", state };\n\t\t\t}\n\t\t\tcase \"off\": {\n\t\t\t\tthis.enabled = false;\n\t\t\t\tthis.manager.setHidden(true);\n\t\t\t\tthis.stop();\n\t\t\t\treturn { type: \"off\" };\n\t\t\t}\n\t\t\tdefault: {\n\t\t\t\t// Handle \"/buddy model\" and \"/buddy model <name>\"\n\t\t\t\tif (subcommand === \"model\" || subcommand.startsWith(\"model \")) {\n\t\t\t\t\treturn this.handleModelCommand(subcommand);\n\t\t\t\t}\n\n\t\t\t\t// No subcommand: hatch or show\n\t\t\t\tconst current = this.manager.getState();\n\t\t\t\tif (current) {\n\t\t\t\t\t// Already showing — just enable and return\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.manager.setHidden(false);\n\t\t\t\t\treturn { type: \"show\", state: current };\n\t\t\t\t}\n\n\t\t\t\t// Try to load existing buddy\n\t\t\t\tconst existing = this.manager.load();\n\t\t\t\tif (existing) {\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.manager.setHidden(false);\n\t\t\t\t\treturn { type: \"show\", state: existing };\n\t\t\t\t}\n\n\t\t\t\t// Hatch new buddy\n\t\t\t\tthis.callbacks.onThinkingStart();\n\t\t\t\ttry {\n\t\t\t\t\tconst hatchState = await this.callbacks.onHatch(this.manager);\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\treturn { type: \"hatch\", state: hatchState };\n\t\t\t\t} catch (err) {\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\treturn { type: \"error\", message: `Hatch failed: ${err instanceof Error ? err.message : String(err)}` };\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Model selection\n\t// =========================================================================\n\n\t/** Handle /buddy model [name] — show current model or set a new one */\n\tprivate async handleModelCommand(subcommand: string): Promise<BuddyCommandResult> {\n\t\tconst modelArg = subcommand.slice(\"model\".length).trim();\n\n\t\tif (!modelArg) {\n\t\t\t// \"/buddy model\" with no argument — show current + available\n\t\t\tconst current = this.manager.getOllamaModel();\n\t\t\tconst status = await checkOllama();\n\t\t\tif (!status.available) {\n\t\t\t\treturn { type: \"model\", message: status.error ?? \"Ollama is not available.\" };\n\t\t\t}\n\t\t\tconst available = status.models.map((m) => ` • ${m}`).join(\"\\n\");\n\t\t\tif (current) {\n\t\t\t\treturn {\n\t\t\t\t\ttype: \"model\",\n\t\t\t\t\tmessage: `Current model: ${current}\\n\\nAvailable models:\\n${available}\\n\\nChange with: /buddy model <name>`,\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn {\n\t\t\t\ttype: \"model\",\n\t\t\t\tmessage: `No model set. Choose one with: /buddy model <name>\\n\\nAvailable models:\\n${available}`,\n\t\t\t};\n\t\t}\n\n\t\t// \"/buddy model <name>\" — set the model\n\t\tif (!this.manager.getState() && !this.manager.hasStoredBuddy()) {\n\t\t\treturn { type: \"warning\", message: \"No buddy yet — hatch one first with /buddy, then set a model.\" };\n\t\t}\n\n\t\tconst status = await checkOllama();\n\t\tif (!status.available) {\n\t\t\treturn { type: \"error\", message: status.error ?? \"Ollama is not available.\" };\n\t\t}\n\n\t\t// Check if the model is installed\n\t\tconst match = status.models.find((m) => m === modelArg || m.startsWith(`${modelArg}:`));\n\t\tif (!match) {\n\t\t\tconst available = status.models.map((m) => ` • ${m}`).join(\"\\n\");\n\t\t\treturn {\n\t\t\t\ttype: \"error\",\n\t\t\t\tmessage: `Model \"${modelArg}\" not found. Available models:\\n${available}\\n\\nPull it first with: ollama pull ${modelArg}`,\n\t\t\t};\n\t\t}\n\n\t\tthis.manager.setOllamaModel(match);\n\t\treturn { type: \"model\", message: `Buddy model set to: ${match}` };\n\t}\n\n\t/** Check if an Ollama model is configured, return a nudge message if not */\n\tgetModelNudge(): string | null {\n\t\tif (this.manager.getOllamaModel()) return null;\n\t\treturn \"No Ollama model set — reactions are disabled. Run /buddy model to choose one.\";\n\t}\n\n\t// =========================================================================\n\t// Lifecycle\n\t// =========================================================================\n\n\t/** Start the controller — auto-load buddy if one exists */\n\tstart(): BuddyState | null {\n\t\tconst existing = this.manager.load();\n\t\tif (existing) {\n\t\t\t// If buddy was hidden (via /buddy off), keep it loaded but disabled\n\t\t\tthis.enabled = !existing.hidden;\n\t\t\treturn existing;\n\t\t}\n\t\treturn null;\n\t}\n\n\t/** Stop the controller — clear timers */\n\tstop(): void {\n\t\tif (this.idleTimer) {\n\t\t\tclearTimeout(this.idleTimer);\n\t\t\tthis.idleTimer = null;\n\t\t}\n\t}\n\n\t/** Full reset — clear context buffer, idle timer, reaction budget.\n\t * Respects persisted hidden state so bridge reconnects don't undo /buddy off. */\n\treset(): void {\n\t\tthis.stop();\n\t\tthis.contextBuffer = [];\n\t\tthis.lastReactionTime = 0;\n\t\tthis.reactionTimestamps = [];\n\t\t// Re-enable unless buddy was explicitly hidden via /buddy off\n\t\tconst state = this.manager.getState() ?? this.manager.load();\n\t\tthis.enabled = state ? !state.hidden : true;\n\t}\n}\n"]}
|
|
@@ -9,6 +9,8 @@ export interface SessionHeader {
|
|
|
9
9
|
timestamp: string;
|
|
10
10
|
cwd: string;
|
|
11
11
|
parentSession?: string;
|
|
12
|
+
/** Agent type that executed this session (set for subagent child processes via --agent-type) */
|
|
13
|
+
agentType?: string;
|
|
12
14
|
}
|
|
13
15
|
export interface NewSessionOptions {
|
|
14
16
|
id?: string;
|
|
@@ -179,6 +181,12 @@ export declare class SessionManager {
|
|
|
179
181
|
/** Switch to a different session file (used for resume and branching) */
|
|
180
182
|
setSessionFile(sessionFile: string): void;
|
|
181
183
|
newSession(options?: NewSessionOptions): string | undefined;
|
|
184
|
+
/**
|
|
185
|
+
* Set agent type metadata on the current session header.
|
|
186
|
+
* Must be called before the first flush (i.e., before the first assistant message).
|
|
187
|
+
* Used by subagent child processes to record which agent definition executed the session.
|
|
188
|
+
*/
|
|
189
|
+
setAgentType(agentType: string): void;
|
|
182
190
|
private _buildIndex;
|
|
183
191
|
private _rewriteFile;
|
|
184
192
|
isPersisted(): boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../../src/core/session-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAiBnE,OAAO,EACN,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAIlB,MAAM,eAAe,CAAC;AAEvB,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAEzC,MAAM,WAAW,aAAa;IAC7B,IAAI,EAAE,SAAS,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,iBAAiB;IACjC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,gBAAgB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,mBAAoB,SAAQ,gBAAgB;IAC5D,IAAI,EAAE,SAAS,CAAC;IAChB,OAAO,EAAE,YAAY,CAAC;CACtB;AAED,MAAM,WAAW,wBAAyB,SAAQ,gBAAgB;IACjE,IAAI,EAAE,uBAAuB,CAAC;IAC9B,aAAa,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,gBAAiB,SAAQ,gBAAgB;IACzD,IAAI,EAAE,cAAc,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,eAAe,CAAC,CAAC,GAAG,OAAO,CAAE,SAAQ,gBAAgB;IACrE,IAAI,EAAE,YAAY,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,+FAA+F;IAC/F,OAAO,CAAC,EAAE,CAAC,CAAC;IACZ,iGAAiG;IACjG,QAAQ,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB,CAAC,CAAC,GAAG,OAAO,CAAE,SAAQ,gBAAgB;IACxE,IAAI,EAAE,gBAAgB,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,gDAAgD;IAChD,OAAO,CAAC,EAAE,CAAC,CAAC;IACZ,iEAAiE;IACjE,QAAQ,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,WAAW,CAAC,CAAC,GAAG,OAAO,CAAE,SAAQ,gBAAgB;IACjE,IAAI,EAAE,QAAQ,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,CAAC,CAAC;CACT;AAED,iEAAiE;AACjE,MAAM,WAAW,UAAW,SAAQ,gBAAgB;IACnD,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;CAC1B;AAED,gEAAgE;AAChE,MAAM,WAAW,gBAAiB,SAAQ,gBAAgB;IACzD,IAAI,EAAE,cAAc,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,kBAAkB,CAAC,CAAC,GAAG,OAAO,CAAE,SAAQ,gBAAgB;IACxE,IAAI,EAAE,gBAAgB,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,GAAG,CAAC,WAAW,GAAG,YAAY,CAAC,EAAE,CAAC;IACjD,OAAO,CAAC,EAAE,CAAC,CAAC;IACZ,OAAO,EAAE,OAAO,CAAC;CACjB;AAED,wGAAwG;AACxG,MAAM,MAAM,YAAY,GACrB,mBAAmB,GACnB,wBAAwB,GACxB,gBAAgB,GAChB,eAAe,GACf,kBAAkB,GAClB,WAAW,GACX,kBAAkB,GAClB,UAAU,GACV,gBAAgB,CAAC;AAEpB,uCAAuC;AACvC,MAAM,MAAM,SAAS,GAAG,aAAa,GAAG,YAAY,CAAC;AAErD,oEAAoE;AACpE,MAAM,WAAW,eAAe;IAC/B,KAAK,EAAE,YAAY,CAAC;IACpB,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,4CAA4C;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,cAAc;IAC9B,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;CACpD;AAED,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,sFAAsF;IACtF,GAAG,EAAE,MAAM,CAAC;IACZ,2DAA2D;IAC3D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,OAAO,EAAE,IAAI,CAAC;IACd,QAAQ,EAAE,IAAI,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,sBAAsB,GAAG,IAAI,CACxC,cAAc,EACZ,QAAQ,GACR,eAAe,GACf,cAAc,GACd,gBAAgB,GAChB,WAAW,GACX,cAAc,GACd,UAAU,GACV,UAAU,GACV,WAAW,GACX,WAAW,GACX,YAAY,GACZ,SAAS,GACT,gBAAgB,CAClB,CAAC;AA2EF,2BAA2B;AAC3B,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,SAAS,EAAE,GAAG,IAAI,CAEhE;AAED,sCAAsC;AACtC,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,CAehE;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,eAAe,GAAG,IAAI,CAOxF;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAClC,OAAO,EAAE,YAAY,EAAE,EACvB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,EACtB,IAAI,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,GAC9B,cAAc,CAuGhB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,GAAE,MAA6B,GAAG,MAAM,CAOjG;AAED,2BAA2B;AAC3B,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAAE,CAyBjE;AAkBD,2BAA2B;AAC3B,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAcvE;AA8HD,MAAM,MAAM,mBAAmB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;AAuC1E;;;;;;;;;;GAUG;AACH,qBAAa,cAAc;IAC1B,OAAO,CAAC,SAAS,CAAc;IAC/B,OAAO,CAAC,WAAW,CAAqB;IACxC,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,GAAG,CAAS;IACpB,OAAO,CAAC,OAAO,CAAU;IACzB,OAAO,CAAC,OAAO,CAAkB;IACjC,OAAO,CAAC,WAAW,CAAmB;IACtC,OAAO,CAAC,IAAI,CAAwC;IACpD,OAAO,CAAC,UAAU,CAAkC;IACpD,OAAO,CAAC,MAAM,CAAuB;IAErC,OAAO,eAaN;IAED,yEAAyE;IACzE,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CA8BxC;IAED,UAAU,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,MAAM,GAAG,SAAS,CAsB1D;IAED,OAAO,CAAC,WAAW;IAkBnB,OAAO,CAAC,YAAY;IAMpB,WAAW,IAAI,OAAO,CAErB;IAED,MAAM,IAAI,MAAM,CAEf;IAED,aAAa,IAAI,MAAM,CAEtB;IAED,YAAY,IAAI,MAAM,CAErB;IAED,cAAc,IAAI,MAAM,GAAG,SAAS,CAEnC;IAED,QAAQ,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,CAkBlC;IAED,OAAO,CAAC,YAAY;IAOpB;;;;;OAKG;IACH,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,aAAa,GAAG,oBAAoB,GAAG,MAAM,CAU7E;IAED,oGAAoG;IACpG,yBAAyB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAUvD;IAED,2FAA2F;IAC3F,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAW3D;IAED,iGAAiG;IACjG,gBAAgB,CAAC,CAAC,GAAG,OAAO,EAC3B,OAAO,EAAE,MAAM,EACf,gBAAgB,EAAE,MAAM,EACxB,YAAY,EAAE,MAAM,EACpB,OAAO,CAAC,EAAE,CAAC,EACX,QAAQ,CAAC,EAAE,OAAO,GAChB,MAAM,CAcR;IAED,4GAA4G;IAC5G,iBAAiB,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,MAAM,CAW5D;IAED,0EAA0E;IAC1E,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAUtC;IAED,+EAA+E;IAC/E,cAAc,IAAI,MAAM,GAAG,SAAS,CAWnC;IAED;;;;;;;OAOG;IACH,wBAAwB,CAAC,CAAC,GAAG,OAAO,EACnC,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GAAG,CAAC,WAAW,GAAG,YAAY,CAAC,EAAE,EAChD,OAAO,EAAE,OAAO,EAChB,OAAO,CAAC,EAAE,CAAC,GACT,MAAM,CAaR;IAMD,SAAS,IAAI,MAAM,GAAG,IAAI,CAEzB;IAED,YAAY,IAAI,YAAY,GAAG,SAAS,CAEvC;IAED,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAE7C;IAED;;OAEG;IACH,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,YAAY,EAAE,CAQ5C;IAED;;OAEG;IACH,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEvC;IAED;;;;OAIG;IACH,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAmBrE;IAED;;;;OAIG;IACH,SAAS,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,YAAY,EAAE,CASzC;IAED;;;OAGG;IACH,mBAAmB,IAAI,cAAc,CAEpC;IAED;;OAEG;IACH,SAAS,IAAI,aAAa,GAAG,IAAI,CAGhC;IAED;;;;OAIG;IACH,UAAU,IAAI,YAAY,EAAE,CAE3B;IAED;;;;OAIG;IACH,OAAO,IAAI,eAAe,EAAE,CAqC3B;IAMD;;;;;OAKG;IACH,MAAM,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAKjC;IAED;;;;OAIG;IACH,SAAS,IAAI,IAAI,CAEhB;IAED;;;;OAIG;IACH,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,OAAO,GAAG,MAAM,CAiB7G;IAED;;;;OAIG;IACH,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CA4FxD;IAED;;;;OAIG;IACH,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,cAAc,CAG9D;IAED;;;;OAIG;IACH,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,cAAc,CAQ7D;IAED;;;;OAIG;IACH,MAAM,CAAC,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,cAAc,CAOtE;IAED,wDAAwD;IACxD,MAAM,CAAC,QAAQ,CAAC,GAAG,GAAE,MAAsB,GAAG,cAAc,CAE3D;IAED;;;;;;OAMG;IACH,MAAM,CAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,cAAc,CAyC1F;IAED;;;;;OAKG;IACH,OAAa,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAK5G;IAED;;;OAGG;IACH,OAAa,OAAO,CAAC,UAAU,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAkD7E;CACD","sourcesContent":["import type { AgentMessage } from \"@dreb/agent-core\";\nimport type { ImageContent, Message, TextContent } from \"@dreb/ai\";\nimport { randomUUID } from \"crypto\";\nimport {\n\tappendFileSync,\n\tcloseSync,\n\texistsSync,\n\tmkdirSync,\n\topenSync,\n\treaddirSync,\n\treadFileSync,\n\treadSync,\n\tstatSync,\n\twriteFileSync,\n} from \"fs\";\nimport { readdir, readFile, stat } from \"fs/promises\";\nimport { join, resolve } from \"path\";\nimport { getAgentDir as getDefaultAgentDir, getSessionsDir } from \"../config.js\";\nimport {\n\ttype BashExecutionMessage,\n\ttype CustomMessage,\n\tcreateBranchSummaryMessage,\n\tcreateCompactionSummaryMessage,\n\tcreateCustomMessage,\n} from \"./messages.js\";\n\nexport const CURRENT_SESSION_VERSION = 3;\n\nexport interface SessionHeader {\n\ttype: \"session\";\n\tversion?: number; // v1 sessions don't have this\n\tid: string;\n\ttimestamp: string;\n\tcwd: string;\n\tparentSession?: string;\n}\n\nexport interface NewSessionOptions {\n\tid?: string;\n\tparentSession?: string;\n}\n\nexport interface SessionEntryBase {\n\ttype: string;\n\tid: string;\n\tparentId: string | null;\n\ttimestamp: string;\n}\n\nexport interface SessionMessageEntry extends SessionEntryBase {\n\ttype: \"message\";\n\tmessage: AgentMessage;\n}\n\nexport interface ThinkingLevelChangeEntry extends SessionEntryBase {\n\ttype: \"thinking_level_change\";\n\tthinkingLevel: string;\n}\n\nexport interface ModelChangeEntry extends SessionEntryBase {\n\ttype: \"model_change\";\n\tprovider: string;\n\tmodelId: string;\n}\n\nexport interface CompactionEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"compaction\";\n\tsummary: string;\n\tfirstKeptEntryId: string;\n\ttokensBefore: number;\n\t/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */\n\tdetails?: T;\n\t/** True if generated by an extension, undefined/false if dreb-generated (backward compatible) */\n\tfromHook?: boolean;\n}\n\nexport interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"branch_summary\";\n\tfromId: string;\n\tsummary: string;\n\t/** Extension-specific data (not sent to LLM) */\n\tdetails?: T;\n\t/** True if generated by an extension, false if dreb-generated */\n\tfromHook?: boolean;\n}\n\n/**\n * Custom entry for extensions to store extension-specific data in the session.\n * Use customType to identify your extension's entries.\n *\n * Purpose: Persist extension state across session reloads. On reload, extensions can\n * scan entries for their customType and reconstruct internal state.\n *\n * Does NOT participate in LLM context (ignored by buildSessionContext).\n * For injecting content into context, see CustomMessageEntry.\n */\nexport interface CustomEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"custom\";\n\tcustomType: string;\n\tdata?: T;\n}\n\n/** Label entry for user-defined bookmarks/markers on entries. */\nexport interface LabelEntry extends SessionEntryBase {\n\ttype: \"label\";\n\ttargetId: string;\n\tlabel: string | undefined;\n}\n\n/** Session metadata entry (e.g., user-defined display name). */\nexport interface SessionInfoEntry extends SessionEntryBase {\n\ttype: \"session_info\";\n\tname?: string;\n}\n\n/**\n * Custom message entry for extensions to inject messages into LLM context.\n * Use customType to identify your extension's entries.\n *\n * Unlike CustomEntry, this DOES participate in LLM context.\n * The content is converted to a user message in buildSessionContext().\n * Use details for extension-specific metadata (not sent to LLM).\n *\n * display controls TUI rendering:\n * - false: hidden entirely\n * - true: rendered with distinct styling (different from user messages)\n */\nexport interface CustomMessageEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"custom_message\";\n\tcustomType: string;\n\tcontent: string | (TextContent | ImageContent)[];\n\tdetails?: T;\n\tdisplay: boolean;\n}\n\n/** Session entry - has id/parentId for tree structure (returned by \"read\" methods in SessionManager) */\nexport type SessionEntry =\n\t| SessionMessageEntry\n\t| ThinkingLevelChangeEntry\n\t| ModelChangeEntry\n\t| CompactionEntry\n\t| BranchSummaryEntry\n\t| CustomEntry\n\t| CustomMessageEntry\n\t| LabelEntry\n\t| SessionInfoEntry;\n\n/** Raw file entry (includes header) */\nexport type FileEntry = SessionHeader | SessionEntry;\n\n/** Tree node for getTree() - defensive copy of session structure */\nexport interface SessionTreeNode {\n\tentry: SessionEntry;\n\tchildren: SessionTreeNode[];\n\t/** Resolved label for this entry, if any */\n\tlabel?: string;\n}\n\nexport interface SessionContext {\n\tmessages: AgentMessage[];\n\tthinkingLevel: string;\n\tmodel: { provider: string; modelId: string } | null;\n}\n\nexport interface SessionInfo {\n\tpath: string;\n\tid: string;\n\t/** Working directory where the session was started. Empty string for old sessions. */\n\tcwd: string;\n\t/** User-defined display name from session_info entries. */\n\tname?: string;\n\t/** Path to the parent session (if this session was forked). */\n\tparentSessionPath?: string;\n\tcreated: Date;\n\tmodified: Date;\n\tmessageCount: number;\n\tfirstMessage: string;\n\tallMessagesText: string;\n}\n\nexport type ReadonlySessionManager = Pick<\n\tSessionManager,\n\t| \"getCwd\"\n\t| \"getSessionDir\"\n\t| \"getSessionId\"\n\t| \"getSessionFile\"\n\t| \"getLeafId\"\n\t| \"getLeafEntry\"\n\t| \"getEntry\"\n\t| \"getLabel\"\n\t| \"getBranch\"\n\t| \"getHeader\"\n\t| \"getEntries\"\n\t| \"getTree\"\n\t| \"getSessionName\"\n>;\n\n/** Generate a unique short ID (8 hex chars, collision-checked) */\nfunction generateId(byId: { has(id: string): boolean }): string {\n\tfor (let i = 0; i < 100; i++) {\n\t\tconst id = randomUUID().slice(0, 8);\n\t\tif (!byId.has(id)) return id;\n\t}\n\t// Fallback to full UUID if somehow we have collisions\n\treturn randomUUID();\n}\n\n/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */\nfunction migrateV1ToV2(entries: FileEntry[]): void {\n\tconst ids = new Set<string>();\n\tlet prevId: string | null = null;\n\n\tfor (const entry of entries) {\n\t\tif (entry.type === \"session\") {\n\t\t\tentry.version = 2;\n\t\t\tcontinue;\n\t\t}\n\n\t\tentry.id = generateId(ids);\n\t\tentry.parentId = prevId;\n\t\tprevId = entry.id;\n\n\t\t// Convert firstKeptEntryIndex to firstKeptEntryId for compaction\n\t\tif (entry.type === \"compaction\") {\n\t\t\tconst comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };\n\t\t\tif (typeof comp.firstKeptEntryIndex === \"number\") {\n\t\t\t\tconst targetEntry = entries[comp.firstKeptEntryIndex];\n\t\t\t\tif (targetEntry && targetEntry.type !== \"session\") {\n\t\t\t\t\tcomp.firstKeptEntryId = targetEntry.id;\n\t\t\t\t}\n\t\t\t\tdelete comp.firstKeptEntryIndex;\n\t\t\t}\n\t\t}\n\t}\n}\n\n/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */\nfunction migrateV2ToV3(entries: FileEntry[]): void {\n\tfor (const entry of entries) {\n\t\tif (entry.type === \"session\") {\n\t\t\tentry.version = 3;\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Update message entries with hookMessage role\n\t\tif (entry.type === \"message\") {\n\t\t\tconst msgEntry = entry as SessionMessageEntry;\n\t\t\tif (msgEntry.message && (msgEntry.message as { role: string }).role === \"hookMessage\") {\n\t\t\t\t(msgEntry.message as { role: string }).role = \"custom\";\n\t\t\t}\n\t\t}\n\t}\n}\n\n/**\n * Run all necessary migrations to bring entries to current version.\n * Mutates entries in place. Returns true if any migration was applied.\n */\nfunction migrateToCurrentVersion(entries: FileEntry[]): boolean {\n\tconst header = entries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\tconst version = header?.version ?? 1;\n\n\tif (version >= CURRENT_SESSION_VERSION) return false;\n\n\tif (version < 2) migrateV1ToV2(entries);\n\tif (version < 3) migrateV2ToV3(entries);\n\n\treturn true;\n}\n\n/** Exported for testing */\nexport function migrateSessionEntries(entries: FileEntry[]): void {\n\tmigrateToCurrentVersion(entries);\n}\n\n/** Exported for compaction.test.ts */\nexport function parseSessionEntries(content: string): FileEntry[] {\n\tconst entries: FileEntry[] = [];\n\tconst lines = content.trim().split(\"\\n\");\n\n\tfor (const line of lines) {\n\t\tif (!line.trim()) continue;\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line) as FileEntry;\n\t\t\tentries.push(entry);\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\n\treturn entries;\n}\n\nexport function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tif (entries[i].type === \"compaction\") {\n\t\t\treturn entries[i] as CompactionEntry;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Build the session context from entries using tree traversal.\n * If leafId is provided, walks from that entry to root.\n * Handles compaction and branch summaries along the path.\n */\nexport function buildSessionContext(\n\tentries: SessionEntry[],\n\tleafId?: string | null,\n\tbyId?: Map<string, SessionEntry>,\n): SessionContext {\n\t// Build uuid index if not available\n\tif (!byId) {\n\t\tbyId = new Map<string, SessionEntry>();\n\t\tfor (const entry of entries) {\n\t\t\tbyId.set(entry.id, entry);\n\t\t}\n\t}\n\n\t// Find leaf\n\tlet leaf: SessionEntry | undefined;\n\tif (leafId === null) {\n\t\t// Explicitly null - return no messages (navigated to before first entry)\n\t\treturn { messages: [], thinkingLevel: \"off\", model: null };\n\t}\n\tif (leafId) {\n\t\tleaf = byId.get(leafId);\n\t}\n\tif (!leaf) {\n\t\t// Fallback to last entry (when leafId is undefined)\n\t\tleaf = entries[entries.length - 1];\n\t}\n\n\tif (!leaf) {\n\t\treturn { messages: [], thinkingLevel: \"off\", model: null };\n\t}\n\n\t// Walk from leaf to root, collecting path\n\tconst path: SessionEntry[] = [];\n\tlet current: SessionEntry | undefined = leaf;\n\twhile (current) {\n\t\tpath.unshift(current);\n\t\tcurrent = current.parentId ? byId.get(current.parentId) : undefined;\n\t}\n\n\t// Extract settings and find compaction\n\tlet thinkingLevel = \"off\";\n\tlet model: { provider: string; modelId: string } | null = null;\n\tlet compaction: CompactionEntry | null = null;\n\n\tfor (const entry of path) {\n\t\tif (entry.type === \"thinking_level_change\") {\n\t\t\tthinkingLevel = entry.thinkingLevel;\n\t\t} else if (entry.type === \"model_change\") {\n\t\t\tmodel = { provider: entry.provider, modelId: entry.modelId };\n\t\t} else if (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\tmodel = { provider: entry.message.provider, modelId: entry.message.model };\n\t\t} else if (entry.type === \"compaction\") {\n\t\t\tcompaction = entry;\n\t\t}\n\t}\n\n\t// Build messages and collect corresponding entries\n\t// When there's a compaction, we need to:\n\t// 1. Emit summary first (entry = compaction)\n\t// 2. Emit kept messages (from firstKeptEntryId up to compaction)\n\t// 3. Emit messages after compaction\n\tconst messages: AgentMessage[] = [];\n\n\tconst appendMessage = (entry: SessionEntry) => {\n\t\tif (entry.type === \"message\") {\n\t\t\tmessages.push(entry.message);\n\t\t} else if (entry.type === \"custom_message\") {\n\t\t\tmessages.push(\n\t\t\t\tcreateCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),\n\t\t\t);\n\t\t} else if (entry.type === \"branch_summary\" && entry.summary) {\n\t\t\tmessages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));\n\t\t}\n\t};\n\n\tif (compaction) {\n\t\t// Emit summary first\n\t\tmessages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp));\n\n\t\t// Find compaction index in path\n\t\tconst compactionIdx = path.findIndex((e) => e.type === \"compaction\" && e.id === compaction.id);\n\n\t\t// Emit kept messages (before compaction, starting from firstKeptEntryId)\n\t\tlet foundFirstKept = false;\n\t\tfor (let i = 0; i < compactionIdx; i++) {\n\t\t\tconst entry = path[i];\n\t\t\tif (entry.id === compaction.firstKeptEntryId) {\n\t\t\t\tfoundFirstKept = true;\n\t\t\t}\n\t\t\tif (foundFirstKept) {\n\t\t\t\tappendMessage(entry);\n\t\t\t}\n\t\t}\n\n\t\t// Emit messages after compaction\n\t\tfor (let i = compactionIdx + 1; i < path.length; i++) {\n\t\t\tconst entry = path[i];\n\t\t\tappendMessage(entry);\n\t\t}\n\t} else {\n\t\t// No compaction - emit all messages, handle branch summaries and custom messages\n\t\tfor (const entry of path) {\n\t\t\tappendMessage(entry);\n\t\t}\n\t}\n\n\treturn { messages, thinkingLevel, model };\n}\n\n/**\n * Compute the default session directory for a cwd.\n * Encodes cwd into a safe directory name under ~/.dreb/agent/sessions/.\n */\nexport function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string {\n\tconst safePath = `--${cwd.replace(/^[/\\\\]/, \"\").replace(/[/\\\\:]/g, \"-\")}--`;\n\tconst sessionDir = join(agentDir, \"sessions\", safePath);\n\tif (!existsSync(sessionDir)) {\n\t\tmkdirSync(sessionDir, { recursive: true });\n\t}\n\treturn sessionDir;\n}\n\n/** Exported for testing */\nexport function loadEntriesFromFile(filePath: string): FileEntry[] {\n\tif (!existsSync(filePath)) return [];\n\n\tconst content = readFileSync(filePath, \"utf8\");\n\tconst entries: FileEntry[] = [];\n\tconst lines = content.trim().split(\"\\n\");\n\n\tfor (const line of lines) {\n\t\tif (!line.trim()) continue;\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line) as FileEntry;\n\t\t\tentries.push(entry);\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\n\t// Validate session header\n\tif (entries.length === 0) return entries;\n\tconst header = entries[0];\n\tif (header.type !== \"session\" || typeof (header as any).id !== \"string\") {\n\t\treturn [];\n\t}\n\n\treturn entries;\n}\n\nfunction isValidSessionFile(filePath: string): boolean {\n\ttry {\n\t\tconst fd = openSync(filePath, \"r\");\n\t\tconst buffer = Buffer.alloc(512);\n\t\tconst bytesRead = readSync(fd, buffer, 0, 512, 0);\n\t\tcloseSync(fd);\n\t\tconst firstLine = buffer.toString(\"utf8\", 0, bytesRead).split(\"\\n\")[0];\n\t\tif (!firstLine) return false;\n\t\tconst header = JSON.parse(firstLine);\n\t\treturn header.type === \"session\" && typeof header.id === \"string\";\n\t} catch {\n\t\t/* Not a valid session file (corrupt, not JSON) */\n\t\treturn false;\n\t}\n}\n\n/** Exported for testing */\nexport function findMostRecentSession(sessionDir: string): string | null {\n\ttry {\n\t\tconst files = readdirSync(sessionDir)\n\t\t\t.filter((f) => f.endsWith(\".jsonl\"))\n\t\t\t.map((f) => join(sessionDir, f))\n\t\t\t.filter(isValidSessionFile)\n\t\t\t.map((path) => ({ path, mtime: statSync(path).mtime }))\n\t\t\t.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());\n\n\t\treturn files[0]?.path || null;\n\t} catch {\n\t\t/* Session directory may not exist or be unreadable */\n\t\treturn null;\n\t}\n}\n\nfunction isMessageWithContent(message: AgentMessage): message is Message {\n\treturn typeof (message as Message).role === \"string\" && \"content\" in message;\n}\n\nfunction extractTextContent(message: Message): string {\n\tconst content = message.content;\n\tif (typeof content === \"string\") {\n\t\treturn content;\n\t}\n\treturn content\n\t\t.filter((block): block is TextContent => block.type === \"text\")\n\t\t.map((block) => block.text)\n\t\t.join(\" \");\n}\n\nfunction getLastActivityTime(entries: FileEntry[]): number | undefined {\n\tlet lastActivityTime: number | undefined;\n\n\tfor (const entry of entries) {\n\t\tif (entry.type !== \"message\") continue;\n\n\t\tconst message = (entry as SessionMessageEntry).message;\n\t\tif (!isMessageWithContent(message)) continue;\n\t\tif (message.role !== \"user\" && message.role !== \"assistant\") continue;\n\n\t\tconst msgTimestamp = (message as { timestamp?: number }).timestamp;\n\t\tif (typeof msgTimestamp === \"number\") {\n\t\t\tlastActivityTime = Math.max(lastActivityTime ?? 0, msgTimestamp);\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst entryTimestamp = (entry as SessionEntryBase).timestamp;\n\t\tif (typeof entryTimestamp === \"string\") {\n\t\t\tconst t = new Date(entryTimestamp).getTime();\n\t\t\tif (!Number.isNaN(t)) {\n\t\t\t\tlastActivityTime = Math.max(lastActivityTime ?? 0, t);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn lastActivityTime;\n}\n\nfunction getSessionModifiedDate(entries: FileEntry[], header: SessionHeader, statsMtime: Date): Date {\n\tconst lastActivityTime = getLastActivityTime(entries);\n\tif (typeof lastActivityTime === \"number\" && lastActivityTime > 0) {\n\t\treturn new Date(lastActivityTime);\n\t}\n\n\tconst headerTime = typeof header.timestamp === \"string\" ? new Date(header.timestamp).getTime() : NaN;\n\treturn !Number.isNaN(headerTime) ? new Date(headerTime) : statsMtime;\n}\n\nasync function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {\n\ttry {\n\t\tconst content = await readFile(filePath, \"utf8\");\n\t\tconst entries: FileEntry[] = [];\n\t\tconst lines = content.trim().split(\"\\n\");\n\n\t\tfor (const line of lines) {\n\t\t\tif (!line.trim()) continue;\n\t\t\ttry {\n\t\t\t\tentries.push(JSON.parse(line) as FileEntry);\n\t\t\t} catch {\n\t\t\t\t// Skip malformed lines\n\t\t\t}\n\t\t}\n\n\t\tif (entries.length === 0) return null;\n\t\tconst header = entries[0];\n\t\tif (header.type !== \"session\") return null;\n\n\t\tconst stats = await stat(filePath);\n\t\tlet messageCount = 0;\n\t\tlet firstMessage = \"\";\n\t\tconst allMessages: string[] = [];\n\t\tlet name: string | undefined;\n\n\t\tfor (const entry of entries) {\n\t\t\t// Extract session name (use latest, including explicit clears)\n\t\t\tif (entry.type === \"session_info\") {\n\t\t\t\tconst infoEntry = entry as SessionInfoEntry;\n\t\t\t\tname = infoEntry.name?.trim() || undefined;\n\t\t\t}\n\n\t\t\tif (entry.type !== \"message\") continue;\n\t\t\tmessageCount++;\n\n\t\t\tconst message = (entry as SessionMessageEntry).message;\n\t\t\tif (!isMessageWithContent(message)) continue;\n\t\t\tif (message.role !== \"user\" && message.role !== \"assistant\") continue;\n\n\t\t\tconst textContent = extractTextContent(message);\n\t\t\tif (!textContent) continue;\n\n\t\t\tallMessages.push(textContent);\n\t\t\tif (!firstMessage && message.role === \"user\") {\n\t\t\t\tfirstMessage = textContent;\n\t\t\t}\n\t\t}\n\n\t\tconst cwd = typeof (header as SessionHeader).cwd === \"string\" ? (header as SessionHeader).cwd : \"\";\n\t\tconst parentSessionPath = (header as SessionHeader).parentSession;\n\n\t\tconst modified = getSessionModifiedDate(entries, header as SessionHeader, stats.mtime);\n\n\t\treturn {\n\t\t\tpath: filePath,\n\t\t\tid: (header as SessionHeader).id,\n\t\t\tcwd,\n\t\t\tname,\n\t\t\tparentSessionPath,\n\t\t\tcreated: new Date((header as SessionHeader).timestamp),\n\t\t\tmodified,\n\t\t\tmessageCount,\n\t\t\tfirstMessage: firstMessage || \"(no messages)\",\n\t\t\tallMessagesText: allMessages.join(\" \"),\n\t\t};\n\t} catch {\n\t\t/* Corrupt session file — exclude from listing */\n\t\treturn null;\n\t}\n}\n\nexport type SessionListProgress = (loaded: number, total: number) => void;\n\nasync function listSessionsFromDir(\n\tdir: string,\n\tonProgress?: SessionListProgress,\n\tprogressOffset = 0,\n\tprogressTotal?: number,\n): Promise<SessionInfo[]> {\n\tconst sessions: SessionInfo[] = [];\n\tif (!existsSync(dir)) {\n\t\treturn sessions;\n\t}\n\n\ttry {\n\t\tconst dirEntries = await readdir(dir);\n\t\tconst files = dirEntries.filter((f) => f.endsWith(\".jsonl\")).map((f) => join(dir, f));\n\t\tconst total = progressTotal ?? files.length;\n\n\t\tlet loaded = 0;\n\t\tconst results = await Promise.all(\n\t\t\tfiles.map(async (file) => {\n\t\t\t\tconst info = await buildSessionInfo(file);\n\t\t\t\tloaded++;\n\t\t\t\tonProgress?.(progressOffset + loaded, total);\n\t\t\t\treturn info;\n\t\t\t}),\n\t\t);\n\t\tfor (const info of results) {\n\t\t\tif (info) {\n\t\t\t\tsessions.push(info);\n\t\t\t}\n\t\t}\n\t} catch {\n\t\t/* Session directory read failed — return partial results */\n\t}\n\n\treturn sessions;\n}\n\n/**\n * Manages conversation sessions as append-only trees stored in JSONL files.\n *\n * Each session entry has an id and parentId forming a tree structure. The \"leaf\"\n * pointer tracks the current position. Appending creates a child of the current leaf.\n * Branching moves the leaf to an earlier entry, allowing new branches without\n * modifying history.\n *\n * Use buildSessionContext() to get the resolved message list for the LLM, which\n * handles compaction summaries and follows the path from root to current leaf.\n */\nexport class SessionManager {\n\tprivate sessionId: string = \"\";\n\tprivate sessionFile: string | undefined;\n\tprivate sessionDir: string;\n\tprivate cwd: string;\n\tprivate persist: boolean;\n\tprivate flushed: boolean = false;\n\tprivate fileEntries: FileEntry[] = [];\n\tprivate byId: Map<string, SessionEntry> = new Map();\n\tprivate labelsById: Map<string, string> = new Map();\n\tprivate leafId: string | null = null;\n\n\tprivate constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {\n\t\tthis.cwd = cwd;\n\t\tthis.sessionDir = sessionDir;\n\t\tthis.persist = persist;\n\t\tif (persist && sessionDir && !existsSync(sessionDir)) {\n\t\t\tmkdirSync(sessionDir, { recursive: true });\n\t\t}\n\n\t\tif (sessionFile) {\n\t\t\tthis.setSessionFile(sessionFile);\n\t\t} else {\n\t\t\tthis.newSession();\n\t\t}\n\t}\n\n\t/** Switch to a different session file (used for resume and branching) */\n\tsetSessionFile(sessionFile: string): void {\n\t\tthis.sessionFile = resolve(sessionFile);\n\t\tif (existsSync(this.sessionFile)) {\n\t\t\tthis.fileEntries = loadEntriesFromFile(this.sessionFile);\n\n\t\t\t// If file was empty or corrupted (no valid header), truncate and start fresh\n\t\t\t// to avoid appending messages without a session header (which breaks the session)\n\t\t\tif (this.fileEntries.length === 0) {\n\t\t\t\tconst explicitPath = this.sessionFile;\n\t\t\t\tthis.newSession();\n\t\t\t\tthis.sessionFile = explicitPath;\n\t\t\t\tthis._rewriteFile();\n\t\t\t\tthis.flushed = true;\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst header = this.fileEntries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\t\t\tthis.sessionId = header?.id ?? randomUUID();\n\n\t\t\tif (migrateToCurrentVersion(this.fileEntries)) {\n\t\t\t\tthis._rewriteFile();\n\t\t\t}\n\n\t\t\tthis._buildIndex();\n\t\t\tthis.flushed = true;\n\t\t} else {\n\t\t\tconst explicitPath = this.sessionFile;\n\t\t\tthis.newSession();\n\t\t\tthis.sessionFile = explicitPath; // preserve explicit path from --session flag\n\t\t}\n\t}\n\n\tnewSession(options?: NewSessionOptions): string | undefined {\n\t\tthis.sessionId = options?.id ?? randomUUID();\n\t\tconst timestamp = new Date().toISOString();\n\t\tconst header: SessionHeader = {\n\t\t\ttype: \"session\",\n\t\t\tversion: CURRENT_SESSION_VERSION,\n\t\t\tid: this.sessionId,\n\t\t\ttimestamp,\n\t\t\tcwd: this.cwd,\n\t\t\tparentSession: options?.parentSession,\n\t\t};\n\t\tthis.fileEntries = [header];\n\t\tthis.byId.clear();\n\t\tthis.labelsById.clear();\n\t\tthis.leafId = null;\n\t\tthis.flushed = false;\n\n\t\tif (this.persist) {\n\t\t\tconst fileTimestamp = timestamp.replace(/[:.]/g, \"-\");\n\t\t\tthis.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);\n\t\t}\n\t\treturn this.sessionFile;\n\t}\n\n\tprivate _buildIndex(): void {\n\t\tthis.byId.clear();\n\t\tthis.labelsById.clear();\n\t\tthis.leafId = null;\n\t\tfor (const entry of this.fileEntries) {\n\t\t\tif (entry.type === \"session\") continue;\n\t\t\tthis.byId.set(entry.id, entry);\n\t\t\tthis.leafId = entry.id;\n\t\t\tif (entry.type === \"label\") {\n\t\t\t\tif (entry.label) {\n\t\t\t\t\tthis.labelsById.set(entry.targetId, entry.label);\n\t\t\t\t} else {\n\t\t\t\t\tthis.labelsById.delete(entry.targetId);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate _rewriteFile(): void {\n\t\tif (!this.persist || !this.sessionFile) return;\n\t\tconst content = `${this.fileEntries.map((e) => JSON.stringify(e)).join(\"\\n\")}\\n`;\n\t\twriteFileSync(this.sessionFile, content);\n\t}\n\n\tisPersisted(): boolean {\n\t\treturn this.persist;\n\t}\n\n\tgetCwd(): string {\n\t\treturn this.cwd;\n\t}\n\n\tgetSessionDir(): string {\n\t\treturn this.sessionDir;\n\t}\n\n\tgetSessionId(): string {\n\t\treturn this.sessionId;\n\t}\n\n\tgetSessionFile(): string | undefined {\n\t\treturn this.sessionFile;\n\t}\n\n\t_persist(entry: SessionEntry): void {\n\t\tif (!this.persist || !this.sessionFile) return;\n\n\t\tconst hasAssistant = this.fileEntries.some((e) => e.type === \"message\" && e.message.role === \"assistant\");\n\t\tif (!hasAssistant) {\n\t\t\t// Mark as not flushed so when assistant arrives, all entries get written\n\t\t\tthis.flushed = false;\n\t\t\treturn;\n\t\t}\n\n\t\tif (!this.flushed) {\n\t\t\tfor (const e of this.fileEntries) {\n\t\t\t\tappendFileSync(this.sessionFile, `${JSON.stringify(e)}\\n`);\n\t\t\t}\n\t\t\tthis.flushed = true;\n\t\t} else {\n\t\t\tappendFileSync(this.sessionFile, `${JSON.stringify(entry)}\\n`);\n\t\t}\n\t}\n\n\tprivate _appendEntry(entry: SessionEntry): void {\n\t\tthis.fileEntries.push(entry);\n\t\tthis.byId.set(entry.id, entry);\n\t\tthis.leafId = entry.id;\n\t\tthis._persist(entry);\n\t}\n\n\t/** Append a message as child of current leaf, then advance leaf. Returns entry id.\n\t * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.\n\t * Reason: we want these to be top-level entries in the session, not message session entries,\n\t * so it is easier to find them.\n\t * These need to be appended via appendCompaction() and appendBranchSummary() methods.\n\t */\n\tappendMessage(message: Message | CustomMessage | BashExecutionMessage): string {\n\t\tconst entry: SessionMessageEntry = {\n\t\t\ttype: \"message\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tmessage,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */\n\tappendThinkingLevelChange(thinkingLevel: string): string {\n\t\tconst entry: ThinkingLevelChangeEntry = {\n\t\t\ttype: \"thinking_level_change\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tthinkingLevel,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a model change as child of current leaf, then advance leaf. Returns entry id. */\n\tappendModelChange(provider: string, modelId: string): string {\n\t\tconst entry: ModelChangeEntry = {\n\t\t\ttype: \"model_change\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tprovider,\n\t\t\tmodelId,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */\n\tappendCompaction<T = unknown>(\n\t\tsummary: string,\n\t\tfirstKeptEntryId: string,\n\t\ttokensBefore: number,\n\t\tdetails?: T,\n\t\tfromHook?: boolean,\n\t): string {\n\t\tconst entry: CompactionEntry<T> = {\n\t\t\ttype: \"compaction\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tsummary,\n\t\t\tfirstKeptEntryId,\n\t\t\ttokensBefore,\n\t\t\tdetails,\n\t\t\tfromHook,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */\n\tappendCustomEntry(customType: string, data?: unknown): string {\n\t\tconst entry: CustomEntry = {\n\t\t\ttype: \"custom\",\n\t\t\tcustomType,\n\t\t\tdata,\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a session info entry (e.g., display name). Returns entry id. */\n\tappendSessionInfo(name: string): string {\n\t\tconst entry: SessionInfoEntry = {\n\t\t\ttype: \"session_info\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tname: name.trim(),\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Get the current session name from the latest session_info entry, if any. */\n\tgetSessionName(): string | undefined {\n\t\t// Walk entries in reverse to find the latest session_info entry.\n\t\t// Empty names explicitly clear the session title.\n\t\tconst entries = this.getEntries();\n\t\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\t\tconst entry = entries[i];\n\t\t\tif (entry.type === \"session_info\") {\n\t\t\t\treturn entry.name?.trim() || undefined;\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Append a custom message entry (for extensions) that participates in LLM context.\n\t * @param customType Extension identifier for filtering on reload\n\t * @param content Message content (string or TextContent/ImageContent array)\n\t * @param display Whether to show in TUI (true = styled display, false = hidden)\n\t * @param details Optional extension-specific metadata (not sent to LLM)\n\t * @returns Entry id\n\t */\n\tappendCustomMessageEntry<T = unknown>(\n\t\tcustomType: string,\n\t\tcontent: string | (TextContent | ImageContent)[],\n\t\tdisplay: boolean,\n\t\tdetails?: T,\n\t): string {\n\t\tconst entry: CustomMessageEntry<T> = {\n\t\t\ttype: \"custom_message\",\n\t\t\tcustomType,\n\t\t\tcontent,\n\t\t\tdisplay,\n\t\t\tdetails,\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t// =========================================================================\n\t// Tree Traversal\n\t// =========================================================================\n\n\tgetLeafId(): string | null {\n\t\treturn this.leafId;\n\t}\n\n\tgetLeafEntry(): SessionEntry | undefined {\n\t\treturn this.leafId ? this.byId.get(this.leafId) : undefined;\n\t}\n\n\tgetEntry(id: string): SessionEntry | undefined {\n\t\treturn this.byId.get(id);\n\t}\n\n\t/**\n\t * Get all direct children of an entry.\n\t */\n\tgetChildren(parentId: string): SessionEntry[] {\n\t\tconst children: SessionEntry[] = [];\n\t\tfor (const entry of this.byId.values()) {\n\t\t\tif (entry.parentId === parentId) {\n\t\t\t\tchildren.push(entry);\n\t\t\t}\n\t\t}\n\t\treturn children;\n\t}\n\n\t/**\n\t * Get the label for an entry, if any.\n\t */\n\tgetLabel(id: string): string | undefined {\n\t\treturn this.labelsById.get(id);\n\t}\n\n\t/**\n\t * Set or clear a label on an entry.\n\t * Labels are user-defined markers for bookmarking/navigation.\n\t * Pass undefined or empty string to clear the label.\n\t */\n\tappendLabelChange(targetId: string, label: string | undefined): string {\n\t\tif (!this.byId.has(targetId)) {\n\t\t\tthrow new Error(`Entry ${targetId} not found`);\n\t\t}\n\t\tconst entry: LabelEntry = {\n\t\t\ttype: \"label\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\ttargetId,\n\t\t\tlabel,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\tif (label) {\n\t\t\tthis.labelsById.set(targetId, label);\n\t\t} else {\n\t\t\tthis.labelsById.delete(targetId);\n\t\t}\n\t\treturn entry.id;\n\t}\n\n\t/**\n\t * Walk from entry to root, returning all entries in path order.\n\t * Includes all entry types (messages, compaction, model changes, etc.).\n\t * Use buildSessionContext() to get the resolved messages for the LLM.\n\t */\n\tgetBranch(fromId?: string): SessionEntry[] {\n\t\tconst path: SessionEntry[] = [];\n\t\tconst startId = fromId ?? this.leafId;\n\t\tlet current = startId ? this.byId.get(startId) : undefined;\n\t\twhile (current) {\n\t\t\tpath.unshift(current);\n\t\t\tcurrent = current.parentId ? this.byId.get(current.parentId) : undefined;\n\t\t}\n\t\treturn path;\n\t}\n\n\t/**\n\t * Build the session context (what gets sent to the LLM).\n\t * Uses tree traversal from current leaf.\n\t */\n\tbuildSessionContext(): SessionContext {\n\t\treturn buildSessionContext(this.getEntries(), this.leafId, this.byId);\n\t}\n\n\t/**\n\t * Get session header.\n\t */\n\tgetHeader(): SessionHeader | null {\n\t\tconst h = this.fileEntries.find((e) => e.type === \"session\");\n\t\treturn h ? (h as SessionHeader) : null;\n\t}\n\n\t/**\n\t * Get all session entries (excludes header). Returns a shallow copy.\n\t * The session is append-only: use appendXXX() to add entries, branch() to\n\t * change the leaf pointer. Entries cannot be modified or deleted.\n\t */\n\tgetEntries(): SessionEntry[] {\n\t\treturn this.fileEntries.filter((e): e is SessionEntry => e.type !== \"session\");\n\t}\n\n\t/**\n\t * Get the session as a tree structure. Returns a shallow defensive copy of all entries.\n\t * A well-formed session has exactly one root (first entry with parentId === null).\n\t * Orphaned entries (broken parent chain) are also returned as roots.\n\t */\n\tgetTree(): SessionTreeNode[] {\n\t\tconst entries = this.getEntries();\n\t\tconst nodeMap = new Map<string, SessionTreeNode>();\n\t\tconst roots: SessionTreeNode[] = [];\n\n\t\t// Create nodes with resolved labels\n\t\tfor (const entry of entries) {\n\t\t\tconst label = this.labelsById.get(entry.id);\n\t\t\tnodeMap.set(entry.id, { entry, children: [], label });\n\t\t}\n\n\t\t// Build tree\n\t\tfor (const entry of entries) {\n\t\t\tconst node = nodeMap.get(entry.id)!;\n\t\t\tif (entry.parentId === null || entry.parentId === entry.id) {\n\t\t\t\troots.push(node);\n\t\t\t} else {\n\t\t\t\tconst parent = nodeMap.get(entry.parentId);\n\t\t\t\tif (parent) {\n\t\t\t\t\tparent.children.push(node);\n\t\t\t\t} else {\n\t\t\t\t\t// Orphan - treat as root\n\t\t\t\t\troots.push(node);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Sort children by timestamp (oldest first, newest at bottom)\n\t\t// Use iterative approach to avoid stack overflow on deep trees\n\t\tconst stack: SessionTreeNode[] = [...roots];\n\t\twhile (stack.length > 0) {\n\t\t\tconst node = stack.pop()!;\n\t\t\tnode.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());\n\t\t\tstack.push(...node.children);\n\t\t}\n\n\t\treturn roots;\n\t}\n\n\t// =========================================================================\n\t// Branching\n\t// =========================================================================\n\n\t/**\n\t * Start a new branch from an earlier entry.\n\t * Moves the leaf pointer to the specified entry. The next appendXXX() call\n\t * will create a child of that entry, forming a new branch. Existing entries\n\t * are not modified or deleted.\n\t */\n\tbranch(branchFromId: string): void {\n\t\tif (!this.byId.has(branchFromId)) {\n\t\t\tthrow new Error(`Entry ${branchFromId} not found`);\n\t\t}\n\t\tthis.leafId = branchFromId;\n\t}\n\n\t/**\n\t * Reset the leaf pointer to null (before any entries).\n\t * The next appendXXX() call will create a new root entry (parentId = null).\n\t * Use this when navigating to re-edit the first user message.\n\t */\n\tresetLeaf(): void {\n\t\tthis.leafId = null;\n\t}\n\n\t/**\n\t * Start a new branch with a summary of the abandoned path.\n\t * Same as branch(), but also appends a branch_summary entry that captures\n\t * context from the abandoned conversation path.\n\t */\n\tbranchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean): string {\n\t\tif (branchFromId !== null && !this.byId.has(branchFromId)) {\n\t\t\tthrow new Error(`Entry ${branchFromId} not found`);\n\t\t}\n\t\tthis.leafId = branchFromId;\n\t\tconst entry: BranchSummaryEntry = {\n\t\t\ttype: \"branch_summary\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: branchFromId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tfromId: branchFromId ?? \"root\",\n\t\t\tsummary,\n\t\t\tdetails,\n\t\t\tfromHook,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/**\n\t * Create a new session file containing only the path from root to the specified leaf.\n\t * Useful for extracting a single conversation path from a branched session.\n\t * Returns the new session file path, or undefined if not persisting.\n\t */\n\tcreateBranchedSession(leafId: string): string | undefined {\n\t\tconst previousSessionFile = this.sessionFile;\n\t\tconst path = this.getBranch(leafId);\n\t\tif (path.length === 0) {\n\t\t\tthrow new Error(`Entry ${leafId} not found`);\n\t\t}\n\n\t\t// Filter out LabelEntry from path - we'll recreate them from the resolved map\n\t\tconst pathWithoutLabels = path.filter((e) => e.type !== \"label\");\n\n\t\tconst newSessionId = randomUUID();\n\t\tconst timestamp = new Date().toISOString();\n\t\tconst fileTimestamp = timestamp.replace(/[:.]/g, \"-\");\n\t\tconst newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);\n\n\t\tconst header: SessionHeader = {\n\t\t\ttype: \"session\",\n\t\t\tversion: CURRENT_SESSION_VERSION,\n\t\t\tid: newSessionId,\n\t\t\ttimestamp,\n\t\t\tcwd: this.cwd,\n\t\t\tparentSession: this.persist ? previousSessionFile : undefined,\n\t\t};\n\n\t\t// Collect labels for entries in the path\n\t\tconst pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id));\n\t\tconst labelsToWrite: Array<{ targetId: string; label: string }> = [];\n\t\tfor (const [targetId, label] of this.labelsById) {\n\t\t\tif (pathEntryIds.has(targetId)) {\n\t\t\t\tlabelsToWrite.push({ targetId, label });\n\t\t\t}\n\t\t}\n\n\t\tif (this.persist) {\n\t\t\t// Build label entries\n\t\t\tconst lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;\n\t\t\tlet parentId = lastEntryId;\n\t\t\tconst labelEntries: LabelEntry[] = [];\n\t\t\tfor (const { targetId, label } of labelsToWrite) {\n\t\t\t\tconst labelEntry: LabelEntry = {\n\t\t\t\t\ttype: \"label\",\n\t\t\t\t\tid: generateId(new Set(pathEntryIds)),\n\t\t\t\t\tparentId,\n\t\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t\t\ttargetId,\n\t\t\t\t\tlabel,\n\t\t\t\t};\n\t\t\t\tpathEntryIds.add(labelEntry.id);\n\t\t\t\tlabelEntries.push(labelEntry);\n\t\t\t\tparentId = labelEntry.id;\n\t\t\t}\n\n\t\t\tthis.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];\n\t\t\tthis.sessionId = newSessionId;\n\t\t\tthis.sessionFile = newSessionFile;\n\t\t\tthis._buildIndex();\n\n\t\t\t// Only write the file now if it contains an assistant message.\n\t\t\t// Otherwise defer to _persist(), which creates the file on the\n\t\t\t// first assistant response, matching the newSession() contract\n\t\t\t// and avoiding the duplicate-header bug when _persist()'s\n\t\t\t// no-assistant guard later resets flushed to false.\n\t\t\tconst hasAssistant = this.fileEntries.some((e) => e.type === \"message\" && e.message.role === \"assistant\");\n\t\t\tif (hasAssistant) {\n\t\t\t\tthis._rewriteFile();\n\t\t\t\tthis.flushed = true;\n\t\t\t} else {\n\t\t\t\tthis.flushed = false;\n\t\t\t}\n\n\t\t\treturn newSessionFile;\n\t\t}\n\n\t\t// In-memory mode: replace current session with the path + labels\n\t\tconst labelEntries: LabelEntry[] = [];\n\t\tlet parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;\n\t\tfor (const { targetId, label } of labelsToWrite) {\n\t\t\tconst labelEntry: LabelEntry = {\n\t\t\t\ttype: \"label\",\n\t\t\t\tid: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])),\n\t\t\t\tparentId,\n\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t\ttargetId,\n\t\t\t\tlabel,\n\t\t\t};\n\t\t\tlabelEntries.push(labelEntry);\n\t\t\tparentId = labelEntry.id;\n\t\t}\n\t\tthis.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];\n\t\tthis.sessionId = newSessionId;\n\t\tthis._buildIndex();\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Create a new session.\n\t * @param cwd Working directory (stored in session header)\n\t * @param sessionDir Optional session directory. If omitted, uses default (~/.dreb/agent/sessions/<encoded-cwd>/).\n\t */\n\tstatic create(cwd: string, sessionDir?: string): SessionManager {\n\t\tconst dir = sessionDir ?? getDefaultSessionDir(cwd);\n\t\treturn new SessionManager(cwd, dir, undefined, true);\n\t}\n\n\t/**\n\t * Open a specific session file.\n\t * @param path Path to session file\n\t * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.\n\t */\n\tstatic open(path: string, sessionDir?: string): SessionManager {\n\t\t// Extract cwd from session header if possible, otherwise use process.cwd()\n\t\tconst entries = loadEntriesFromFile(path);\n\t\tconst header = entries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\t\tconst cwd = header?.cwd ?? process.cwd();\n\t\t// If no sessionDir provided, derive from file's parent directory\n\t\tconst dir = sessionDir ?? resolve(path, \"..\");\n\t\treturn new SessionManager(cwd, dir, path, true);\n\t}\n\n\t/**\n\t * Continue the most recent session, or create new if none.\n\t * @param cwd Working directory\n\t * @param sessionDir Optional session directory. If omitted, uses default (~/.dreb/agent/sessions/<encoded-cwd>/).\n\t */\n\tstatic continueRecent(cwd: string, sessionDir?: string): SessionManager {\n\t\tconst dir = sessionDir ?? getDefaultSessionDir(cwd);\n\t\tconst mostRecent = findMostRecentSession(dir);\n\t\tif (mostRecent) {\n\t\t\treturn new SessionManager(cwd, dir, mostRecent, true);\n\t\t}\n\t\treturn new SessionManager(cwd, dir, undefined, true);\n\t}\n\n\t/** Create an in-memory session (no file persistence) */\n\tstatic inMemory(cwd: string = process.cwd()): SessionManager {\n\t\treturn new SessionManager(cwd, \"\", undefined, false);\n\t}\n\n\t/**\n\t * Fork a session from another project directory into the current project.\n\t * Creates a new session in the target cwd with the full history from the source session.\n\t * @param sourcePath Path to the source session file\n\t * @param targetCwd Target working directory (where the new session will be stored)\n\t * @param sessionDir Optional session directory. If omitted, uses default for targetCwd.\n\t */\n\tstatic forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string): SessionManager {\n\t\tconst sourceEntries = loadEntriesFromFile(sourcePath);\n\t\tif (sourceEntries.length === 0) {\n\t\t\tthrow new Error(`Cannot fork: source session file is empty or invalid: ${sourcePath}`);\n\t\t}\n\n\t\tconst sourceHeader = sourceEntries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\t\tif (!sourceHeader) {\n\t\t\tthrow new Error(`Cannot fork: source session has no header: ${sourcePath}`);\n\t\t}\n\n\t\tconst dir = sessionDir ?? getDefaultSessionDir(targetCwd);\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true });\n\t\t}\n\n\t\t// Create new session file with new ID but forked content\n\t\tconst newSessionId = randomUUID();\n\t\tconst timestamp = new Date().toISOString();\n\t\tconst fileTimestamp = timestamp.replace(/[:.]/g, \"-\");\n\t\tconst newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`);\n\n\t\t// Write new header pointing to source as parent, with updated cwd\n\t\tconst newHeader: SessionHeader = {\n\t\t\ttype: \"session\",\n\t\t\tversion: CURRENT_SESSION_VERSION,\n\t\t\tid: newSessionId,\n\t\t\ttimestamp,\n\t\t\tcwd: targetCwd,\n\t\t\tparentSession: sourcePath,\n\t\t};\n\t\tappendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\\n`);\n\n\t\t// Copy all non-header entries from source\n\t\tfor (const entry of sourceEntries) {\n\t\t\tif (entry.type !== \"session\") {\n\t\t\t\tappendFileSync(newSessionFile, `${JSON.stringify(entry)}\\n`);\n\t\t\t}\n\t\t}\n\n\t\treturn new SessionManager(targetCwd, dir, newSessionFile, true);\n\t}\n\n\t/**\n\t * List all sessions for a directory.\n\t * @param cwd Working directory (used to compute default session directory)\n\t * @param sessionDir Optional session directory. If omitted, uses default (~/.dreb/agent/sessions/<encoded-cwd>/).\n\t * @param onProgress Optional callback for progress updates (loaded, total)\n\t */\n\tstatic async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]> {\n\t\tconst dir = sessionDir ?? getDefaultSessionDir(cwd);\n\t\tconst sessions = await listSessionsFromDir(dir, onProgress);\n\t\tsessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());\n\t\treturn sessions;\n\t}\n\n\t/**\n\t * List all sessions across all project directories.\n\t * @param onProgress Optional callback for progress updates (loaded, total)\n\t */\n\tstatic async listAll(onProgress?: SessionListProgress): Promise<SessionInfo[]> {\n\t\tconst sessionsDir = getSessionsDir();\n\n\t\ttry {\n\t\t\tif (!existsSync(sessionsDir)) {\n\t\t\t\treturn [];\n\t\t\t}\n\t\t\tconst entries = await readdir(sessionsDir, { withFileTypes: true });\n\t\t\tconst dirs = entries.filter((e) => e.isDirectory()).map((e) => join(sessionsDir, e.name));\n\n\t\t\t// Count total files first for accurate progress\n\t\t\tlet totalFiles = 0;\n\t\t\tconst dirFiles: string[][] = [];\n\t\t\tfor (const dir of dirs) {\n\t\t\t\ttry {\n\t\t\t\t\tconst files = (await readdir(dir)).filter((f) => f.endsWith(\".jsonl\"));\n\t\t\t\t\tdirFiles.push(files.map((f) => join(dir, f)));\n\t\t\t\t\ttotalFiles += files.length;\n\t\t\t\t} catch {\n\t\t\t\t\t/* Directory unreadable — skip */\n\t\t\t\t\tdirFiles.push([]);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Process all files with progress tracking\n\t\t\tlet loaded = 0;\n\t\t\tconst sessions: SessionInfo[] = [];\n\t\t\tconst allFiles = dirFiles.flat();\n\n\t\t\tconst results = await Promise.all(\n\t\t\t\tallFiles.map(async (file) => {\n\t\t\t\t\tconst info = await buildSessionInfo(file);\n\t\t\t\t\tloaded++;\n\t\t\t\t\tonProgress?.(loaded, totalFiles);\n\t\t\t\t\treturn info;\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tfor (const info of results) {\n\t\t\t\tif (info) {\n\t\t\t\t\tsessions.push(info);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tsessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());\n\t\t\treturn sessions;\n\t\t} catch {\n\t\t\t/* Session listing failed — return empty list */\n\t\t\treturn [];\n\t\t}\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../../src/core/session-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAiBnE,OAAO,EACN,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAIlB,MAAM,eAAe,CAAC;AAEvB,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAEzC,MAAM,WAAW,aAAa;IAC7B,IAAI,EAAE,SAAS,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gGAAgG;IAChG,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IACjC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,gBAAgB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,mBAAoB,SAAQ,gBAAgB;IAC5D,IAAI,EAAE,SAAS,CAAC;IAChB,OAAO,EAAE,YAAY,CAAC;CACtB;AAED,MAAM,WAAW,wBAAyB,SAAQ,gBAAgB;IACjE,IAAI,EAAE,uBAAuB,CAAC;IAC9B,aAAa,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,gBAAiB,SAAQ,gBAAgB;IACzD,IAAI,EAAE,cAAc,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,eAAe,CAAC,CAAC,GAAG,OAAO,CAAE,SAAQ,gBAAgB;IACrE,IAAI,EAAE,YAAY,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,+FAA+F;IAC/F,OAAO,CAAC,EAAE,CAAC,CAAC;IACZ,iGAAiG;IACjG,QAAQ,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB,CAAC,CAAC,GAAG,OAAO,CAAE,SAAQ,gBAAgB;IACxE,IAAI,EAAE,gBAAgB,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,gDAAgD;IAChD,OAAO,CAAC,EAAE,CAAC,CAAC;IACZ,iEAAiE;IACjE,QAAQ,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,WAAW,CAAC,CAAC,GAAG,OAAO,CAAE,SAAQ,gBAAgB;IACjE,IAAI,EAAE,QAAQ,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,CAAC,CAAC;CACT;AAED,iEAAiE;AACjE,MAAM,WAAW,UAAW,SAAQ,gBAAgB;IACnD,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;CAC1B;AAED,gEAAgE;AAChE,MAAM,WAAW,gBAAiB,SAAQ,gBAAgB;IACzD,IAAI,EAAE,cAAc,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,kBAAkB,CAAC,CAAC,GAAG,OAAO,CAAE,SAAQ,gBAAgB;IACxE,IAAI,EAAE,gBAAgB,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,GAAG,CAAC,WAAW,GAAG,YAAY,CAAC,EAAE,CAAC;IACjD,OAAO,CAAC,EAAE,CAAC,CAAC;IACZ,OAAO,EAAE,OAAO,CAAC;CACjB;AAED,wGAAwG;AACxG,MAAM,MAAM,YAAY,GACrB,mBAAmB,GACnB,wBAAwB,GACxB,gBAAgB,GAChB,eAAe,GACf,kBAAkB,GAClB,WAAW,GACX,kBAAkB,GAClB,UAAU,GACV,gBAAgB,CAAC;AAEpB,uCAAuC;AACvC,MAAM,MAAM,SAAS,GAAG,aAAa,GAAG,YAAY,CAAC;AAErD,oEAAoE;AACpE,MAAM,WAAW,eAAe;IAC/B,KAAK,EAAE,YAAY,CAAC;IACpB,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,4CAA4C;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,cAAc;IAC9B,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;CACpD;AAED,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,sFAAsF;IACtF,GAAG,EAAE,MAAM,CAAC;IACZ,2DAA2D;IAC3D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,OAAO,EAAE,IAAI,CAAC;IACd,QAAQ,EAAE,IAAI,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,sBAAsB,GAAG,IAAI,CACxC,cAAc,EACZ,QAAQ,GACR,eAAe,GACf,cAAc,GACd,gBAAgB,GAChB,WAAW,GACX,cAAc,GACd,UAAU,GACV,UAAU,GACV,WAAW,GACX,WAAW,GACX,YAAY,GACZ,SAAS,GACT,gBAAgB,CAClB,CAAC;AA2EF,2BAA2B;AAC3B,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,SAAS,EAAE,GAAG,IAAI,CAEhE;AAED,sCAAsC;AACtC,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,CAehE;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,eAAe,GAAG,IAAI,CAOxF;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAClC,OAAO,EAAE,YAAY,EAAE,EACvB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,EACtB,IAAI,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,GAC9B,cAAc,CAuGhB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,GAAE,MAA6B,GAAG,MAAM,CAOjG;AAED,2BAA2B;AAC3B,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAAE,CAyBjE;AAkBD,2BAA2B;AAC3B,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAcvE;AA8HD,MAAM,MAAM,mBAAmB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;AAuC1E;;;;;;;;;;GAUG;AACH,qBAAa,cAAc;IAC1B,OAAO,CAAC,SAAS,CAAc;IAC/B,OAAO,CAAC,WAAW,CAAqB;IACxC,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,GAAG,CAAS;IACpB,OAAO,CAAC,OAAO,CAAU;IACzB,OAAO,CAAC,OAAO,CAAkB;IACjC,OAAO,CAAC,WAAW,CAAmB;IACtC,OAAO,CAAC,IAAI,CAAwC;IACpD,OAAO,CAAC,UAAU,CAAkC;IACpD,OAAO,CAAC,MAAM,CAAuB;IAErC,OAAO,eAaN;IAED,yEAAyE;IACzE,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CA8BxC;IAED,UAAU,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,MAAM,GAAG,SAAS,CAsB1D;IAED;;;;OAIG;IACH,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAKpC;IAED,OAAO,CAAC,WAAW;IAkBnB,OAAO,CAAC,YAAY;IAMpB,WAAW,IAAI,OAAO,CAErB;IAED,MAAM,IAAI,MAAM,CAEf;IAED,aAAa,IAAI,MAAM,CAEtB;IAED,YAAY,IAAI,MAAM,CAErB;IAED,cAAc,IAAI,MAAM,GAAG,SAAS,CAEnC;IAED,QAAQ,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,CAkBlC;IAED,OAAO,CAAC,YAAY;IAOpB;;;;;OAKG;IACH,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,aAAa,GAAG,oBAAoB,GAAG,MAAM,CAU7E;IAED,oGAAoG;IACpG,yBAAyB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAUvD;IAED,2FAA2F;IAC3F,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAW3D;IAED,iGAAiG;IACjG,gBAAgB,CAAC,CAAC,GAAG,OAAO,EAC3B,OAAO,EAAE,MAAM,EACf,gBAAgB,EAAE,MAAM,EACxB,YAAY,EAAE,MAAM,EACpB,OAAO,CAAC,EAAE,CAAC,EACX,QAAQ,CAAC,EAAE,OAAO,GAChB,MAAM,CAcR;IAED,4GAA4G;IAC5G,iBAAiB,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,MAAM,CAW5D;IAED,0EAA0E;IAC1E,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAUtC;IAED,+EAA+E;IAC/E,cAAc,IAAI,MAAM,GAAG,SAAS,CAWnC;IAED;;;;;;;OAOG;IACH,wBAAwB,CAAC,CAAC,GAAG,OAAO,EACnC,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GAAG,CAAC,WAAW,GAAG,YAAY,CAAC,EAAE,EAChD,OAAO,EAAE,OAAO,EAChB,OAAO,CAAC,EAAE,CAAC,GACT,MAAM,CAaR;IAMD,SAAS,IAAI,MAAM,GAAG,IAAI,CAEzB;IAED,YAAY,IAAI,YAAY,GAAG,SAAS,CAEvC;IAED,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAE7C;IAED;;OAEG;IACH,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,YAAY,EAAE,CAQ5C;IAED;;OAEG;IACH,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEvC;IAED;;;;OAIG;IACH,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAmBrE;IAED;;;;OAIG;IACH,SAAS,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,YAAY,EAAE,CASzC;IAED;;;OAGG;IACH,mBAAmB,IAAI,cAAc,CAEpC;IAED;;OAEG;IACH,SAAS,IAAI,aAAa,GAAG,IAAI,CAGhC;IAED;;;;OAIG;IACH,UAAU,IAAI,YAAY,EAAE,CAE3B;IAED;;;;OAIG;IACH,OAAO,IAAI,eAAe,EAAE,CAqC3B;IAMD;;;;;OAKG;IACH,MAAM,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAKjC;IAED;;;;OAIG;IACH,SAAS,IAAI,IAAI,CAEhB;IAED;;;;OAIG;IACH,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,OAAO,GAAG,MAAM,CAiB7G;IAED;;;;OAIG;IACH,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CA4FxD;IAED;;;;OAIG;IACH,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,cAAc,CAG9D;IAED;;;;OAIG;IACH,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,cAAc,CAQ7D;IAED;;;;OAIG;IACH,MAAM,CAAC,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,cAAc,CAOtE;IAED,wDAAwD;IACxD,MAAM,CAAC,QAAQ,CAAC,GAAG,GAAE,MAAsB,GAAG,cAAc,CAE3D;IAED;;;;;;OAMG;IACH,MAAM,CAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,cAAc,CAyC1F;IAED;;;;;OAKG;IACH,OAAa,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAK5G;IAED;;;OAGG;IACH,OAAa,OAAO,CAAC,UAAU,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAkD7E;CACD","sourcesContent":["import type { AgentMessage } from \"@dreb/agent-core\";\nimport type { ImageContent, Message, TextContent } from \"@dreb/ai\";\nimport { randomUUID } from \"crypto\";\nimport {\n\tappendFileSync,\n\tcloseSync,\n\texistsSync,\n\tmkdirSync,\n\topenSync,\n\treaddirSync,\n\treadFileSync,\n\treadSync,\n\tstatSync,\n\twriteFileSync,\n} from \"fs\";\nimport { readdir, readFile, stat } from \"fs/promises\";\nimport { join, resolve } from \"path\";\nimport { getAgentDir as getDefaultAgentDir, getSessionsDir } from \"../config.js\";\nimport {\n\ttype BashExecutionMessage,\n\ttype CustomMessage,\n\tcreateBranchSummaryMessage,\n\tcreateCompactionSummaryMessage,\n\tcreateCustomMessage,\n} from \"./messages.js\";\n\nexport const CURRENT_SESSION_VERSION = 3;\n\nexport interface SessionHeader {\n\ttype: \"session\";\n\tversion?: number; // v1 sessions don't have this\n\tid: string;\n\ttimestamp: string;\n\tcwd: string;\n\tparentSession?: string;\n\t/** Agent type that executed this session (set for subagent child processes via --agent-type) */\n\tagentType?: string;\n}\n\nexport interface NewSessionOptions {\n\tid?: string;\n\tparentSession?: string;\n}\n\nexport interface SessionEntryBase {\n\ttype: string;\n\tid: string;\n\tparentId: string | null;\n\ttimestamp: string;\n}\n\nexport interface SessionMessageEntry extends SessionEntryBase {\n\ttype: \"message\";\n\tmessage: AgentMessage;\n}\n\nexport interface ThinkingLevelChangeEntry extends SessionEntryBase {\n\ttype: \"thinking_level_change\";\n\tthinkingLevel: string;\n}\n\nexport interface ModelChangeEntry extends SessionEntryBase {\n\ttype: \"model_change\";\n\tprovider: string;\n\tmodelId: string;\n}\n\nexport interface CompactionEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"compaction\";\n\tsummary: string;\n\tfirstKeptEntryId: string;\n\ttokensBefore: number;\n\t/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */\n\tdetails?: T;\n\t/** True if generated by an extension, undefined/false if dreb-generated (backward compatible) */\n\tfromHook?: boolean;\n}\n\nexport interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"branch_summary\";\n\tfromId: string;\n\tsummary: string;\n\t/** Extension-specific data (not sent to LLM) */\n\tdetails?: T;\n\t/** True if generated by an extension, false if dreb-generated */\n\tfromHook?: boolean;\n}\n\n/**\n * Custom entry for extensions to store extension-specific data in the session.\n * Use customType to identify your extension's entries.\n *\n * Purpose: Persist extension state across session reloads. On reload, extensions can\n * scan entries for their customType and reconstruct internal state.\n *\n * Does NOT participate in LLM context (ignored by buildSessionContext).\n * For injecting content into context, see CustomMessageEntry.\n */\nexport interface CustomEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"custom\";\n\tcustomType: string;\n\tdata?: T;\n}\n\n/** Label entry for user-defined bookmarks/markers on entries. */\nexport interface LabelEntry extends SessionEntryBase {\n\ttype: \"label\";\n\ttargetId: string;\n\tlabel: string | undefined;\n}\n\n/** Session metadata entry (e.g., user-defined display name). */\nexport interface SessionInfoEntry extends SessionEntryBase {\n\ttype: \"session_info\";\n\tname?: string;\n}\n\n/**\n * Custom message entry for extensions to inject messages into LLM context.\n * Use customType to identify your extension's entries.\n *\n * Unlike CustomEntry, this DOES participate in LLM context.\n * The content is converted to a user message in buildSessionContext().\n * Use details for extension-specific metadata (not sent to LLM).\n *\n * display controls TUI rendering:\n * - false: hidden entirely\n * - true: rendered with distinct styling (different from user messages)\n */\nexport interface CustomMessageEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"custom_message\";\n\tcustomType: string;\n\tcontent: string | (TextContent | ImageContent)[];\n\tdetails?: T;\n\tdisplay: boolean;\n}\n\n/** Session entry - has id/parentId for tree structure (returned by \"read\" methods in SessionManager) */\nexport type SessionEntry =\n\t| SessionMessageEntry\n\t| ThinkingLevelChangeEntry\n\t| ModelChangeEntry\n\t| CompactionEntry\n\t| BranchSummaryEntry\n\t| CustomEntry\n\t| CustomMessageEntry\n\t| LabelEntry\n\t| SessionInfoEntry;\n\n/** Raw file entry (includes header) */\nexport type FileEntry = SessionHeader | SessionEntry;\n\n/** Tree node for getTree() - defensive copy of session structure */\nexport interface SessionTreeNode {\n\tentry: SessionEntry;\n\tchildren: SessionTreeNode[];\n\t/** Resolved label for this entry, if any */\n\tlabel?: string;\n}\n\nexport interface SessionContext {\n\tmessages: AgentMessage[];\n\tthinkingLevel: string;\n\tmodel: { provider: string; modelId: string } | null;\n}\n\nexport interface SessionInfo {\n\tpath: string;\n\tid: string;\n\t/** Working directory where the session was started. Empty string for old sessions. */\n\tcwd: string;\n\t/** User-defined display name from session_info entries. */\n\tname?: string;\n\t/** Path to the parent session (if this session was forked). */\n\tparentSessionPath?: string;\n\tcreated: Date;\n\tmodified: Date;\n\tmessageCount: number;\n\tfirstMessage: string;\n\tallMessagesText: string;\n}\n\nexport type ReadonlySessionManager = Pick<\n\tSessionManager,\n\t| \"getCwd\"\n\t| \"getSessionDir\"\n\t| \"getSessionId\"\n\t| \"getSessionFile\"\n\t| \"getLeafId\"\n\t| \"getLeafEntry\"\n\t| \"getEntry\"\n\t| \"getLabel\"\n\t| \"getBranch\"\n\t| \"getHeader\"\n\t| \"getEntries\"\n\t| \"getTree\"\n\t| \"getSessionName\"\n>;\n\n/** Generate a unique short ID (8 hex chars, collision-checked) */\nfunction generateId(byId: { has(id: string): boolean }): string {\n\tfor (let i = 0; i < 100; i++) {\n\t\tconst id = randomUUID().slice(0, 8);\n\t\tif (!byId.has(id)) return id;\n\t}\n\t// Fallback to full UUID if somehow we have collisions\n\treturn randomUUID();\n}\n\n/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */\nfunction migrateV1ToV2(entries: FileEntry[]): void {\n\tconst ids = new Set<string>();\n\tlet prevId: string | null = null;\n\n\tfor (const entry of entries) {\n\t\tif (entry.type === \"session\") {\n\t\t\tentry.version = 2;\n\t\t\tcontinue;\n\t\t}\n\n\t\tentry.id = generateId(ids);\n\t\tentry.parentId = prevId;\n\t\tprevId = entry.id;\n\n\t\t// Convert firstKeptEntryIndex to firstKeptEntryId for compaction\n\t\tif (entry.type === \"compaction\") {\n\t\t\tconst comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };\n\t\t\tif (typeof comp.firstKeptEntryIndex === \"number\") {\n\t\t\t\tconst targetEntry = entries[comp.firstKeptEntryIndex];\n\t\t\t\tif (targetEntry && targetEntry.type !== \"session\") {\n\t\t\t\t\tcomp.firstKeptEntryId = targetEntry.id;\n\t\t\t\t}\n\t\t\t\tdelete comp.firstKeptEntryIndex;\n\t\t\t}\n\t\t}\n\t}\n}\n\n/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */\nfunction migrateV2ToV3(entries: FileEntry[]): void {\n\tfor (const entry of entries) {\n\t\tif (entry.type === \"session\") {\n\t\t\tentry.version = 3;\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Update message entries with hookMessage role\n\t\tif (entry.type === \"message\") {\n\t\t\tconst msgEntry = entry as SessionMessageEntry;\n\t\t\tif (msgEntry.message && (msgEntry.message as { role: string }).role === \"hookMessage\") {\n\t\t\t\t(msgEntry.message as { role: string }).role = \"custom\";\n\t\t\t}\n\t\t}\n\t}\n}\n\n/**\n * Run all necessary migrations to bring entries to current version.\n * Mutates entries in place. Returns true if any migration was applied.\n */\nfunction migrateToCurrentVersion(entries: FileEntry[]): boolean {\n\tconst header = entries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\tconst version = header?.version ?? 1;\n\n\tif (version >= CURRENT_SESSION_VERSION) return false;\n\n\tif (version < 2) migrateV1ToV2(entries);\n\tif (version < 3) migrateV2ToV3(entries);\n\n\treturn true;\n}\n\n/** Exported for testing */\nexport function migrateSessionEntries(entries: FileEntry[]): void {\n\tmigrateToCurrentVersion(entries);\n}\n\n/** Exported for compaction.test.ts */\nexport function parseSessionEntries(content: string): FileEntry[] {\n\tconst entries: FileEntry[] = [];\n\tconst lines = content.trim().split(\"\\n\");\n\n\tfor (const line of lines) {\n\t\tif (!line.trim()) continue;\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line) as FileEntry;\n\t\t\tentries.push(entry);\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\n\treturn entries;\n}\n\nexport function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tif (entries[i].type === \"compaction\") {\n\t\t\treturn entries[i] as CompactionEntry;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Build the session context from entries using tree traversal.\n * If leafId is provided, walks from that entry to root.\n * Handles compaction and branch summaries along the path.\n */\nexport function buildSessionContext(\n\tentries: SessionEntry[],\n\tleafId?: string | null,\n\tbyId?: Map<string, SessionEntry>,\n): SessionContext {\n\t// Build uuid index if not available\n\tif (!byId) {\n\t\tbyId = new Map<string, SessionEntry>();\n\t\tfor (const entry of entries) {\n\t\t\tbyId.set(entry.id, entry);\n\t\t}\n\t}\n\n\t// Find leaf\n\tlet leaf: SessionEntry | undefined;\n\tif (leafId === null) {\n\t\t// Explicitly null - return no messages (navigated to before first entry)\n\t\treturn { messages: [], thinkingLevel: \"off\", model: null };\n\t}\n\tif (leafId) {\n\t\tleaf = byId.get(leafId);\n\t}\n\tif (!leaf) {\n\t\t// Fallback to last entry (when leafId is undefined)\n\t\tleaf = entries[entries.length - 1];\n\t}\n\n\tif (!leaf) {\n\t\treturn { messages: [], thinkingLevel: \"off\", model: null };\n\t}\n\n\t// Walk from leaf to root, collecting path\n\tconst path: SessionEntry[] = [];\n\tlet current: SessionEntry | undefined = leaf;\n\twhile (current) {\n\t\tpath.unshift(current);\n\t\tcurrent = current.parentId ? byId.get(current.parentId) : undefined;\n\t}\n\n\t// Extract settings and find compaction\n\tlet thinkingLevel = \"off\";\n\tlet model: { provider: string; modelId: string } | null = null;\n\tlet compaction: CompactionEntry | null = null;\n\n\tfor (const entry of path) {\n\t\tif (entry.type === \"thinking_level_change\") {\n\t\t\tthinkingLevel = entry.thinkingLevel;\n\t\t} else if (entry.type === \"model_change\") {\n\t\t\tmodel = { provider: entry.provider, modelId: entry.modelId };\n\t\t} else if (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\tmodel = { provider: entry.message.provider, modelId: entry.message.model };\n\t\t} else if (entry.type === \"compaction\") {\n\t\t\tcompaction = entry;\n\t\t}\n\t}\n\n\t// Build messages and collect corresponding entries\n\t// When there's a compaction, we need to:\n\t// 1. Emit summary first (entry = compaction)\n\t// 2. Emit kept messages (from firstKeptEntryId up to compaction)\n\t// 3. Emit messages after compaction\n\tconst messages: AgentMessage[] = [];\n\n\tconst appendMessage = (entry: SessionEntry) => {\n\t\tif (entry.type === \"message\") {\n\t\t\tmessages.push(entry.message);\n\t\t} else if (entry.type === \"custom_message\") {\n\t\t\tmessages.push(\n\t\t\t\tcreateCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),\n\t\t\t);\n\t\t} else if (entry.type === \"branch_summary\" && entry.summary) {\n\t\t\tmessages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));\n\t\t}\n\t};\n\n\tif (compaction) {\n\t\t// Emit summary first\n\t\tmessages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp));\n\n\t\t// Find compaction index in path\n\t\tconst compactionIdx = path.findIndex((e) => e.type === \"compaction\" && e.id === compaction.id);\n\n\t\t// Emit kept messages (before compaction, starting from firstKeptEntryId)\n\t\tlet foundFirstKept = false;\n\t\tfor (let i = 0; i < compactionIdx; i++) {\n\t\t\tconst entry = path[i];\n\t\t\tif (entry.id === compaction.firstKeptEntryId) {\n\t\t\t\tfoundFirstKept = true;\n\t\t\t}\n\t\t\tif (foundFirstKept) {\n\t\t\t\tappendMessage(entry);\n\t\t\t}\n\t\t}\n\n\t\t// Emit messages after compaction\n\t\tfor (let i = compactionIdx + 1; i < path.length; i++) {\n\t\t\tconst entry = path[i];\n\t\t\tappendMessage(entry);\n\t\t}\n\t} else {\n\t\t// No compaction - emit all messages, handle branch summaries and custom messages\n\t\tfor (const entry of path) {\n\t\t\tappendMessage(entry);\n\t\t}\n\t}\n\n\treturn { messages, thinkingLevel, model };\n}\n\n/**\n * Compute the default session directory for a cwd.\n * Encodes cwd into a safe directory name under ~/.dreb/agent/sessions/.\n */\nexport function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string {\n\tconst safePath = `--${cwd.replace(/^[/\\\\]/, \"\").replace(/[/\\\\:]/g, \"-\")}--`;\n\tconst sessionDir = join(agentDir, \"sessions\", safePath);\n\tif (!existsSync(sessionDir)) {\n\t\tmkdirSync(sessionDir, { recursive: true });\n\t}\n\treturn sessionDir;\n}\n\n/** Exported for testing */\nexport function loadEntriesFromFile(filePath: string): FileEntry[] {\n\tif (!existsSync(filePath)) return [];\n\n\tconst content = readFileSync(filePath, \"utf8\");\n\tconst entries: FileEntry[] = [];\n\tconst lines = content.trim().split(\"\\n\");\n\n\tfor (const line of lines) {\n\t\tif (!line.trim()) continue;\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line) as FileEntry;\n\t\t\tentries.push(entry);\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\n\t// Validate session header\n\tif (entries.length === 0) return entries;\n\tconst header = entries[0];\n\tif (header.type !== \"session\" || typeof (header as any).id !== \"string\") {\n\t\treturn [];\n\t}\n\n\treturn entries;\n}\n\nfunction isValidSessionFile(filePath: string): boolean {\n\ttry {\n\t\tconst fd = openSync(filePath, \"r\");\n\t\tconst buffer = Buffer.alloc(512);\n\t\tconst bytesRead = readSync(fd, buffer, 0, 512, 0);\n\t\tcloseSync(fd);\n\t\tconst firstLine = buffer.toString(\"utf8\", 0, bytesRead).split(\"\\n\")[0];\n\t\tif (!firstLine) return false;\n\t\tconst header = JSON.parse(firstLine);\n\t\treturn header.type === \"session\" && typeof header.id === \"string\";\n\t} catch {\n\t\t/* Not a valid session file (corrupt, not JSON) */\n\t\treturn false;\n\t}\n}\n\n/** Exported for testing */\nexport function findMostRecentSession(sessionDir: string): string | null {\n\ttry {\n\t\tconst files = readdirSync(sessionDir)\n\t\t\t.filter((f) => f.endsWith(\".jsonl\"))\n\t\t\t.map((f) => join(sessionDir, f))\n\t\t\t.filter(isValidSessionFile)\n\t\t\t.map((path) => ({ path, mtime: statSync(path).mtime }))\n\t\t\t.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());\n\n\t\treturn files[0]?.path || null;\n\t} catch {\n\t\t/* Session directory may not exist or be unreadable */\n\t\treturn null;\n\t}\n}\n\nfunction isMessageWithContent(message: AgentMessage): message is Message {\n\treturn typeof (message as Message).role === \"string\" && \"content\" in message;\n}\n\nfunction extractTextContent(message: Message): string {\n\tconst content = message.content;\n\tif (typeof content === \"string\") {\n\t\treturn content;\n\t}\n\treturn content\n\t\t.filter((block): block is TextContent => block.type === \"text\")\n\t\t.map((block) => block.text)\n\t\t.join(\" \");\n}\n\nfunction getLastActivityTime(entries: FileEntry[]): number | undefined {\n\tlet lastActivityTime: number | undefined;\n\n\tfor (const entry of entries) {\n\t\tif (entry.type !== \"message\") continue;\n\n\t\tconst message = (entry as SessionMessageEntry).message;\n\t\tif (!isMessageWithContent(message)) continue;\n\t\tif (message.role !== \"user\" && message.role !== \"assistant\") continue;\n\n\t\tconst msgTimestamp = (message as { timestamp?: number }).timestamp;\n\t\tif (typeof msgTimestamp === \"number\") {\n\t\t\tlastActivityTime = Math.max(lastActivityTime ?? 0, msgTimestamp);\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst entryTimestamp = (entry as SessionEntryBase).timestamp;\n\t\tif (typeof entryTimestamp === \"string\") {\n\t\t\tconst t = new Date(entryTimestamp).getTime();\n\t\t\tif (!Number.isNaN(t)) {\n\t\t\t\tlastActivityTime = Math.max(lastActivityTime ?? 0, t);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn lastActivityTime;\n}\n\nfunction getSessionModifiedDate(entries: FileEntry[], header: SessionHeader, statsMtime: Date): Date {\n\tconst lastActivityTime = getLastActivityTime(entries);\n\tif (typeof lastActivityTime === \"number\" && lastActivityTime > 0) {\n\t\treturn new Date(lastActivityTime);\n\t}\n\n\tconst headerTime = typeof header.timestamp === \"string\" ? new Date(header.timestamp).getTime() : NaN;\n\treturn !Number.isNaN(headerTime) ? new Date(headerTime) : statsMtime;\n}\n\nasync function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {\n\ttry {\n\t\tconst content = await readFile(filePath, \"utf8\");\n\t\tconst entries: FileEntry[] = [];\n\t\tconst lines = content.trim().split(\"\\n\");\n\n\t\tfor (const line of lines) {\n\t\t\tif (!line.trim()) continue;\n\t\t\ttry {\n\t\t\t\tentries.push(JSON.parse(line) as FileEntry);\n\t\t\t} catch {\n\t\t\t\t// Skip malformed lines\n\t\t\t}\n\t\t}\n\n\t\tif (entries.length === 0) return null;\n\t\tconst header = entries[0];\n\t\tif (header.type !== \"session\") return null;\n\n\t\tconst stats = await stat(filePath);\n\t\tlet messageCount = 0;\n\t\tlet firstMessage = \"\";\n\t\tconst allMessages: string[] = [];\n\t\tlet name: string | undefined;\n\n\t\tfor (const entry of entries) {\n\t\t\t// Extract session name (use latest, including explicit clears)\n\t\t\tif (entry.type === \"session_info\") {\n\t\t\t\tconst infoEntry = entry as SessionInfoEntry;\n\t\t\t\tname = infoEntry.name?.trim() || undefined;\n\t\t\t}\n\n\t\t\tif (entry.type !== \"message\") continue;\n\t\t\tmessageCount++;\n\n\t\t\tconst message = (entry as SessionMessageEntry).message;\n\t\t\tif (!isMessageWithContent(message)) continue;\n\t\t\tif (message.role !== \"user\" && message.role !== \"assistant\") continue;\n\n\t\t\tconst textContent = extractTextContent(message);\n\t\t\tif (!textContent) continue;\n\n\t\t\tallMessages.push(textContent);\n\t\t\tif (!firstMessage && message.role === \"user\") {\n\t\t\t\tfirstMessage = textContent;\n\t\t\t}\n\t\t}\n\n\t\tconst cwd = typeof (header as SessionHeader).cwd === \"string\" ? (header as SessionHeader).cwd : \"\";\n\t\tconst parentSessionPath = (header as SessionHeader).parentSession;\n\n\t\tconst modified = getSessionModifiedDate(entries, header as SessionHeader, stats.mtime);\n\n\t\treturn {\n\t\t\tpath: filePath,\n\t\t\tid: (header as SessionHeader).id,\n\t\t\tcwd,\n\t\t\tname,\n\t\t\tparentSessionPath,\n\t\t\tcreated: new Date((header as SessionHeader).timestamp),\n\t\t\tmodified,\n\t\t\tmessageCount,\n\t\t\tfirstMessage: firstMessage || \"(no messages)\",\n\t\t\tallMessagesText: allMessages.join(\" \"),\n\t\t};\n\t} catch {\n\t\t/* Corrupt session file — exclude from listing */\n\t\treturn null;\n\t}\n}\n\nexport type SessionListProgress = (loaded: number, total: number) => void;\n\nasync function listSessionsFromDir(\n\tdir: string,\n\tonProgress?: SessionListProgress,\n\tprogressOffset = 0,\n\tprogressTotal?: number,\n): Promise<SessionInfo[]> {\n\tconst sessions: SessionInfo[] = [];\n\tif (!existsSync(dir)) {\n\t\treturn sessions;\n\t}\n\n\ttry {\n\t\tconst dirEntries = await readdir(dir);\n\t\tconst files = dirEntries.filter((f) => f.endsWith(\".jsonl\")).map((f) => join(dir, f));\n\t\tconst total = progressTotal ?? files.length;\n\n\t\tlet loaded = 0;\n\t\tconst results = await Promise.all(\n\t\t\tfiles.map(async (file) => {\n\t\t\t\tconst info = await buildSessionInfo(file);\n\t\t\t\tloaded++;\n\t\t\t\tonProgress?.(progressOffset + loaded, total);\n\t\t\t\treturn info;\n\t\t\t}),\n\t\t);\n\t\tfor (const info of results) {\n\t\t\tif (info) {\n\t\t\t\tsessions.push(info);\n\t\t\t}\n\t\t}\n\t} catch {\n\t\t/* Session directory read failed — return partial results */\n\t}\n\n\treturn sessions;\n}\n\n/**\n * Manages conversation sessions as append-only trees stored in JSONL files.\n *\n * Each session entry has an id and parentId forming a tree structure. The \"leaf\"\n * pointer tracks the current position. Appending creates a child of the current leaf.\n * Branching moves the leaf to an earlier entry, allowing new branches without\n * modifying history.\n *\n * Use buildSessionContext() to get the resolved message list for the LLM, which\n * handles compaction summaries and follows the path from root to current leaf.\n */\nexport class SessionManager {\n\tprivate sessionId: string = \"\";\n\tprivate sessionFile: string | undefined;\n\tprivate sessionDir: string;\n\tprivate cwd: string;\n\tprivate persist: boolean;\n\tprivate flushed: boolean = false;\n\tprivate fileEntries: FileEntry[] = [];\n\tprivate byId: Map<string, SessionEntry> = new Map();\n\tprivate labelsById: Map<string, string> = new Map();\n\tprivate leafId: string | null = null;\n\n\tprivate constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {\n\t\tthis.cwd = cwd;\n\t\tthis.sessionDir = sessionDir;\n\t\tthis.persist = persist;\n\t\tif (persist && sessionDir && !existsSync(sessionDir)) {\n\t\t\tmkdirSync(sessionDir, { recursive: true });\n\t\t}\n\n\t\tif (sessionFile) {\n\t\t\tthis.setSessionFile(sessionFile);\n\t\t} else {\n\t\t\tthis.newSession();\n\t\t}\n\t}\n\n\t/** Switch to a different session file (used for resume and branching) */\n\tsetSessionFile(sessionFile: string): void {\n\t\tthis.sessionFile = resolve(sessionFile);\n\t\tif (existsSync(this.sessionFile)) {\n\t\t\tthis.fileEntries = loadEntriesFromFile(this.sessionFile);\n\n\t\t\t// If file was empty or corrupted (no valid header), truncate and start fresh\n\t\t\t// to avoid appending messages without a session header (which breaks the session)\n\t\t\tif (this.fileEntries.length === 0) {\n\t\t\t\tconst explicitPath = this.sessionFile;\n\t\t\t\tthis.newSession();\n\t\t\t\tthis.sessionFile = explicitPath;\n\t\t\t\tthis._rewriteFile();\n\t\t\t\tthis.flushed = true;\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst header = this.fileEntries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\t\t\tthis.sessionId = header?.id ?? randomUUID();\n\n\t\t\tif (migrateToCurrentVersion(this.fileEntries)) {\n\t\t\t\tthis._rewriteFile();\n\t\t\t}\n\n\t\t\tthis._buildIndex();\n\t\t\tthis.flushed = true;\n\t\t} else {\n\t\t\tconst explicitPath = this.sessionFile;\n\t\t\tthis.newSession();\n\t\t\tthis.sessionFile = explicitPath; // preserve explicit path from --session flag\n\t\t}\n\t}\n\n\tnewSession(options?: NewSessionOptions): string | undefined {\n\t\tthis.sessionId = options?.id ?? randomUUID();\n\t\tconst timestamp = new Date().toISOString();\n\t\tconst header: SessionHeader = {\n\t\t\ttype: \"session\",\n\t\t\tversion: CURRENT_SESSION_VERSION,\n\t\t\tid: this.sessionId,\n\t\t\ttimestamp,\n\t\t\tcwd: this.cwd,\n\t\t\tparentSession: options?.parentSession,\n\t\t};\n\t\tthis.fileEntries = [header];\n\t\tthis.byId.clear();\n\t\tthis.labelsById.clear();\n\t\tthis.leafId = null;\n\t\tthis.flushed = false;\n\n\t\tif (this.persist) {\n\t\t\tconst fileTimestamp = timestamp.replace(/[:.]/g, \"-\");\n\t\t\tthis.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);\n\t\t}\n\t\treturn this.sessionFile;\n\t}\n\n\t/**\n\t * Set agent type metadata on the current session header.\n\t * Must be called before the first flush (i.e., before the first assistant message).\n\t * Used by subagent child processes to record which agent definition executed the session.\n\t */\n\tsetAgentType(agentType: string): void {\n\t\tconst header = this.fileEntries[0] as SessionHeader | undefined;\n\t\tif (header?.type === \"session\") {\n\t\t\theader.agentType = agentType;\n\t\t}\n\t}\n\n\tprivate _buildIndex(): void {\n\t\tthis.byId.clear();\n\t\tthis.labelsById.clear();\n\t\tthis.leafId = null;\n\t\tfor (const entry of this.fileEntries) {\n\t\t\tif (entry.type === \"session\") continue;\n\t\t\tthis.byId.set(entry.id, entry);\n\t\t\tthis.leafId = entry.id;\n\t\t\tif (entry.type === \"label\") {\n\t\t\t\tif (entry.label) {\n\t\t\t\t\tthis.labelsById.set(entry.targetId, entry.label);\n\t\t\t\t} else {\n\t\t\t\t\tthis.labelsById.delete(entry.targetId);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate _rewriteFile(): void {\n\t\tif (!this.persist || !this.sessionFile) return;\n\t\tconst content = `${this.fileEntries.map((e) => JSON.stringify(e)).join(\"\\n\")}\\n`;\n\t\twriteFileSync(this.sessionFile, content);\n\t}\n\n\tisPersisted(): boolean {\n\t\treturn this.persist;\n\t}\n\n\tgetCwd(): string {\n\t\treturn this.cwd;\n\t}\n\n\tgetSessionDir(): string {\n\t\treturn this.sessionDir;\n\t}\n\n\tgetSessionId(): string {\n\t\treturn this.sessionId;\n\t}\n\n\tgetSessionFile(): string | undefined {\n\t\treturn this.sessionFile;\n\t}\n\n\t_persist(entry: SessionEntry): void {\n\t\tif (!this.persist || !this.sessionFile) return;\n\n\t\tconst hasAssistant = this.fileEntries.some((e) => e.type === \"message\" && e.message.role === \"assistant\");\n\t\tif (!hasAssistant) {\n\t\t\t// Mark as not flushed so when assistant arrives, all entries get written\n\t\t\tthis.flushed = false;\n\t\t\treturn;\n\t\t}\n\n\t\tif (!this.flushed) {\n\t\t\tfor (const e of this.fileEntries) {\n\t\t\t\tappendFileSync(this.sessionFile, `${JSON.stringify(e)}\\n`);\n\t\t\t}\n\t\t\tthis.flushed = true;\n\t\t} else {\n\t\t\tappendFileSync(this.sessionFile, `${JSON.stringify(entry)}\\n`);\n\t\t}\n\t}\n\n\tprivate _appendEntry(entry: SessionEntry): void {\n\t\tthis.fileEntries.push(entry);\n\t\tthis.byId.set(entry.id, entry);\n\t\tthis.leafId = entry.id;\n\t\tthis._persist(entry);\n\t}\n\n\t/** Append a message as child of current leaf, then advance leaf. Returns entry id.\n\t * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.\n\t * Reason: we want these to be top-level entries in the session, not message session entries,\n\t * so it is easier to find them.\n\t * These need to be appended via appendCompaction() and appendBranchSummary() methods.\n\t */\n\tappendMessage(message: Message | CustomMessage | BashExecutionMessage): string {\n\t\tconst entry: SessionMessageEntry = {\n\t\t\ttype: \"message\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tmessage,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */\n\tappendThinkingLevelChange(thinkingLevel: string): string {\n\t\tconst entry: ThinkingLevelChangeEntry = {\n\t\t\ttype: \"thinking_level_change\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tthinkingLevel,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a model change as child of current leaf, then advance leaf. Returns entry id. */\n\tappendModelChange(provider: string, modelId: string): string {\n\t\tconst entry: ModelChangeEntry = {\n\t\t\ttype: \"model_change\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tprovider,\n\t\t\tmodelId,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */\n\tappendCompaction<T = unknown>(\n\t\tsummary: string,\n\t\tfirstKeptEntryId: string,\n\t\ttokensBefore: number,\n\t\tdetails?: T,\n\t\tfromHook?: boolean,\n\t): string {\n\t\tconst entry: CompactionEntry<T> = {\n\t\t\ttype: \"compaction\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tsummary,\n\t\t\tfirstKeptEntryId,\n\t\t\ttokensBefore,\n\t\t\tdetails,\n\t\t\tfromHook,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */\n\tappendCustomEntry(customType: string, data?: unknown): string {\n\t\tconst entry: CustomEntry = {\n\t\t\ttype: \"custom\",\n\t\t\tcustomType,\n\t\t\tdata,\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a session info entry (e.g., display name). Returns entry id. */\n\tappendSessionInfo(name: string): string {\n\t\tconst entry: SessionInfoEntry = {\n\t\t\ttype: \"session_info\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tname: name.trim(),\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Get the current session name from the latest session_info entry, if any. */\n\tgetSessionName(): string | undefined {\n\t\t// Walk entries in reverse to find the latest session_info entry.\n\t\t// Empty names explicitly clear the session title.\n\t\tconst entries = this.getEntries();\n\t\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\t\tconst entry = entries[i];\n\t\t\tif (entry.type === \"session_info\") {\n\t\t\t\treturn entry.name?.trim() || undefined;\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Append a custom message entry (for extensions) that participates in LLM context.\n\t * @param customType Extension identifier for filtering on reload\n\t * @param content Message content (string or TextContent/ImageContent array)\n\t * @param display Whether to show in TUI (true = styled display, false = hidden)\n\t * @param details Optional extension-specific metadata (not sent to LLM)\n\t * @returns Entry id\n\t */\n\tappendCustomMessageEntry<T = unknown>(\n\t\tcustomType: string,\n\t\tcontent: string | (TextContent | ImageContent)[],\n\t\tdisplay: boolean,\n\t\tdetails?: T,\n\t): string {\n\t\tconst entry: CustomMessageEntry<T> = {\n\t\t\ttype: \"custom_message\",\n\t\t\tcustomType,\n\t\t\tcontent,\n\t\t\tdisplay,\n\t\t\tdetails,\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t// =========================================================================\n\t// Tree Traversal\n\t// =========================================================================\n\n\tgetLeafId(): string | null {\n\t\treturn this.leafId;\n\t}\n\n\tgetLeafEntry(): SessionEntry | undefined {\n\t\treturn this.leafId ? this.byId.get(this.leafId) : undefined;\n\t}\n\n\tgetEntry(id: string): SessionEntry | undefined {\n\t\treturn this.byId.get(id);\n\t}\n\n\t/**\n\t * Get all direct children of an entry.\n\t */\n\tgetChildren(parentId: string): SessionEntry[] {\n\t\tconst children: SessionEntry[] = [];\n\t\tfor (const entry of this.byId.values()) {\n\t\t\tif (entry.parentId === parentId) {\n\t\t\t\tchildren.push(entry);\n\t\t\t}\n\t\t}\n\t\treturn children;\n\t}\n\n\t/**\n\t * Get the label for an entry, if any.\n\t */\n\tgetLabel(id: string): string | undefined {\n\t\treturn this.labelsById.get(id);\n\t}\n\n\t/**\n\t * Set or clear a label on an entry.\n\t * Labels are user-defined markers for bookmarking/navigation.\n\t * Pass undefined or empty string to clear the label.\n\t */\n\tappendLabelChange(targetId: string, label: string | undefined): string {\n\t\tif (!this.byId.has(targetId)) {\n\t\t\tthrow new Error(`Entry ${targetId} not found`);\n\t\t}\n\t\tconst entry: LabelEntry = {\n\t\t\ttype: \"label\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\ttargetId,\n\t\t\tlabel,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\tif (label) {\n\t\t\tthis.labelsById.set(targetId, label);\n\t\t} else {\n\t\t\tthis.labelsById.delete(targetId);\n\t\t}\n\t\treturn entry.id;\n\t}\n\n\t/**\n\t * Walk from entry to root, returning all entries in path order.\n\t * Includes all entry types (messages, compaction, model changes, etc.).\n\t * Use buildSessionContext() to get the resolved messages for the LLM.\n\t */\n\tgetBranch(fromId?: string): SessionEntry[] {\n\t\tconst path: SessionEntry[] = [];\n\t\tconst startId = fromId ?? this.leafId;\n\t\tlet current = startId ? this.byId.get(startId) : undefined;\n\t\twhile (current) {\n\t\t\tpath.unshift(current);\n\t\t\tcurrent = current.parentId ? this.byId.get(current.parentId) : undefined;\n\t\t}\n\t\treturn path;\n\t}\n\n\t/**\n\t * Build the session context (what gets sent to the LLM).\n\t * Uses tree traversal from current leaf.\n\t */\n\tbuildSessionContext(): SessionContext {\n\t\treturn buildSessionContext(this.getEntries(), this.leafId, this.byId);\n\t}\n\n\t/**\n\t * Get session header.\n\t */\n\tgetHeader(): SessionHeader | null {\n\t\tconst h = this.fileEntries.find((e) => e.type === \"session\");\n\t\treturn h ? (h as SessionHeader) : null;\n\t}\n\n\t/**\n\t * Get all session entries (excludes header). Returns a shallow copy.\n\t * The session is append-only: use appendXXX() to add entries, branch() to\n\t * change the leaf pointer. Entries cannot be modified or deleted.\n\t */\n\tgetEntries(): SessionEntry[] {\n\t\treturn this.fileEntries.filter((e): e is SessionEntry => e.type !== \"session\");\n\t}\n\n\t/**\n\t * Get the session as a tree structure. Returns a shallow defensive copy of all entries.\n\t * A well-formed session has exactly one root (first entry with parentId === null).\n\t * Orphaned entries (broken parent chain) are also returned as roots.\n\t */\n\tgetTree(): SessionTreeNode[] {\n\t\tconst entries = this.getEntries();\n\t\tconst nodeMap = new Map<string, SessionTreeNode>();\n\t\tconst roots: SessionTreeNode[] = [];\n\n\t\t// Create nodes with resolved labels\n\t\tfor (const entry of entries) {\n\t\t\tconst label = this.labelsById.get(entry.id);\n\t\t\tnodeMap.set(entry.id, { entry, children: [], label });\n\t\t}\n\n\t\t// Build tree\n\t\tfor (const entry of entries) {\n\t\t\tconst node = nodeMap.get(entry.id)!;\n\t\t\tif (entry.parentId === null || entry.parentId === entry.id) {\n\t\t\t\troots.push(node);\n\t\t\t} else {\n\t\t\t\tconst parent = nodeMap.get(entry.parentId);\n\t\t\t\tif (parent) {\n\t\t\t\t\tparent.children.push(node);\n\t\t\t\t} else {\n\t\t\t\t\t// Orphan - treat as root\n\t\t\t\t\troots.push(node);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Sort children by timestamp (oldest first, newest at bottom)\n\t\t// Use iterative approach to avoid stack overflow on deep trees\n\t\tconst stack: SessionTreeNode[] = [...roots];\n\t\twhile (stack.length > 0) {\n\t\t\tconst node = stack.pop()!;\n\t\t\tnode.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());\n\t\t\tstack.push(...node.children);\n\t\t}\n\n\t\treturn roots;\n\t}\n\n\t// =========================================================================\n\t// Branching\n\t// =========================================================================\n\n\t/**\n\t * Start a new branch from an earlier entry.\n\t * Moves the leaf pointer to the specified entry. The next appendXXX() call\n\t * will create a child of that entry, forming a new branch. Existing entries\n\t * are not modified or deleted.\n\t */\n\tbranch(branchFromId: string): void {\n\t\tif (!this.byId.has(branchFromId)) {\n\t\t\tthrow new Error(`Entry ${branchFromId} not found`);\n\t\t}\n\t\tthis.leafId = branchFromId;\n\t}\n\n\t/**\n\t * Reset the leaf pointer to null (before any entries).\n\t * The next appendXXX() call will create a new root entry (parentId = null).\n\t * Use this when navigating to re-edit the first user message.\n\t */\n\tresetLeaf(): void {\n\t\tthis.leafId = null;\n\t}\n\n\t/**\n\t * Start a new branch with a summary of the abandoned path.\n\t * Same as branch(), but also appends a branch_summary entry that captures\n\t * context from the abandoned conversation path.\n\t */\n\tbranchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean): string {\n\t\tif (branchFromId !== null && !this.byId.has(branchFromId)) {\n\t\t\tthrow new Error(`Entry ${branchFromId} not found`);\n\t\t}\n\t\tthis.leafId = branchFromId;\n\t\tconst entry: BranchSummaryEntry = {\n\t\t\ttype: \"branch_summary\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: branchFromId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tfromId: branchFromId ?? \"root\",\n\t\t\tsummary,\n\t\t\tdetails,\n\t\t\tfromHook,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/**\n\t * Create a new session file containing only the path from root to the specified leaf.\n\t * Useful for extracting a single conversation path from a branched session.\n\t * Returns the new session file path, or undefined if not persisting.\n\t */\n\tcreateBranchedSession(leafId: string): string | undefined {\n\t\tconst previousSessionFile = this.sessionFile;\n\t\tconst path = this.getBranch(leafId);\n\t\tif (path.length === 0) {\n\t\t\tthrow new Error(`Entry ${leafId} not found`);\n\t\t}\n\n\t\t// Filter out LabelEntry from path - we'll recreate them from the resolved map\n\t\tconst pathWithoutLabels = path.filter((e) => e.type !== \"label\");\n\n\t\tconst newSessionId = randomUUID();\n\t\tconst timestamp = new Date().toISOString();\n\t\tconst fileTimestamp = timestamp.replace(/[:.]/g, \"-\");\n\t\tconst newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);\n\n\t\tconst header: SessionHeader = {\n\t\t\ttype: \"session\",\n\t\t\tversion: CURRENT_SESSION_VERSION,\n\t\t\tid: newSessionId,\n\t\t\ttimestamp,\n\t\t\tcwd: this.cwd,\n\t\t\tparentSession: this.persist ? previousSessionFile : undefined,\n\t\t};\n\n\t\t// Collect labels for entries in the path\n\t\tconst pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id));\n\t\tconst labelsToWrite: Array<{ targetId: string; label: string }> = [];\n\t\tfor (const [targetId, label] of this.labelsById) {\n\t\t\tif (pathEntryIds.has(targetId)) {\n\t\t\t\tlabelsToWrite.push({ targetId, label });\n\t\t\t}\n\t\t}\n\n\t\tif (this.persist) {\n\t\t\t// Build label entries\n\t\t\tconst lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;\n\t\t\tlet parentId = lastEntryId;\n\t\t\tconst labelEntries: LabelEntry[] = [];\n\t\t\tfor (const { targetId, label } of labelsToWrite) {\n\t\t\t\tconst labelEntry: LabelEntry = {\n\t\t\t\t\ttype: \"label\",\n\t\t\t\t\tid: generateId(new Set(pathEntryIds)),\n\t\t\t\t\tparentId,\n\t\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t\t\ttargetId,\n\t\t\t\t\tlabel,\n\t\t\t\t};\n\t\t\t\tpathEntryIds.add(labelEntry.id);\n\t\t\t\tlabelEntries.push(labelEntry);\n\t\t\t\tparentId = labelEntry.id;\n\t\t\t}\n\n\t\t\tthis.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];\n\t\t\tthis.sessionId = newSessionId;\n\t\t\tthis.sessionFile = newSessionFile;\n\t\t\tthis._buildIndex();\n\n\t\t\t// Only write the file now if it contains an assistant message.\n\t\t\t// Otherwise defer to _persist(), which creates the file on the\n\t\t\t// first assistant response, matching the newSession() contract\n\t\t\t// and avoiding the duplicate-header bug when _persist()'s\n\t\t\t// no-assistant guard later resets flushed to false.\n\t\t\tconst hasAssistant = this.fileEntries.some((e) => e.type === \"message\" && e.message.role === \"assistant\");\n\t\t\tif (hasAssistant) {\n\t\t\t\tthis._rewriteFile();\n\t\t\t\tthis.flushed = true;\n\t\t\t} else {\n\t\t\t\tthis.flushed = false;\n\t\t\t}\n\n\t\t\treturn newSessionFile;\n\t\t}\n\n\t\t// In-memory mode: replace current session with the path + labels\n\t\tconst labelEntries: LabelEntry[] = [];\n\t\tlet parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;\n\t\tfor (const { targetId, label } of labelsToWrite) {\n\t\t\tconst labelEntry: LabelEntry = {\n\t\t\t\ttype: \"label\",\n\t\t\t\tid: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])),\n\t\t\t\tparentId,\n\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t\ttargetId,\n\t\t\t\tlabel,\n\t\t\t};\n\t\t\tlabelEntries.push(labelEntry);\n\t\t\tparentId = labelEntry.id;\n\t\t}\n\t\tthis.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];\n\t\tthis.sessionId = newSessionId;\n\t\tthis._buildIndex();\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Create a new session.\n\t * @param cwd Working directory (stored in session header)\n\t * @param sessionDir Optional session directory. If omitted, uses default (~/.dreb/agent/sessions/<encoded-cwd>/).\n\t */\n\tstatic create(cwd: string, sessionDir?: string): SessionManager {\n\t\tconst dir = sessionDir ?? getDefaultSessionDir(cwd);\n\t\treturn new SessionManager(cwd, dir, undefined, true);\n\t}\n\n\t/**\n\t * Open a specific session file.\n\t * @param path Path to session file\n\t * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.\n\t */\n\tstatic open(path: string, sessionDir?: string): SessionManager {\n\t\t// Extract cwd from session header if possible, otherwise use process.cwd()\n\t\tconst entries = loadEntriesFromFile(path);\n\t\tconst header = entries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\t\tconst cwd = header?.cwd ?? process.cwd();\n\t\t// If no sessionDir provided, derive from file's parent directory\n\t\tconst dir = sessionDir ?? resolve(path, \"..\");\n\t\treturn new SessionManager(cwd, dir, path, true);\n\t}\n\n\t/**\n\t * Continue the most recent session, or create new if none.\n\t * @param cwd Working directory\n\t * @param sessionDir Optional session directory. If omitted, uses default (~/.dreb/agent/sessions/<encoded-cwd>/).\n\t */\n\tstatic continueRecent(cwd: string, sessionDir?: string): SessionManager {\n\t\tconst dir = sessionDir ?? getDefaultSessionDir(cwd);\n\t\tconst mostRecent = findMostRecentSession(dir);\n\t\tif (mostRecent) {\n\t\t\treturn new SessionManager(cwd, dir, mostRecent, true);\n\t\t}\n\t\treturn new SessionManager(cwd, dir, undefined, true);\n\t}\n\n\t/** Create an in-memory session (no file persistence) */\n\tstatic inMemory(cwd: string = process.cwd()): SessionManager {\n\t\treturn new SessionManager(cwd, \"\", undefined, false);\n\t}\n\n\t/**\n\t * Fork a session from another project directory into the current project.\n\t * Creates a new session in the target cwd with the full history from the source session.\n\t * @param sourcePath Path to the source session file\n\t * @param targetCwd Target working directory (where the new session will be stored)\n\t * @param sessionDir Optional session directory. If omitted, uses default for targetCwd.\n\t */\n\tstatic forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string): SessionManager {\n\t\tconst sourceEntries = loadEntriesFromFile(sourcePath);\n\t\tif (sourceEntries.length === 0) {\n\t\t\tthrow new Error(`Cannot fork: source session file is empty or invalid: ${sourcePath}`);\n\t\t}\n\n\t\tconst sourceHeader = sourceEntries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\t\tif (!sourceHeader) {\n\t\t\tthrow new Error(`Cannot fork: source session has no header: ${sourcePath}`);\n\t\t}\n\n\t\tconst dir = sessionDir ?? getDefaultSessionDir(targetCwd);\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true });\n\t\t}\n\n\t\t// Create new session file with new ID but forked content\n\t\tconst newSessionId = randomUUID();\n\t\tconst timestamp = new Date().toISOString();\n\t\tconst fileTimestamp = timestamp.replace(/[:.]/g, \"-\");\n\t\tconst newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`);\n\n\t\t// Write new header pointing to source as parent, with updated cwd\n\t\tconst newHeader: SessionHeader = {\n\t\t\ttype: \"session\",\n\t\t\tversion: CURRENT_SESSION_VERSION,\n\t\t\tid: newSessionId,\n\t\t\ttimestamp,\n\t\t\tcwd: targetCwd,\n\t\t\tparentSession: sourcePath,\n\t\t};\n\t\tappendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\\n`);\n\n\t\t// Copy all non-header entries from source\n\t\tfor (const entry of sourceEntries) {\n\t\t\tif (entry.type !== \"session\") {\n\t\t\t\tappendFileSync(newSessionFile, `${JSON.stringify(entry)}\\n`);\n\t\t\t}\n\t\t}\n\n\t\treturn new SessionManager(targetCwd, dir, newSessionFile, true);\n\t}\n\n\t/**\n\t * List all sessions for a directory.\n\t * @param cwd Working directory (used to compute default session directory)\n\t * @param sessionDir Optional session directory. If omitted, uses default (~/.dreb/agent/sessions/<encoded-cwd>/).\n\t * @param onProgress Optional callback for progress updates (loaded, total)\n\t */\n\tstatic async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]> {\n\t\tconst dir = sessionDir ?? getDefaultSessionDir(cwd);\n\t\tconst sessions = await listSessionsFromDir(dir, onProgress);\n\t\tsessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());\n\t\treturn sessions;\n\t}\n\n\t/**\n\t * List all sessions across all project directories.\n\t * @param onProgress Optional callback for progress updates (loaded, total)\n\t */\n\tstatic async listAll(onProgress?: SessionListProgress): Promise<SessionInfo[]> {\n\t\tconst sessionsDir = getSessionsDir();\n\n\t\ttry {\n\t\t\tif (!existsSync(sessionsDir)) {\n\t\t\t\treturn [];\n\t\t\t}\n\t\t\tconst entries = await readdir(sessionsDir, { withFileTypes: true });\n\t\t\tconst dirs = entries.filter((e) => e.isDirectory()).map((e) => join(sessionsDir, e.name));\n\n\t\t\t// Count total files first for accurate progress\n\t\t\tlet totalFiles = 0;\n\t\t\tconst dirFiles: string[][] = [];\n\t\t\tfor (const dir of dirs) {\n\t\t\t\ttry {\n\t\t\t\t\tconst files = (await readdir(dir)).filter((f) => f.endsWith(\".jsonl\"));\n\t\t\t\t\tdirFiles.push(files.map((f) => join(dir, f)));\n\t\t\t\t\ttotalFiles += files.length;\n\t\t\t\t} catch {\n\t\t\t\t\t/* Directory unreadable — skip */\n\t\t\t\t\tdirFiles.push([]);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Process all files with progress tracking\n\t\t\tlet loaded = 0;\n\t\t\tconst sessions: SessionInfo[] = [];\n\t\t\tconst allFiles = dirFiles.flat();\n\n\t\t\tconst results = await Promise.all(\n\t\t\t\tallFiles.map(async (file) => {\n\t\t\t\t\tconst info = await buildSessionInfo(file);\n\t\t\t\t\tloaded++;\n\t\t\t\t\tonProgress?.(loaded, totalFiles);\n\t\t\t\t\treturn info;\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tfor (const info of results) {\n\t\t\t\tif (info) {\n\t\t\t\t\tsessions.push(info);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tsessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());\n\t\t\treturn sessions;\n\t\t} catch {\n\t\t\t/* Session listing failed — return empty list */\n\t\t\treturn [];\n\t\t}\n\t}\n}\n"]}
|
|
@@ -501,6 +501,17 @@ export class SessionManager {
|
|
|
501
501
|
}
|
|
502
502
|
return this.sessionFile;
|
|
503
503
|
}
|
|
504
|
+
/**
|
|
505
|
+
* Set agent type metadata on the current session header.
|
|
506
|
+
* Must be called before the first flush (i.e., before the first assistant message).
|
|
507
|
+
* Used by subagent child processes to record which agent definition executed the session.
|
|
508
|
+
*/
|
|
509
|
+
setAgentType(agentType) {
|
|
510
|
+
const header = this.fileEntries[0];
|
|
511
|
+
if (header?.type === "session") {
|
|
512
|
+
header.agentType = agentType;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
504
515
|
_buildIndex() {
|
|
505
516
|
this.byId.clear();
|
|
506
517
|
this.labelsById.clear();
|