@dreb/coding-agent 2.64.1 → 2.64.3

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.
Files changed (34) hide show
  1. package/dist/core/agent-session.d.ts.map +1 -1
  2. package/dist/core/agent-session.js +5 -2
  3. package/dist/core/agent-session.js.map +1 -1
  4. package/dist/core/buddy/buddy-manager.d.ts +6 -2
  5. package/dist/core/buddy/buddy-manager.d.ts.map +1 -1
  6. package/dist/core/buddy/buddy-manager.js +10 -6
  7. package/dist/core/buddy/buddy-manager.js.map +1 -1
  8. package/dist/core/compaction/branch-summarization.d.ts +2 -0
  9. package/dist/core/compaction/branch-summarization.d.ts.map +1 -1
  10. package/dist/core/compaction/branch-summarization.js +2 -2
  11. package/dist/core/compaction/branch-summarization.js.map +1 -1
  12. package/dist/core/compaction/compaction.d.ts +2 -2
  13. package/dist/core/compaction/compaction.d.ts.map +1 -1
  14. package/dist/core/compaction/compaction.js +14 -10
  15. package/dist/core/compaction/compaction.js.map +1 -1
  16. package/dist/core/dispatch-arbiter.d.ts +2 -0
  17. package/dist/core/dispatch-arbiter.d.ts.map +1 -1
  18. package/dist/core/dispatch-arbiter.js +2 -0
  19. package/dist/core/dispatch-arbiter.js.map +1 -1
  20. package/dist/core/tab-title.d.ts +2 -0
  21. package/dist/core/tab-title.d.ts.map +1 -1
  22. package/dist/core/tab-title.js +3 -1
  23. package/dist/core/tab-title.js.map +1 -1
  24. package/dist/core/tools/subagent.d.ts +14 -2
  25. package/dist/core/tools/subagent.d.ts.map +1 -1
  26. package/dist/core/tools/subagent.js +17 -9
  27. package/dist/core/tools/subagent.js.map +1 -1
  28. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  29. package/dist/modes/interactive/interactive-mode.js +3 -2
  30. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  31. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  32. package/dist/modes/rpc/rpc-mode.js +3 -2
  33. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  34. package/package.json +1 -1
@@ -38,11 +38,15 @@ export declare class BuddyManager {
38
38
  * Hatch a new buddy. Generates bones, then uses parent LLM for soul.
39
39
  * Returns the new state.
40
40
  */
41
- hatch(parentModel: Model<"openai-completions">, apiKey: string): Promise<BuddyState>;
41
+ hatch(parentModel: Model<"openai-completions">, apiKey: string,
42
+ /** Stable conversation UUID forwarded to the soul-generation request (issue 500). */
43
+ sessionId?: string): Promise<BuddyState>;
42
44
  /**
43
45
  * Reroll the buddy — new bones + new soul.
44
46
  */
45
- reroll(parentModel: Model<"openai-completions">, apiKey: string): Promise<BuddyState>;
47
+ reroll(parentModel: Model<"openai-completions">, apiKey: string,
48
+ /** Stable conversation UUID forwarded to the soul-generation request (issue 500). */
49
+ sessionId?: string): Promise<BuddyState>;
46
50
  /** Get buddy's name (for name-call detection) */
47
51
  getName(): string | null;
48
52
  private ollamaChat;
@@ -1 +1 @@
1
- {"version":3,"file":"buddy-manager.d.ts","sourceRoot":"","sources":["../../../src/core/buddy/buddy-manager.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAW,KAAK,EAAE,MAAM,UAAU,CAAC;AAQ/C,OAAO,KAAK,EAAE,UAAU,EAAmC,MAAM,kBAAkB,CAAC;AA4DpF,MAAM,WAAW,YAAY;IAC5B,SAAS,EAAE,OAAO,CAAC;IACnB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;GAGG;AACH,wBAAsB,WAAW,IAAI,OAAO,CAAC,YAAY,CAAC,CAgBzD;AAaD;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAIvE;AAgJD,qBAAa,YAAY;IACxB,OAAO,CAAC,KAAK,CAA2B;IACxC,OAAO,CAAC,YAAY,CAA6B;IAEjD,mDAAmD;IACnD,QAAQ,IAAI,UAAU,GAAG,IAAI,CAE5B;IAED,oCAAoC;IACpC,cAAc,IAAI,OAAO,CAExB;IAED;;;;OAIG;IACH,IAAI,IAAI,UAAU,GAAG,IAAI,CAOxB;IAED;;;OAGG;IACG,KAAK,CAAC,WAAW,EAAE,KAAK,CAAC,oBAAoB,CAAC,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAmBzF;IAED;;OAEG;IACG,MAAM,CAAC,WAAW,EAAE,KAAK,CAAC,oBAAoB,CAAC,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAmB1F;IAED,iDAAiD;IACjD,OAAO,IAAI,MAAM,GAAG,IAAI,CAEvB;YAMa,UAAU;IAmDxB;;;OAGG;IACG,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAejD;IAED;;;OAGG;IACG,iBAAiB,CAAC,WAAW,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAgB1F;IAED,+DAA+D;IAC/D,cAAc,IAAI,MAAM,GAAG,IAAI,CAE9B;IAED,kEAAkE;IAClE,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAWtC;IAED,2EAA2E;IAC3E,gBAAgB,IAAI,IAAI,CAEvB;IAED;;;;OAIG;IACH,QAAQ,IAAI,OAAO,CAElB;IAED,kDAAkD;IAClD,SAAS,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAU/B;CACD","sourcesContent":["/**\n * BuddyManager — Core state machine for the buddy companion.\n *\n * Handles: bone rolling, soul generation, persistence, Ollama availability checks.\n * Bones are deterministic from hash(username + hostname + salt + rerollCount).\n * Soul is LLM-generated once on first hatch and persisted to buddy.json.\n */\n\nimport type { Context, Model } from \"@dreb/ai\";\nimport { completeSimple } from \"@dreb/ai\";\nimport { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from \"fs\";\nimport { hostname } from \"os\";\nimport { join } from \"path\";\nimport { getAgentDir } from \"../../config.js\";\nimport { createBuddyRng } from \"./buddy-prng.js\";\nimport { rollEyes, rollHat, rollSpecies, rollStats } from \"./buddy-species.js\";\nimport type { BuddyState, CompanionBones, StoredCompanion } from \"./buddy-types.js\";\nimport { STAT_NAMES } from \"./buddy-types.js\";\n\nconst BUDDY_SALT = \"dreb-buddy-v1\";\nconst BUDDY_FILENAME = \"buddy.json\";\nconst DEFAULT_BACKSTORY = \"A mysterious past shrouded in legend.\";\n\n/** Base Ollama model config — id/name are set dynamically from available models */\nconst OLLAMA_MODEL_BASE: Omit<Model<\"openai-completions\">, \"id\" | \"name\"> = {\n\tapi: \"openai-completions\",\n\tprovider: \"ollama\",\n\tbaseUrl: \"http://localhost:11434/v1\",\n\treasoning: false,\n\tinput: [\"text\"],\n\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\tcontextWindow: 128000,\n\tmaxTokens: 2048,\n\tcompat: {\n\t\tsupportsDeveloperRole: false,\n\t\tsupportsReasoningEffort: false,\n\t},\n};\n\n/** Max words for buddy response before truncation */\nconst MAX_RESPONSE_WORDS = 60;\n\n/** Prompt for soul generation (uses parent LLM, not Ollama) */\nconst SOUL_GENERATION_PROMPT = `You are generating a companion character for a coding assistant terminal app. Based on the species, rarity, and stats below, generate a creative name, a one-sentence personality description, and a funny fictional backstory.\n\nSpecies: {species}\nRarity: {rarity}\nStats: {stats}\nShiny: {shiny}\n\nThe name must NOT be a common English word, programming keyword, tool name, or command. It should be unique and distinctive — a proper noun that won't appear in normal conversation. The name must be 4-8 characters and easy to type on a QWERTY keyboard — use only common letters (a-z, avoid q, x, z, j). Do not use species name as the name.\n\nRespond in EXACTLY this format:\nNAME: <name>\nPERSONALITY: <one sentence personality>\nBACKSTORY: <2-3 sentence elaborate fictional backstory — funny, absurd, or dramatic. Include specific events, places, former occupations>`;\n\n/** Prompt for buddy reactions via Ollama */\nconst REACTION_PROMPT = `You are {name}, a {species} companion in a terminal coding app. You are {personality}. Your backstory: {backstory}\n\nSomething just happened. React with a short, in-character quip based on the context below. Be specific — reference what actually happened, not just that something happened. Max 20 words. No quotes, no prefixes, just the quip.\n\nContext:\n{event}`;\n\nconst NAME_CALL_PROMPT = `You are {name}, a {species} companion in a terminal coding app. You are {personality}. Your backstory: {backstory}\n\nThe user just said: \"{message}\"\nRecent context: {context}\n\nRespond to what the user said directly. Be in-character, reference your backstory occasionally. Max 30 words. No quotes, no prefixes, just your response.`;\n\n// =============================================================================\n// Ollama availability\n// =============================================================================\n\nexport interface OllamaStatus {\n\tavailable: boolean;\n\tmodels: string[];\n\terror?: string;\n}\n\n/**\n * Check if Ollama is running and has models available.\n * Uses the /api/tags endpoint.\n */\nexport async function checkOllama(): Promise<OllamaStatus> {\n\ttry {\n\t\tconst res = await fetch(\"http://localhost:11434/api/tags\", { signal: AbortSignal.timeout(3000) });\n\t\tif (!res.ok) {\n\t\t\treturn { available: false, models: [], error: `Ollama returned ${res.status}` };\n\t\t}\n\t\tconst data = (await res.json()) as { models?: { name: string }[] };\n\t\tconst models = (data.models ?? []).map((m) => m.name);\n\t\tif (models.length === 0) {\n\t\t\treturn { available: false, models: [], error: \"No models installed. Run: ollama pull llama3.2\" };\n\t\t}\n\t\treturn { available: true, models };\n\t} catch {\n\t\t/* Ollama not running or unreachable — report as unavailable */\n\t\treturn { available: false, models: [], error: \"Ollama is not running. Start it with: ollama serve\" };\n\t}\n}\n\n/**\n * Pick the Ollama model for the buddy.\n * Returns the stored model name if it's available, otherwise null.\n */\nfunction pickOllamaModel(storedModel: string | undefined, availableModels: string[]): string | null {\n\tif (!storedModel) return null;\n\t// Check if the stored model is installed (exact match or prefix match without tag)\n\tconst match = availableModels.find((m) => m === storedModel || m.startsWith(`${storedModel}:`));\n\treturn match ?? null;\n}\n\n/**\n * Truncate response to a maximum word count, appending \"...[truncated]\" if exceeded.\n */\nexport function truncateResponse(text: string, maxWords: number): string {\n\tconst words = text.split(/\\s+/);\n\tif (words.length <= maxWords) return text;\n\treturn `${words.slice(0, maxWords).join(\" \")} ...[truncated]`;\n}\n\n// =============================================================================\n// Persistence\n// =============================================================================\n\nfunction getBuddyPath(): string {\n\treturn join(getAgentDir(), BUDDY_FILENAME);\n}\n\nfunction loadStored(): StoredCompanion | null {\n\tconst path = getBuddyPath();\n\tif (!existsSync(path)) return null;\n\ttry {\n\t\tconst data = JSON.parse(readFileSync(path, \"utf-8\"));\n\t\t// Validate required fields\n\t\tif (\n\t\t\ttypeof data.rerollCount === \"number\" &&\n\t\t\ttypeof data.name === \"string\" &&\n\t\t\ttypeof data.personality === \"string\"\n\t\t) {\n\t\t\treturn {\n\t\t\t\trerollCount: data.rerollCount,\n\t\t\t\tname: data.name,\n\t\t\t\tpersonality: data.personality,\n\t\t\t\tbackstory: typeof data.backstory === \"string\" ? data.backstory : DEFAULT_BACKSTORY,\n\t\t\t\thatchedAt: data.hatchedAt ?? new Date().toISOString(),\n\t\t\t\t...(data.hidden !== undefined ? { hidden: data.hidden } : {}),\n\t\t\t\t...(typeof data.ollamaModel === \"string\" ? { ollamaModel: data.ollamaModel } : {}),\n\t\t\t};\n\t\t}\n\t\treturn null;\n\t} catch {\n\t\t/* Corrupt or missing companion file — return null to trigger fresh creation */\n\t\treturn null;\n\t}\n}\n\nfunction saveStored(stored: StoredCompanion): void {\n\tconst path = getBuddyPath();\n\tconst dir = join(path, \"..\");\n\tif (!existsSync(dir)) {\n\t\tmkdirSync(dir, { recursive: true });\n\t}\n\n\tlet tempPath: string | undefined = join(dir, `.buddy-${process.pid}-${Date.now()}.json.tmp`);\n\ttry {\n\t\twriteFileSync(tempPath, JSON.stringify(stored, null, 2), \"utf8\");\n\t\trenameSync(tempPath, path);\n\t\ttempPath = undefined;\n\t} finally {\n\t\tif (tempPath) {\n\t\t\ttry {\n\t\t\t\trmSync(tempPath, { force: true });\n\t\t\t} catch {\n\t\t\t\t// Best-effort cleanup only\n\t\t\t}\n\t\t}\n\t}\n}\n\n// =============================================================================\n// Bone rolling\n// =============================================================================\n\nfunction rollBones(rerollCount: number): CompanionBones {\n\tconst username = process.env.USER ?? process.env.LOGNAME ?? \"user\";\n\tconst host = hostname();\n\tconst rng = createBuddyRng(username, host, BUDDY_SALT, rerollCount);\n\n\t// Roll species + rarity\n\tconst { species, rarity } = rollSpecies(rng);\n\n\t// Roll shiny (1% chance)\n\tconst shiny = rng() < 0.01;\n\n\t// Roll eyes and hat\n\tconst eyes = rollEyes(rng);\n\tconst hat = rollHat(rng);\n\n\t// Roll stats\n\tconst stats = rollStats(rng, rarity);\n\n\treturn { species, rarity, shiny, stats, eyeStyle: eyes, hat };\n}\n\n// =============================================================================\n// Soul generation\n// =============================================================================\n\n/**\n * Generate a soul (name + personality + backstory) using the parent LLM.\n * Only called on first hatch or reroll.\n */\nasync function generateSoul(\n\tbones: CompanionBones,\n\tparentModel: Model<\"openai-completions\">,\n\tapiKey: string,\n): Promise<{ name: string; personality: string; backstory: string }> {\n\tconst statsStr = STAT_NAMES.map((s) => `${s}: ${bones.stats[s]}`).join(\", \");\n\tconst prompt = SOUL_GENERATION_PROMPT.replace(\"{species}\", bones.species)\n\t\t.replace(\"{rarity}\", bones.rarity)\n\t\t.replace(\"{stats}\", statsStr)\n\t\t.replace(\"{shiny}\", bones.shiny ? \"YES ✨\" : \"no\");\n\n\tconst context: Context = {\n\t\tsystemPrompt: \"Generate a companion character. Respond in the exact format requested.\",\n\t\tmessages: [{ role: \"user\", content: prompt, timestamp: Date.now() }],\n\t};\n\n\ttry {\n\t\tconst response = await completeSimple(parentModel, context, { apiKey });\n\t\tconst text = response.content\n\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t.map((c) => c.text)\n\t\t\t.join(\"\");\n\n\t\t// Parse NAME: ... and PERSONALITY: ... and BACKSTORY: ...\n\t\tconst nameMatch = text.match(/NAME:\\s*(.+)/i);\n\t\tconst personalityMatch = text.match(/PERSONALITY:\\s*(.+)/i);\n\t\tconst backstoryMatch = text.match(/BACKSTORY:\\s*([\\s\\S]+)/i);\n\n\t\tlet name = nameMatch?.[1]?.trim() ?? bones.species;\n\t\tconst personality = personalityMatch?.[1]?.trim() ?? `A ${bones.rarity} ${bones.species} companion.`;\n\t\tconst backstory = backstoryMatch?.[1]?.trim() ?? DEFAULT_BACKSTORY;\n\n\t\t// Enforce name length\n\t\tif (name.length > 8) name = name.slice(0, 8);\n\n\t\treturn { name, personality, backstory };\n\t} catch {\n\t\t// Fallback if LLM fails\n\t\treturn {\n\t\t\tname: bones.species,\n\t\t\tpersonality: `A ${bones.rarity} ${bones.species} companion.`,\n\t\t\tbackstory: DEFAULT_BACKSTORY,\n\t\t};\n\t}\n}\n\n// =============================================================================\n// BuddyManager\n// =============================================================================\n\nexport class BuddyManager {\n\tprivate state: BuddyState | null = null;\n\tprivate ollamaStatus: OllamaStatus | null = null;\n\n\t/** Get current buddy state (null if not loaded) */\n\tgetState(): BuddyState | null {\n\t\treturn this.state;\n\t}\n\n\t/** Check if buddy exists on disk */\n\thasStoredBuddy(): boolean {\n\t\treturn loadStored() !== null;\n\t}\n\n\t/**\n\t * Load or create buddy state.\n\t * If stored buddy exists, loads soul and re-rolls bones.\n\t * If no stored buddy, returns null (need to hatch first).\n\t */\n\tload(): BuddyState | null {\n\t\tconst stored = loadStored();\n\t\tif (!stored) return null;\n\n\t\tconst bones = rollBones(stored.rerollCount);\n\t\tthis.state = { ...bones, ...stored };\n\t\treturn this.state;\n\t}\n\n\t/**\n\t * Hatch a new buddy. Generates bones, then uses parent LLM for soul.\n\t * Returns the new state.\n\t */\n\tasync hatch(parentModel: Model<\"openai-completions\">, apiKey: string): Promise<BuddyState> {\n\t\tconst stored = loadStored();\n\t\tconst rerollCount = stored?.rerollCount ?? 0;\n\n\t\tconst bones = rollBones(rerollCount);\n\t\tconst { name, personality, backstory } = await generateSoul(bones, parentModel, apiKey);\n\n\t\tconst newStored: StoredCompanion = {\n\t\t\trerollCount,\n\t\t\tname,\n\t\t\tpersonality,\n\t\t\tbackstory,\n\t\t\thatchedAt: new Date().toISOString(),\n\t\t\t...(stored?.ollamaModel ? { ollamaModel: stored.ollamaModel } : {}),\n\t\t};\n\n\t\tsaveStored(newStored);\n\t\tthis.state = { ...bones, ...newStored };\n\t\treturn this.state;\n\t}\n\n\t/**\n\t * Reroll the buddy — new bones + new soul.\n\t */\n\tasync reroll(parentModel: Model<\"openai-completions\">, apiKey: string): Promise<BuddyState> {\n\t\tconst stored = loadStored();\n\t\tconst newRerollCount = (stored?.rerollCount ?? 0) + 1;\n\n\t\tconst bones = rollBones(newRerollCount);\n\t\tconst { name, personality, backstory } = await generateSoul(bones, parentModel, apiKey);\n\n\t\tconst newStored: StoredCompanion = {\n\t\t\trerollCount: newRerollCount,\n\t\t\tname,\n\t\t\tpersonality,\n\t\t\tbackstory,\n\t\t\thatchedAt: new Date().toISOString(),\n\t\t\t...(stored?.ollamaModel ? { ollamaModel: stored.ollamaModel } : {}),\n\t\t};\n\n\t\tsaveStored(newStored);\n\t\tthis.state = { ...bones, ...newStored };\n\t\treturn this.state;\n\t}\n\n\t/** Get buddy's name (for name-call detection) */\n\tgetName(): string | null {\n\t\treturn this.state?.name ?? loadStored()?.name ?? null;\n\t}\n\n\t/**\n\t * Shared Ollama chat helper. Checks availability, picks model, runs completion.\n\t * Returns the response text, or null if Ollama is unavailable or no model configured.\n\t */\n\tprivate async ollamaChat(context: Context): Promise<string | null> {\n\t\t// Check Ollama lazily, retry if previously unavailable\n\t\tif (!this.ollamaStatus || !this.ollamaStatus.available) {\n\t\t\tthis.ollamaStatus = await checkOllama();\n\t\t}\n\t\tif (!this.ollamaStatus.available) return null;\n\n\t\tconst modelName = pickOllamaModel(this.state?.ollamaModel, this.ollamaStatus.models);\n\t\tif (!modelName) return null;\n\t\tconst model: Model<\"openai-completions\"> = {\n\t\t\t...OLLAMA_MODEL_BASE,\n\t\t\tid: modelName,\n\t\t\tname: `${modelName} (Ollama)`,\n\t\t};\n\n\t\tlet response: import(\"@dreb/ai\").AssistantMessage;\n\t\ttry {\n\t\t\tresponse = await completeSimple(model, context, {\n\t\t\t\tapiKey: \"ollama\",\n\t\t\t\tsignal: AbortSignal.timeout(120000),\n\t\t\t});\n\t\t} catch {\n\t\t\t// Safety net for unexpected sync errors (e.g. provider not found).\n\t\t\t// Normal runtime errors (timeout, connection) are handled via stopReason below.\n\t\t\tthis.ollamaStatus = null;\n\t\t\treturn null;\n\t\t}\n\n\t\t// Connection error — invalidate cache so next attempt re-checks Ollama\n\t\tif (response.stopReason === \"error\") {\n\t\t\tthis.ollamaStatus = null;\n\t\t\treturn null;\n\t\t}\n\n\t\t// Timeout or abort — preserve cache (model is just slow, Ollama is fine)\n\t\tif (response.stopReason === \"aborted\") {\n\t\t\treturn null;\n\t\t}\n\n\t\tlet text = response.content\n\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t.map((c) => c.text)\n\t\t\t.join(\"\")\n\t\t\t.trim();\n\n\t\t// Truncate overly long responses\n\t\ttext = truncateResponse(text, MAX_RESPONSE_WORDS);\n\n\t\treturn text || null;\n\t}\n\n\t/**\n\t * Generate a reaction to an event using Ollama.\n\t * Returns null if Ollama is unavailable.\n\t */\n\tasync react(event: string): Promise<string | null> {\n\t\tif (!this.state) return null;\n\n\t\tconst prompt = REACTION_PROMPT.replace(\"{name}\", this.state.name)\n\t\t\t.replace(\"{species}\", this.state.species)\n\t\t\t.replace(\"{personality}\", this.state.personality)\n\t\t\t.replace(\"{backstory}\", this.state.backstory)\n\t\t\t.replace(\"{event}\", event);\n\n\t\tconst context: Context = {\n\t\t\tsystemPrompt: \"Respond with a short in-character quip. Max 20 words.\",\n\t\t\tmessages: [{ role: \"user\", content: prompt, timestamp: Date.now() }],\n\t\t};\n\n\t\treturn this.ollamaChat(context);\n\t}\n\n\t/**\n\t * Respond to the user calling the buddy's name.\n\t * Uses Ollama for the response.\n\t */\n\tasync respondToNameCall(userMessage: string, recentContext: string): Promise<string | null> {\n\t\tif (!this.state) return null;\n\n\t\tconst prompt = NAME_CALL_PROMPT.replace(\"{name}\", this.state.name)\n\t\t\t.replace(\"{species}\", this.state.species)\n\t\t\t.replace(\"{personality}\", this.state.personality)\n\t\t\t.replace(\"{backstory}\", this.state.backstory)\n\t\t\t.replace(\"{message}\", userMessage)\n\t\t\t.replace(\"{context}\", recentContext);\n\n\t\tconst context: Context = {\n\t\t\tsystemPrompt: \"Respond with a short friendly greeting. Max 30 words.\",\n\t\t\tmessages: [{ role: \"user\", content: prompt, timestamp: Date.now() }],\n\t\t};\n\n\t\treturn this.ollamaChat(context);\n\t}\n\n\t/** Get the configured Ollama model name, or null if not set */\n\tgetOllamaModel(): string | null {\n\t\treturn this.state?.ollamaModel ?? loadStored()?.ollamaModel ?? null;\n\t}\n\n\t/** Set the Ollama model for buddy reactions. Persists to disk. */\n\tsetOllamaModel(modelName: string): void {\n\t\tconst stored = loadStored();\n\t\tif (stored) {\n\t\t\tstored.ollamaModel = modelName;\n\t\t\tsaveStored(stored);\n\t\t}\n\t\tif (this.state) {\n\t\t\tthis.state.ollamaModel = modelName;\n\t\t}\n\t\t// Invalidate Ollama status cache so next call picks up the new model\n\t\tthis.ollamaStatus = null;\n\t}\n\n\t/** Reset Ollama status cache (e.g. after detecting it became available) */\n\tresetOllamaCache(): void {\n\t\tthis.ollamaStatus = null;\n\t}\n\n\t/**\n\t * Fresh-read the persisted `hidden` flag from disk (bypasses the in-memory\n\t * cache). Returns false when no buddy is stored. Used to detect a `/buddy off`\n\t * (or re-enable) performed by another concurrently-running dreb instance.\n\t */\n\tisHidden(): boolean {\n\t\treturn loadStored()?.hidden ?? false;\n\t}\n\n\t/** Update the hidden flag in persisted storage */\n\tsetHidden(hidden: boolean): void {\n\t\tconst stored = loadStored();\n\t\tif (stored) {\n\t\t\tstored.hidden = hidden;\n\t\t\tsaveStored(stored);\n\t\t}\n\t\t// Keep in-memory state in sync so reset() reads current hidden flag\n\t\tif (this.state) {\n\t\t\tthis.state.hidden = hidden;\n\t\t}\n\t}\n}\n"]}
1
+ {"version":3,"file":"buddy-manager.d.ts","sourceRoot":"","sources":["../../../src/core/buddy/buddy-manager.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAW,KAAK,EAAE,MAAM,UAAU,CAAC;AAQ/C,OAAO,KAAK,EAAE,UAAU,EAAmC,MAAM,kBAAkB,CAAC;AA4DpF,MAAM,WAAW,YAAY;IAC5B,SAAS,EAAE,OAAO,CAAC;IACnB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;GAGG;AACH,wBAAsB,WAAW,IAAI,OAAO,CAAC,YAAY,CAAC,CAgBzD;AAaD;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAIvE;AAiJD,qBAAa,YAAY;IACxB,OAAO,CAAC,KAAK,CAA2B;IACxC,OAAO,CAAC,YAAY,CAA6B;IAEjD,mDAAmD;IACnD,QAAQ,IAAI,UAAU,GAAG,IAAI,CAE5B;IAED,oCAAoC;IACpC,cAAc,IAAI,OAAO,CAExB;IAED;;;;OAIG;IACH,IAAI,IAAI,UAAU,GAAG,IAAI,CAOxB;IAED;;;OAGG;IACG,KAAK,CACV,WAAW,EAAE,KAAK,CAAC,oBAAoB,CAAC,EACxC,MAAM,EAAE,MAAM;IACd,qFAAqF;IACrF,SAAS,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,UAAU,CAAC,CAmBrB;IAED;;OAEG;IACG,MAAM,CACX,WAAW,EAAE,KAAK,CAAC,oBAAoB,CAAC,EACxC,MAAM,EAAE,MAAM;IACd,qFAAqF;IACrF,SAAS,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,UAAU,CAAC,CAmBrB;IAED,iDAAiD;IACjD,OAAO,IAAI,MAAM,GAAG,IAAI,CAEvB;YAMa,UAAU;IAmDxB;;;OAGG;IACG,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAejD;IAED;;;OAGG;IACG,iBAAiB,CAAC,WAAW,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAgB1F;IAED,+DAA+D;IAC/D,cAAc,IAAI,MAAM,GAAG,IAAI,CAE9B;IAED,kEAAkE;IAClE,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAWtC;IAED,2EAA2E;IAC3E,gBAAgB,IAAI,IAAI,CAEvB;IAED;;;;OAIG;IACH,QAAQ,IAAI,OAAO,CAElB;IAED,kDAAkD;IAClD,SAAS,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAU/B;CACD","sourcesContent":["/**\n * BuddyManager — Core state machine for the buddy companion.\n *\n * Handles: bone rolling, soul generation, persistence, Ollama availability checks.\n * Bones are deterministic from hash(username + hostname + salt + rerollCount).\n * Soul is LLM-generated once on first hatch and persisted to buddy.json.\n */\n\nimport type { Context, Model } from \"@dreb/ai\";\nimport { completeSimple } from \"@dreb/ai\";\nimport { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from \"fs\";\nimport { hostname } from \"os\";\nimport { join } from \"path\";\nimport { getAgentDir } from \"../../config.js\";\nimport { createBuddyRng } from \"./buddy-prng.js\";\nimport { rollEyes, rollHat, rollSpecies, rollStats } from \"./buddy-species.js\";\nimport type { BuddyState, CompanionBones, StoredCompanion } from \"./buddy-types.js\";\nimport { STAT_NAMES } from \"./buddy-types.js\";\n\nconst BUDDY_SALT = \"dreb-buddy-v1\";\nconst BUDDY_FILENAME = \"buddy.json\";\nconst DEFAULT_BACKSTORY = \"A mysterious past shrouded in legend.\";\n\n/** Base Ollama model config — id/name are set dynamically from available models */\nconst OLLAMA_MODEL_BASE: Omit<Model<\"openai-completions\">, \"id\" | \"name\"> = {\n\tapi: \"openai-completions\",\n\tprovider: \"ollama\",\n\tbaseUrl: \"http://localhost:11434/v1\",\n\treasoning: false,\n\tinput: [\"text\"],\n\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\tcontextWindow: 128000,\n\tmaxTokens: 2048,\n\tcompat: {\n\t\tsupportsDeveloperRole: false,\n\t\tsupportsReasoningEffort: false,\n\t},\n};\n\n/** Max words for buddy response before truncation */\nconst MAX_RESPONSE_WORDS = 60;\n\n/** Prompt for soul generation (uses parent LLM, not Ollama) */\nconst SOUL_GENERATION_PROMPT = `You are generating a companion character for a coding assistant terminal app. Based on the species, rarity, and stats below, generate a creative name, a one-sentence personality description, and a funny fictional backstory.\n\nSpecies: {species}\nRarity: {rarity}\nStats: {stats}\nShiny: {shiny}\n\nThe name must NOT be a common English word, programming keyword, tool name, or command. It should be unique and distinctive — a proper noun that won't appear in normal conversation. The name must be 4-8 characters and easy to type on a QWERTY keyboard — use only common letters (a-z, avoid q, x, z, j). Do not use species name as the name.\n\nRespond in EXACTLY this format:\nNAME: <name>\nPERSONALITY: <one sentence personality>\nBACKSTORY: <2-3 sentence elaborate fictional backstory — funny, absurd, or dramatic. Include specific events, places, former occupations>`;\n\n/** Prompt for buddy reactions via Ollama */\nconst REACTION_PROMPT = `You are {name}, a {species} companion in a terminal coding app. You are {personality}. Your backstory: {backstory}\n\nSomething just happened. React with a short, in-character quip based on the context below. Be specific — reference what actually happened, not just that something happened. Max 20 words. No quotes, no prefixes, just the quip.\n\nContext:\n{event}`;\n\nconst NAME_CALL_PROMPT = `You are {name}, a {species} companion in a terminal coding app. You are {personality}. Your backstory: {backstory}\n\nThe user just said: \"{message}\"\nRecent context: {context}\n\nRespond to what the user said directly. Be in-character, reference your backstory occasionally. Max 30 words. No quotes, no prefixes, just your response.`;\n\n// =============================================================================\n// Ollama availability\n// =============================================================================\n\nexport interface OllamaStatus {\n\tavailable: boolean;\n\tmodels: string[];\n\terror?: string;\n}\n\n/**\n * Check if Ollama is running and has models available.\n * Uses the /api/tags endpoint.\n */\nexport async function checkOllama(): Promise<OllamaStatus> {\n\ttry {\n\t\tconst res = await fetch(\"http://localhost:11434/api/tags\", { signal: AbortSignal.timeout(3000) });\n\t\tif (!res.ok) {\n\t\t\treturn { available: false, models: [], error: `Ollama returned ${res.status}` };\n\t\t}\n\t\tconst data = (await res.json()) as { models?: { name: string }[] };\n\t\tconst models = (data.models ?? []).map((m) => m.name);\n\t\tif (models.length === 0) {\n\t\t\treturn { available: false, models: [], error: \"No models installed. Run: ollama pull llama3.2\" };\n\t\t}\n\t\treturn { available: true, models };\n\t} catch {\n\t\t/* Ollama not running or unreachable — report as unavailable */\n\t\treturn { available: false, models: [], error: \"Ollama is not running. Start it with: ollama serve\" };\n\t}\n}\n\n/**\n * Pick the Ollama model for the buddy.\n * Returns the stored model name if it's available, otherwise null.\n */\nfunction pickOllamaModel(storedModel: string | undefined, availableModels: string[]): string | null {\n\tif (!storedModel) return null;\n\t// Check if the stored model is installed (exact match or prefix match without tag)\n\tconst match = availableModels.find((m) => m === storedModel || m.startsWith(`${storedModel}:`));\n\treturn match ?? null;\n}\n\n/**\n * Truncate response to a maximum word count, appending \"...[truncated]\" if exceeded.\n */\nexport function truncateResponse(text: string, maxWords: number): string {\n\tconst words = text.split(/\\s+/);\n\tif (words.length <= maxWords) return text;\n\treturn `${words.slice(0, maxWords).join(\" \")} ...[truncated]`;\n}\n\n// =============================================================================\n// Persistence\n// =============================================================================\n\nfunction getBuddyPath(): string {\n\treturn join(getAgentDir(), BUDDY_FILENAME);\n}\n\nfunction loadStored(): StoredCompanion | null {\n\tconst path = getBuddyPath();\n\tif (!existsSync(path)) return null;\n\ttry {\n\t\tconst data = JSON.parse(readFileSync(path, \"utf-8\"));\n\t\t// Validate required fields\n\t\tif (\n\t\t\ttypeof data.rerollCount === \"number\" &&\n\t\t\ttypeof data.name === \"string\" &&\n\t\t\ttypeof data.personality === \"string\"\n\t\t) {\n\t\t\treturn {\n\t\t\t\trerollCount: data.rerollCount,\n\t\t\t\tname: data.name,\n\t\t\t\tpersonality: data.personality,\n\t\t\t\tbackstory: typeof data.backstory === \"string\" ? data.backstory : DEFAULT_BACKSTORY,\n\t\t\t\thatchedAt: data.hatchedAt ?? new Date().toISOString(),\n\t\t\t\t...(data.hidden !== undefined ? { hidden: data.hidden } : {}),\n\t\t\t\t...(typeof data.ollamaModel === \"string\" ? { ollamaModel: data.ollamaModel } : {}),\n\t\t\t};\n\t\t}\n\t\treturn null;\n\t} catch {\n\t\t/* Corrupt or missing companion file — return null to trigger fresh creation */\n\t\treturn null;\n\t}\n}\n\nfunction saveStored(stored: StoredCompanion): void {\n\tconst path = getBuddyPath();\n\tconst dir = join(path, \"..\");\n\tif (!existsSync(dir)) {\n\t\tmkdirSync(dir, { recursive: true });\n\t}\n\n\tlet tempPath: string | undefined = join(dir, `.buddy-${process.pid}-${Date.now()}.json.tmp`);\n\ttry {\n\t\twriteFileSync(tempPath, JSON.stringify(stored, null, 2), \"utf8\");\n\t\trenameSync(tempPath, path);\n\t\ttempPath = undefined;\n\t} finally {\n\t\tif (tempPath) {\n\t\t\ttry {\n\t\t\t\trmSync(tempPath, { force: true });\n\t\t\t} catch {\n\t\t\t\t// Best-effort cleanup only\n\t\t\t}\n\t\t}\n\t}\n}\n\n// =============================================================================\n// Bone rolling\n// =============================================================================\n\nfunction rollBones(rerollCount: number): CompanionBones {\n\tconst username = process.env.USER ?? process.env.LOGNAME ?? \"user\";\n\tconst host = hostname();\n\tconst rng = createBuddyRng(username, host, BUDDY_SALT, rerollCount);\n\n\t// Roll species + rarity\n\tconst { species, rarity } = rollSpecies(rng);\n\n\t// Roll shiny (1% chance)\n\tconst shiny = rng() < 0.01;\n\n\t// Roll eyes and hat\n\tconst eyes = rollEyes(rng);\n\tconst hat = rollHat(rng);\n\n\t// Roll stats\n\tconst stats = rollStats(rng, rarity);\n\n\treturn { species, rarity, shiny, stats, eyeStyle: eyes, hat };\n}\n\n// =============================================================================\n// Soul generation\n// =============================================================================\n\n/**\n * Generate a soul (name + personality + backstory) using the parent LLM.\n * Only called on first hatch or reroll.\n */\nasync function generateSoul(\n\tbones: CompanionBones,\n\tparentModel: Model<\"openai-completions\">,\n\tapiKey: string,\n\tsessionId?: string,\n): Promise<{ name: string; personality: string; backstory: string }> {\n\tconst statsStr = STAT_NAMES.map((s) => `${s}: ${bones.stats[s]}`).join(\", \");\n\tconst prompt = SOUL_GENERATION_PROMPT.replace(\"{species}\", bones.species)\n\t\t.replace(\"{rarity}\", bones.rarity)\n\t\t.replace(\"{stats}\", statsStr)\n\t\t.replace(\"{shiny}\", bones.shiny ? \"YES ✨\" : \"no\");\n\n\tconst context: Context = {\n\t\tsystemPrompt: \"Generate a companion character. Respond in the exact format requested.\",\n\t\tmessages: [{ role: \"user\", content: prompt, timestamp: Date.now() }],\n\t};\n\n\ttry {\n\t\tconst response = await completeSimple(parentModel, context, { apiKey, ...(sessionId ? { sessionId } : {}) });\n\t\tconst text = response.content\n\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t.map((c) => c.text)\n\t\t\t.join(\"\");\n\n\t\t// Parse NAME: ... and PERSONALITY: ... and BACKSTORY: ...\n\t\tconst nameMatch = text.match(/NAME:\\s*(.+)/i);\n\t\tconst personalityMatch = text.match(/PERSONALITY:\\s*(.+)/i);\n\t\tconst backstoryMatch = text.match(/BACKSTORY:\\s*([\\s\\S]+)/i);\n\n\t\tlet name = nameMatch?.[1]?.trim() ?? bones.species;\n\t\tconst personality = personalityMatch?.[1]?.trim() ?? `A ${bones.rarity} ${bones.species} companion.`;\n\t\tconst backstory = backstoryMatch?.[1]?.trim() ?? DEFAULT_BACKSTORY;\n\n\t\t// Enforce name length\n\t\tif (name.length > 8) name = name.slice(0, 8);\n\n\t\treturn { name, personality, backstory };\n\t} catch {\n\t\t// Fallback if LLM fails\n\t\treturn {\n\t\t\tname: bones.species,\n\t\t\tpersonality: `A ${bones.rarity} ${bones.species} companion.`,\n\t\t\tbackstory: DEFAULT_BACKSTORY,\n\t\t};\n\t}\n}\n\n// =============================================================================\n// BuddyManager\n// =============================================================================\n\nexport class BuddyManager {\n\tprivate state: BuddyState | null = null;\n\tprivate ollamaStatus: OllamaStatus | null = null;\n\n\t/** Get current buddy state (null if not loaded) */\n\tgetState(): BuddyState | null {\n\t\treturn this.state;\n\t}\n\n\t/** Check if buddy exists on disk */\n\thasStoredBuddy(): boolean {\n\t\treturn loadStored() !== null;\n\t}\n\n\t/**\n\t * Load or create buddy state.\n\t * If stored buddy exists, loads soul and re-rolls bones.\n\t * If no stored buddy, returns null (need to hatch first).\n\t */\n\tload(): BuddyState | null {\n\t\tconst stored = loadStored();\n\t\tif (!stored) return null;\n\n\t\tconst bones = rollBones(stored.rerollCount);\n\t\tthis.state = { ...bones, ...stored };\n\t\treturn this.state;\n\t}\n\n\t/**\n\t * Hatch a new buddy. Generates bones, then uses parent LLM for soul.\n\t * Returns the new state.\n\t */\n\tasync hatch(\n\t\tparentModel: Model<\"openai-completions\">,\n\t\tapiKey: string,\n\t\t/** Stable conversation UUID forwarded to the soul-generation request (issue 500). */\n\t\tsessionId?: string,\n\t): Promise<BuddyState> {\n\t\tconst stored = loadStored();\n\t\tconst rerollCount = stored?.rerollCount ?? 0;\n\n\t\tconst bones = rollBones(rerollCount);\n\t\tconst { name, personality, backstory } = await generateSoul(bones, parentModel, apiKey, sessionId);\n\n\t\tconst newStored: StoredCompanion = {\n\t\t\trerollCount,\n\t\t\tname,\n\t\t\tpersonality,\n\t\t\tbackstory,\n\t\t\thatchedAt: new Date().toISOString(),\n\t\t\t...(stored?.ollamaModel ? { ollamaModel: stored.ollamaModel } : {}),\n\t\t};\n\n\t\tsaveStored(newStored);\n\t\tthis.state = { ...bones, ...newStored };\n\t\treturn this.state;\n\t}\n\n\t/**\n\t * Reroll the buddy — new bones + new soul.\n\t */\n\tasync reroll(\n\t\tparentModel: Model<\"openai-completions\">,\n\t\tapiKey: string,\n\t\t/** Stable conversation UUID forwarded to the soul-generation request (issue 500). */\n\t\tsessionId?: string,\n\t): Promise<BuddyState> {\n\t\tconst stored = loadStored();\n\t\tconst newRerollCount = (stored?.rerollCount ?? 0) + 1;\n\n\t\tconst bones = rollBones(newRerollCount);\n\t\tconst { name, personality, backstory } = await generateSoul(bones, parentModel, apiKey, sessionId);\n\n\t\tconst newStored: StoredCompanion = {\n\t\t\trerollCount: newRerollCount,\n\t\t\tname,\n\t\t\tpersonality,\n\t\t\tbackstory,\n\t\t\thatchedAt: new Date().toISOString(),\n\t\t\t...(stored?.ollamaModel ? { ollamaModel: stored.ollamaModel } : {}),\n\t\t};\n\n\t\tsaveStored(newStored);\n\t\tthis.state = { ...bones, ...newStored };\n\t\treturn this.state;\n\t}\n\n\t/** Get buddy's name (for name-call detection) */\n\tgetName(): string | null {\n\t\treturn this.state?.name ?? loadStored()?.name ?? null;\n\t}\n\n\t/**\n\t * Shared Ollama chat helper. Checks availability, picks model, runs completion.\n\t * Returns the response text, or null if Ollama is unavailable or no model configured.\n\t */\n\tprivate async ollamaChat(context: Context): Promise<string | null> {\n\t\t// Check Ollama lazily, retry if previously unavailable\n\t\tif (!this.ollamaStatus || !this.ollamaStatus.available) {\n\t\t\tthis.ollamaStatus = await checkOllama();\n\t\t}\n\t\tif (!this.ollamaStatus.available) return null;\n\n\t\tconst modelName = pickOllamaModel(this.state?.ollamaModel, this.ollamaStatus.models);\n\t\tif (!modelName) return null;\n\t\tconst model: Model<\"openai-completions\"> = {\n\t\t\t...OLLAMA_MODEL_BASE,\n\t\t\tid: modelName,\n\t\t\tname: `${modelName} (Ollama)`,\n\t\t};\n\n\t\tlet response: import(\"@dreb/ai\").AssistantMessage;\n\t\ttry {\n\t\t\tresponse = await completeSimple(model, context, {\n\t\t\t\tapiKey: \"ollama\",\n\t\t\t\tsignal: AbortSignal.timeout(120000),\n\t\t\t});\n\t\t} catch {\n\t\t\t// Safety net for unexpected sync errors (e.g. provider not found).\n\t\t\t// Normal runtime errors (timeout, connection) are handled via stopReason below.\n\t\t\tthis.ollamaStatus = null;\n\t\t\treturn null;\n\t\t}\n\n\t\t// Connection error — invalidate cache so next attempt re-checks Ollama\n\t\tif (response.stopReason === \"error\") {\n\t\t\tthis.ollamaStatus = null;\n\t\t\treturn null;\n\t\t}\n\n\t\t// Timeout or abort — preserve cache (model is just slow, Ollama is fine)\n\t\tif (response.stopReason === \"aborted\") {\n\t\t\treturn null;\n\t\t}\n\n\t\tlet text = response.content\n\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t.map((c) => c.text)\n\t\t\t.join(\"\")\n\t\t\t.trim();\n\n\t\t// Truncate overly long responses\n\t\ttext = truncateResponse(text, MAX_RESPONSE_WORDS);\n\n\t\treturn text || null;\n\t}\n\n\t/**\n\t * Generate a reaction to an event using Ollama.\n\t * Returns null if Ollama is unavailable.\n\t */\n\tasync react(event: string): Promise<string | null> {\n\t\tif (!this.state) return null;\n\n\t\tconst prompt = REACTION_PROMPT.replace(\"{name}\", this.state.name)\n\t\t\t.replace(\"{species}\", this.state.species)\n\t\t\t.replace(\"{personality}\", this.state.personality)\n\t\t\t.replace(\"{backstory}\", this.state.backstory)\n\t\t\t.replace(\"{event}\", event);\n\n\t\tconst context: Context = {\n\t\t\tsystemPrompt: \"Respond with a short in-character quip. Max 20 words.\",\n\t\t\tmessages: [{ role: \"user\", content: prompt, timestamp: Date.now() }],\n\t\t};\n\n\t\treturn this.ollamaChat(context);\n\t}\n\n\t/**\n\t * Respond to the user calling the buddy's name.\n\t * Uses Ollama for the response.\n\t */\n\tasync respondToNameCall(userMessage: string, recentContext: string): Promise<string | null> {\n\t\tif (!this.state) return null;\n\n\t\tconst prompt = NAME_CALL_PROMPT.replace(\"{name}\", this.state.name)\n\t\t\t.replace(\"{species}\", this.state.species)\n\t\t\t.replace(\"{personality}\", this.state.personality)\n\t\t\t.replace(\"{backstory}\", this.state.backstory)\n\t\t\t.replace(\"{message}\", userMessage)\n\t\t\t.replace(\"{context}\", recentContext);\n\n\t\tconst context: Context = {\n\t\t\tsystemPrompt: \"Respond with a short friendly greeting. Max 30 words.\",\n\t\t\tmessages: [{ role: \"user\", content: prompt, timestamp: Date.now() }],\n\t\t};\n\n\t\treturn this.ollamaChat(context);\n\t}\n\n\t/** Get the configured Ollama model name, or null if not set */\n\tgetOllamaModel(): string | null {\n\t\treturn this.state?.ollamaModel ?? loadStored()?.ollamaModel ?? null;\n\t}\n\n\t/** Set the Ollama model for buddy reactions. Persists to disk. */\n\tsetOllamaModel(modelName: string): void {\n\t\tconst stored = loadStored();\n\t\tif (stored) {\n\t\t\tstored.ollamaModel = modelName;\n\t\t\tsaveStored(stored);\n\t\t}\n\t\tif (this.state) {\n\t\t\tthis.state.ollamaModel = modelName;\n\t\t}\n\t\t// Invalidate Ollama status cache so next call picks up the new model\n\t\tthis.ollamaStatus = null;\n\t}\n\n\t/** Reset Ollama status cache (e.g. after detecting it became available) */\n\tresetOllamaCache(): void {\n\t\tthis.ollamaStatus = null;\n\t}\n\n\t/**\n\t * Fresh-read the persisted `hidden` flag from disk (bypasses the in-memory\n\t * cache). Returns false when no buddy is stored. Used to detect a `/buddy off`\n\t * (or re-enable) performed by another concurrently-running dreb instance.\n\t */\n\tisHidden(): boolean {\n\t\treturn loadStored()?.hidden ?? false;\n\t}\n\n\t/** Update the hidden flag in persisted storage */\n\tsetHidden(hidden: boolean): void {\n\t\tconst stored = loadStored();\n\t\tif (stored) {\n\t\t\tstored.hidden = hidden;\n\t\t\tsaveStored(stored);\n\t\t}\n\t\t// Keep in-memory state in sync so reset() reads current hidden flag\n\t\tif (this.state) {\n\t\t\tthis.state.hidden = hidden;\n\t\t}\n\t}\n}\n"]}
@@ -183,7 +183,7 @@ function rollBones(rerollCount) {
183
183
  * Generate a soul (name + personality + backstory) using the parent LLM.
184
184
  * Only called on first hatch or reroll.
185
185
  */
186
- async function generateSoul(bones, parentModel, apiKey) {
186
+ async function generateSoul(bones, parentModel, apiKey, sessionId) {
187
187
  const statsStr = STAT_NAMES.map((s) => `${s}: ${bones.stats[s]}`).join(", ");
188
188
  const prompt = SOUL_GENERATION_PROMPT.replace("{species}", bones.species)
189
189
  .replace("{rarity}", bones.rarity)
@@ -194,7 +194,7 @@ async function generateSoul(bones, parentModel, apiKey) {
194
194
  messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
195
195
  };
196
196
  try {
197
- const response = await completeSimple(parentModel, context, { apiKey });
197
+ const response = await completeSimple(parentModel, context, { apiKey, ...(sessionId ? { sessionId } : {}) });
198
198
  const text = response.content
199
199
  .filter((c) => c.type === "text")
200
200
  .map((c) => c.text)
@@ -251,11 +251,13 @@ export class BuddyManager {
251
251
  * Hatch a new buddy. Generates bones, then uses parent LLM for soul.
252
252
  * Returns the new state.
253
253
  */
254
- async hatch(parentModel, apiKey) {
254
+ async hatch(parentModel, apiKey,
255
+ /** Stable conversation UUID forwarded to the soul-generation request (issue 500). */
256
+ sessionId) {
255
257
  const stored = loadStored();
256
258
  const rerollCount = stored?.rerollCount ?? 0;
257
259
  const bones = rollBones(rerollCount);
258
- const { name, personality, backstory } = await generateSoul(bones, parentModel, apiKey);
260
+ const { name, personality, backstory } = await generateSoul(bones, parentModel, apiKey, sessionId);
259
261
  const newStored = {
260
262
  rerollCount,
261
263
  name,
@@ -271,11 +273,13 @@ export class BuddyManager {
271
273
  /**
272
274
  * Reroll the buddy — new bones + new soul.
273
275
  */
274
- async reroll(parentModel, apiKey) {
276
+ async reroll(parentModel, apiKey,
277
+ /** Stable conversation UUID forwarded to the soul-generation request (issue 500). */
278
+ sessionId) {
275
279
  const stored = loadStored();
276
280
  const newRerollCount = (stored?.rerollCount ?? 0) + 1;
277
281
  const bones = rollBones(newRerollCount);
278
- const { name, personality, backstory } = await generateSoul(bones, parentModel, apiKey);
282
+ const { name, personality, backstory } = await generateSoul(bones, parentModel, apiKey, sessionId);
279
283
  const newStored = {
280
284
  rerollCount: newRerollCount,
281
285
  name,
@@ -1 +1 @@
1
- {"version":3,"file":"buddy-manager.js","sourceRoot":"","sources":["../../../src/core/buddy/buddy-manager.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * BuddyManager — Core state machine for the buddy companion.\n *\n * Handles: bone rolling, soul generation, persistence, Ollama availability checks.\n * Bones are deterministic from hash(username + hostname + salt + rerollCount).\n * Soul is LLM-generated once on first hatch and persisted to buddy.json.\n */\n\nimport type { Context, Model } from \"@dreb/ai\";\nimport { completeSimple } from \"@dreb/ai\";\nimport { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from \"fs\";\nimport { hostname } from \"os\";\nimport { join } from \"path\";\nimport { getAgentDir } from \"../../config.js\";\nimport { createBuddyRng } from \"./buddy-prng.js\";\nimport { rollEyes, rollHat, rollSpecies, rollStats } from \"./buddy-species.js\";\nimport type { BuddyState, CompanionBones, StoredCompanion } from \"./buddy-types.js\";\nimport { STAT_NAMES } from \"./buddy-types.js\";\n\nconst BUDDY_SALT = \"dreb-buddy-v1\";\nconst BUDDY_FILENAME = \"buddy.json\";\nconst DEFAULT_BACKSTORY = \"A mysterious past shrouded in legend.\";\n\n/** Base Ollama model config — id/name are set dynamically from available models */\nconst OLLAMA_MODEL_BASE: Omit<Model<\"openai-completions\">, \"id\" | \"name\"> = {\n\tapi: \"openai-completions\",\n\tprovider: \"ollama\",\n\tbaseUrl: \"http://localhost:11434/v1\",\n\treasoning: false,\n\tinput: [\"text\"],\n\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\tcontextWindow: 128000,\n\tmaxTokens: 2048,\n\tcompat: {\n\t\tsupportsDeveloperRole: false,\n\t\tsupportsReasoningEffort: false,\n\t},\n};\n\n/** Max words for buddy response before truncation */\nconst MAX_RESPONSE_WORDS = 60;\n\n/** Prompt for soul generation (uses parent LLM, not Ollama) */\nconst SOUL_GENERATION_PROMPT = `You are generating a companion character for a coding assistant terminal app. Based on the species, rarity, and stats below, generate a creative name, a one-sentence personality description, and a funny fictional backstory.\n\nSpecies: {species}\nRarity: {rarity}\nStats: {stats}\nShiny: {shiny}\n\nThe name must NOT be a common English word, programming keyword, tool name, or command. It should be unique and distinctive — a proper noun that won't appear in normal conversation. The name must be 4-8 characters and easy to type on a QWERTY keyboard — use only common letters (a-z, avoid q, x, z, j). Do not use species name as the name.\n\nRespond in EXACTLY this format:\nNAME: <name>\nPERSONALITY: <one sentence personality>\nBACKSTORY: <2-3 sentence elaborate fictional backstory — funny, absurd, or dramatic. Include specific events, places, former occupations>`;\n\n/** Prompt for buddy reactions via Ollama */\nconst REACTION_PROMPT = `You are {name}, a {species} companion in a terminal coding app. You are {personality}. Your backstory: {backstory}\n\nSomething just happened. React with a short, in-character quip based on the context below. Be specific — reference what actually happened, not just that something happened. Max 20 words. No quotes, no prefixes, just the quip.\n\nContext:\n{event}`;\n\nconst NAME_CALL_PROMPT = `You are {name}, a {species} companion in a terminal coding app. You are {personality}. Your backstory: {backstory}\n\nThe user just said: \"{message}\"\nRecent context: {context}\n\nRespond to what the user said directly. Be in-character, reference your backstory occasionally. Max 30 words. No quotes, no prefixes, just your response.`;\n\n// =============================================================================\n// Ollama availability\n// =============================================================================\n\nexport interface OllamaStatus {\n\tavailable: boolean;\n\tmodels: string[];\n\terror?: string;\n}\n\n/**\n * Check if Ollama is running and has models available.\n * Uses the /api/tags endpoint.\n */\nexport async function checkOllama(): Promise<OllamaStatus> {\n\ttry {\n\t\tconst res = await fetch(\"http://localhost:11434/api/tags\", { signal: AbortSignal.timeout(3000) });\n\t\tif (!res.ok) {\n\t\t\treturn { available: false, models: [], error: `Ollama returned ${res.status}` };\n\t\t}\n\t\tconst data = (await res.json()) as { models?: { name: string }[] };\n\t\tconst models = (data.models ?? []).map((m) => m.name);\n\t\tif (models.length === 0) {\n\t\t\treturn { available: false, models: [], error: \"No models installed. Run: ollama pull llama3.2\" };\n\t\t}\n\t\treturn { available: true, models };\n\t} catch {\n\t\t/* Ollama not running or unreachable — report as unavailable */\n\t\treturn { available: false, models: [], error: \"Ollama is not running. Start it with: ollama serve\" };\n\t}\n}\n\n/**\n * Pick the Ollama model for the buddy.\n * Returns the stored model name if it's available, otherwise null.\n */\nfunction pickOllamaModel(storedModel: string | undefined, availableModels: string[]): string | null {\n\tif (!storedModel) return null;\n\t// Check if the stored model is installed (exact match or prefix match without tag)\n\tconst match = availableModels.find((m) => m === storedModel || m.startsWith(`${storedModel}:`));\n\treturn match ?? null;\n}\n\n/**\n * Truncate response to a maximum word count, appending \"...[truncated]\" if exceeded.\n */\nexport function truncateResponse(text: string, maxWords: number): string {\n\tconst words = text.split(/\\s+/);\n\tif (words.length <= maxWords) return text;\n\treturn `${words.slice(0, maxWords).join(\" \")} ...[truncated]`;\n}\n\n// =============================================================================\n// Persistence\n// =============================================================================\n\nfunction getBuddyPath(): string {\n\treturn join(getAgentDir(), BUDDY_FILENAME);\n}\n\nfunction loadStored(): StoredCompanion | null {\n\tconst path = getBuddyPath();\n\tif (!existsSync(path)) return null;\n\ttry {\n\t\tconst data = JSON.parse(readFileSync(path, \"utf-8\"));\n\t\t// Validate required fields\n\t\tif (\n\t\t\ttypeof data.rerollCount === \"number\" &&\n\t\t\ttypeof data.name === \"string\" &&\n\t\t\ttypeof data.personality === \"string\"\n\t\t) {\n\t\t\treturn {\n\t\t\t\trerollCount: data.rerollCount,\n\t\t\t\tname: data.name,\n\t\t\t\tpersonality: data.personality,\n\t\t\t\tbackstory: typeof data.backstory === \"string\" ? data.backstory : DEFAULT_BACKSTORY,\n\t\t\t\thatchedAt: data.hatchedAt ?? new Date().toISOString(),\n\t\t\t\t...(data.hidden !== undefined ? { hidden: data.hidden } : {}),\n\t\t\t\t...(typeof data.ollamaModel === \"string\" ? { ollamaModel: data.ollamaModel } : {}),\n\t\t\t};\n\t\t}\n\t\treturn null;\n\t} catch {\n\t\t/* Corrupt or missing companion file — return null to trigger fresh creation */\n\t\treturn null;\n\t}\n}\n\nfunction saveStored(stored: StoredCompanion): void {\n\tconst path = getBuddyPath();\n\tconst dir = join(path, \"..\");\n\tif (!existsSync(dir)) {\n\t\tmkdirSync(dir, { recursive: true });\n\t}\n\n\tlet tempPath: string | undefined = join(dir, `.buddy-${process.pid}-${Date.now()}.json.tmp`);\n\ttry {\n\t\twriteFileSync(tempPath, JSON.stringify(stored, null, 2), \"utf8\");\n\t\trenameSync(tempPath, path);\n\t\ttempPath = undefined;\n\t} finally {\n\t\tif (tempPath) {\n\t\t\ttry {\n\t\t\t\trmSync(tempPath, { force: true });\n\t\t\t} catch {\n\t\t\t\t// Best-effort cleanup only\n\t\t\t}\n\t\t}\n\t}\n}\n\n// =============================================================================\n// Bone rolling\n// =============================================================================\n\nfunction rollBones(rerollCount: number): CompanionBones {\n\tconst username = process.env.USER ?? process.env.LOGNAME ?? \"user\";\n\tconst host = hostname();\n\tconst rng = createBuddyRng(username, host, BUDDY_SALT, rerollCount);\n\n\t// Roll species + rarity\n\tconst { species, rarity } = rollSpecies(rng);\n\n\t// Roll shiny (1% chance)\n\tconst shiny = rng() < 0.01;\n\n\t// Roll eyes and hat\n\tconst eyes = rollEyes(rng);\n\tconst hat = rollHat(rng);\n\n\t// Roll stats\n\tconst stats = rollStats(rng, rarity);\n\n\treturn { species, rarity, shiny, stats, eyeStyle: eyes, hat };\n}\n\n// =============================================================================\n// Soul generation\n// =============================================================================\n\n/**\n * Generate a soul (name + personality + backstory) using the parent LLM.\n * Only called on first hatch or reroll.\n */\nasync function generateSoul(\n\tbones: CompanionBones,\n\tparentModel: Model<\"openai-completions\">,\n\tapiKey: string,\n): Promise<{ name: string; personality: string; backstory: string }> {\n\tconst statsStr = STAT_NAMES.map((s) => `${s}: ${bones.stats[s]}`).join(\", \");\n\tconst prompt = SOUL_GENERATION_PROMPT.replace(\"{species}\", bones.species)\n\t\t.replace(\"{rarity}\", bones.rarity)\n\t\t.replace(\"{stats}\", statsStr)\n\t\t.replace(\"{shiny}\", bones.shiny ? \"YES ✨\" : \"no\");\n\n\tconst context: Context = {\n\t\tsystemPrompt: \"Generate a companion character. Respond in the exact format requested.\",\n\t\tmessages: [{ role: \"user\", content: prompt, timestamp: Date.now() }],\n\t};\n\n\ttry {\n\t\tconst response = await completeSimple(parentModel, context, { apiKey });\n\t\tconst text = response.content\n\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t.map((c) => c.text)\n\t\t\t.join(\"\");\n\n\t\t// Parse NAME: ... and PERSONALITY: ... and BACKSTORY: ...\n\t\tconst nameMatch = text.match(/NAME:\\s*(.+)/i);\n\t\tconst personalityMatch = text.match(/PERSONALITY:\\s*(.+)/i);\n\t\tconst backstoryMatch = text.match(/BACKSTORY:\\s*([\\s\\S]+)/i);\n\n\t\tlet name = nameMatch?.[1]?.trim() ?? bones.species;\n\t\tconst personality = personalityMatch?.[1]?.trim() ?? `A ${bones.rarity} ${bones.species} companion.`;\n\t\tconst backstory = backstoryMatch?.[1]?.trim() ?? DEFAULT_BACKSTORY;\n\n\t\t// Enforce name length\n\t\tif (name.length > 8) name = name.slice(0, 8);\n\n\t\treturn { name, personality, backstory };\n\t} catch {\n\t\t// Fallback if LLM fails\n\t\treturn {\n\t\t\tname: bones.species,\n\t\t\tpersonality: `A ${bones.rarity} ${bones.species} companion.`,\n\t\t\tbackstory: DEFAULT_BACKSTORY,\n\t\t};\n\t}\n}\n\n// =============================================================================\n// BuddyManager\n// =============================================================================\n\nexport class BuddyManager {\n\tprivate state: BuddyState | null = null;\n\tprivate ollamaStatus: OllamaStatus | null = null;\n\n\t/** Get current buddy state (null if not loaded) */\n\tgetState(): BuddyState | null {\n\t\treturn this.state;\n\t}\n\n\t/** Check if buddy exists on disk */\n\thasStoredBuddy(): boolean {\n\t\treturn loadStored() !== null;\n\t}\n\n\t/**\n\t * Load or create buddy state.\n\t * If stored buddy exists, loads soul and re-rolls bones.\n\t * If no stored buddy, returns null (need to hatch first).\n\t */\n\tload(): BuddyState | null {\n\t\tconst stored = loadStored();\n\t\tif (!stored) return null;\n\n\t\tconst bones = rollBones(stored.rerollCount);\n\t\tthis.state = { ...bones, ...stored };\n\t\treturn this.state;\n\t}\n\n\t/**\n\t * Hatch a new buddy. Generates bones, then uses parent LLM for soul.\n\t * Returns the new state.\n\t */\n\tasync hatch(parentModel: Model<\"openai-completions\">, apiKey: string): Promise<BuddyState> {\n\t\tconst stored = loadStored();\n\t\tconst rerollCount = stored?.rerollCount ?? 0;\n\n\t\tconst bones = rollBones(rerollCount);\n\t\tconst { name, personality, backstory } = await generateSoul(bones, parentModel, apiKey);\n\n\t\tconst newStored: StoredCompanion = {\n\t\t\trerollCount,\n\t\t\tname,\n\t\t\tpersonality,\n\t\t\tbackstory,\n\t\t\thatchedAt: new Date().toISOString(),\n\t\t\t...(stored?.ollamaModel ? { ollamaModel: stored.ollamaModel } : {}),\n\t\t};\n\n\t\tsaveStored(newStored);\n\t\tthis.state = { ...bones, ...newStored };\n\t\treturn this.state;\n\t}\n\n\t/**\n\t * Reroll the buddy — new bones + new soul.\n\t */\n\tasync reroll(parentModel: Model<\"openai-completions\">, apiKey: string): Promise<BuddyState> {\n\t\tconst stored = loadStored();\n\t\tconst newRerollCount = (stored?.rerollCount ?? 0) + 1;\n\n\t\tconst bones = rollBones(newRerollCount);\n\t\tconst { name, personality, backstory } = await generateSoul(bones, parentModel, apiKey);\n\n\t\tconst newStored: StoredCompanion = {\n\t\t\trerollCount: newRerollCount,\n\t\t\tname,\n\t\t\tpersonality,\n\t\t\tbackstory,\n\t\t\thatchedAt: new Date().toISOString(),\n\t\t\t...(stored?.ollamaModel ? { ollamaModel: stored.ollamaModel } : {}),\n\t\t};\n\n\t\tsaveStored(newStored);\n\t\tthis.state = { ...bones, ...newStored };\n\t\treturn this.state;\n\t}\n\n\t/** Get buddy's name (for name-call detection) */\n\tgetName(): string | null {\n\t\treturn this.state?.name ?? loadStored()?.name ?? null;\n\t}\n\n\t/**\n\t * Shared Ollama chat helper. Checks availability, picks model, runs completion.\n\t * Returns the response text, or null if Ollama is unavailable or no model configured.\n\t */\n\tprivate async ollamaChat(context: Context): Promise<string | null> {\n\t\t// Check Ollama lazily, retry if previously unavailable\n\t\tif (!this.ollamaStatus || !this.ollamaStatus.available) {\n\t\t\tthis.ollamaStatus = await checkOllama();\n\t\t}\n\t\tif (!this.ollamaStatus.available) return null;\n\n\t\tconst modelName = pickOllamaModel(this.state?.ollamaModel, this.ollamaStatus.models);\n\t\tif (!modelName) return null;\n\t\tconst model: Model<\"openai-completions\"> = {\n\t\t\t...OLLAMA_MODEL_BASE,\n\t\t\tid: modelName,\n\t\t\tname: `${modelName} (Ollama)`,\n\t\t};\n\n\t\tlet response: import(\"@dreb/ai\").AssistantMessage;\n\t\ttry {\n\t\t\tresponse = await completeSimple(model, context, {\n\t\t\t\tapiKey: \"ollama\",\n\t\t\t\tsignal: AbortSignal.timeout(120000),\n\t\t\t});\n\t\t} catch {\n\t\t\t// Safety net for unexpected sync errors (e.g. provider not found).\n\t\t\t// Normal runtime errors (timeout, connection) are handled via stopReason below.\n\t\t\tthis.ollamaStatus = null;\n\t\t\treturn null;\n\t\t}\n\n\t\t// Connection error — invalidate cache so next attempt re-checks Ollama\n\t\tif (response.stopReason === \"error\") {\n\t\t\tthis.ollamaStatus = null;\n\t\t\treturn null;\n\t\t}\n\n\t\t// Timeout or abort — preserve cache (model is just slow, Ollama is fine)\n\t\tif (response.stopReason === \"aborted\") {\n\t\t\treturn null;\n\t\t}\n\n\t\tlet text = response.content\n\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t.map((c) => c.text)\n\t\t\t.join(\"\")\n\t\t\t.trim();\n\n\t\t// Truncate overly long responses\n\t\ttext = truncateResponse(text, MAX_RESPONSE_WORDS);\n\n\t\treturn text || null;\n\t}\n\n\t/**\n\t * Generate a reaction to an event using Ollama.\n\t * Returns null if Ollama is unavailable.\n\t */\n\tasync react(event: string): Promise<string | null> {\n\t\tif (!this.state) return null;\n\n\t\tconst prompt = REACTION_PROMPT.replace(\"{name}\", this.state.name)\n\t\t\t.replace(\"{species}\", this.state.species)\n\t\t\t.replace(\"{personality}\", this.state.personality)\n\t\t\t.replace(\"{backstory}\", this.state.backstory)\n\t\t\t.replace(\"{event}\", event);\n\n\t\tconst context: Context = {\n\t\t\tsystemPrompt: \"Respond with a short in-character quip. Max 20 words.\",\n\t\t\tmessages: [{ role: \"user\", content: prompt, timestamp: Date.now() }],\n\t\t};\n\n\t\treturn this.ollamaChat(context);\n\t}\n\n\t/**\n\t * Respond to the user calling the buddy's name.\n\t * Uses Ollama for the response.\n\t */\n\tasync respondToNameCall(userMessage: string, recentContext: string): Promise<string | null> {\n\t\tif (!this.state) return null;\n\n\t\tconst prompt = NAME_CALL_PROMPT.replace(\"{name}\", this.state.name)\n\t\t\t.replace(\"{species}\", this.state.species)\n\t\t\t.replace(\"{personality}\", this.state.personality)\n\t\t\t.replace(\"{backstory}\", this.state.backstory)\n\t\t\t.replace(\"{message}\", userMessage)\n\t\t\t.replace(\"{context}\", recentContext);\n\n\t\tconst context: Context = {\n\t\t\tsystemPrompt: \"Respond with a short friendly greeting. Max 30 words.\",\n\t\t\tmessages: [{ role: \"user\", content: prompt, timestamp: Date.now() }],\n\t\t};\n\n\t\treturn this.ollamaChat(context);\n\t}\n\n\t/** Get the configured Ollama model name, or null if not set */\n\tgetOllamaModel(): string | null {\n\t\treturn this.state?.ollamaModel ?? loadStored()?.ollamaModel ?? null;\n\t}\n\n\t/** Set the Ollama model for buddy reactions. Persists to disk. */\n\tsetOllamaModel(modelName: string): void {\n\t\tconst stored = loadStored();\n\t\tif (stored) {\n\t\t\tstored.ollamaModel = modelName;\n\t\t\tsaveStored(stored);\n\t\t}\n\t\tif (this.state) {\n\t\t\tthis.state.ollamaModel = modelName;\n\t\t}\n\t\t// Invalidate Ollama status cache so next call picks up the new model\n\t\tthis.ollamaStatus = null;\n\t}\n\n\t/** Reset Ollama status cache (e.g. after detecting it became available) */\n\tresetOllamaCache(): void {\n\t\tthis.ollamaStatus = null;\n\t}\n\n\t/**\n\t * Fresh-read the persisted `hidden` flag from disk (bypasses the in-memory\n\t * cache). Returns false when no buddy is stored. Used to detect a `/buddy off`\n\t * (or re-enable) performed by another concurrently-running dreb instance.\n\t */\n\tisHidden(): boolean {\n\t\treturn loadStored()?.hidden ?? false;\n\t}\n\n\t/** Update the hidden flag in persisted storage */\n\tsetHidden(hidden: boolean): void {\n\t\tconst stored = loadStored();\n\t\tif (stored) {\n\t\t\tstored.hidden = hidden;\n\t\t\tsaveStored(stored);\n\t\t}\n\t\t// Keep in-memory state in sync so reset() reads current hidden flag\n\t\tif (this.state) {\n\t\t\tthis.state.hidden = hidden;\n\t\t}\n\t}\n}\n"]}
1
+ {"version":3,"file":"buddy-manager.js","sourceRoot":"","sources":["../../../src/core/buddy/buddy-manager.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * BuddyManager — Core state machine for the buddy companion.\n *\n * Handles: bone rolling, soul generation, persistence, Ollama availability checks.\n * Bones are deterministic from hash(username + hostname + salt + rerollCount).\n * Soul is LLM-generated once on first hatch and persisted to buddy.json.\n */\n\nimport type { Context, Model } from \"@dreb/ai\";\nimport { completeSimple } from \"@dreb/ai\";\nimport { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from \"fs\";\nimport { hostname } from \"os\";\nimport { join } from \"path\";\nimport { getAgentDir } from \"../../config.js\";\nimport { createBuddyRng } from \"./buddy-prng.js\";\nimport { rollEyes, rollHat, rollSpecies, rollStats } from \"./buddy-species.js\";\nimport type { BuddyState, CompanionBones, StoredCompanion } from \"./buddy-types.js\";\nimport { STAT_NAMES } from \"./buddy-types.js\";\n\nconst BUDDY_SALT = \"dreb-buddy-v1\";\nconst BUDDY_FILENAME = \"buddy.json\";\nconst DEFAULT_BACKSTORY = \"A mysterious past shrouded in legend.\";\n\n/** Base Ollama model config — id/name are set dynamically from available models */\nconst OLLAMA_MODEL_BASE: Omit<Model<\"openai-completions\">, \"id\" | \"name\"> = {\n\tapi: \"openai-completions\",\n\tprovider: \"ollama\",\n\tbaseUrl: \"http://localhost:11434/v1\",\n\treasoning: false,\n\tinput: [\"text\"],\n\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\tcontextWindow: 128000,\n\tmaxTokens: 2048,\n\tcompat: {\n\t\tsupportsDeveloperRole: false,\n\t\tsupportsReasoningEffort: false,\n\t},\n};\n\n/** Max words for buddy response before truncation */\nconst MAX_RESPONSE_WORDS = 60;\n\n/** Prompt for soul generation (uses parent LLM, not Ollama) */\nconst SOUL_GENERATION_PROMPT = `You are generating a companion character for a coding assistant terminal app. Based on the species, rarity, and stats below, generate a creative name, a one-sentence personality description, and a funny fictional backstory.\n\nSpecies: {species}\nRarity: {rarity}\nStats: {stats}\nShiny: {shiny}\n\nThe name must NOT be a common English word, programming keyword, tool name, or command. It should be unique and distinctive — a proper noun that won't appear in normal conversation. The name must be 4-8 characters and easy to type on a QWERTY keyboard — use only common letters (a-z, avoid q, x, z, j). Do not use species name as the name.\n\nRespond in EXACTLY this format:\nNAME: <name>\nPERSONALITY: <one sentence personality>\nBACKSTORY: <2-3 sentence elaborate fictional backstory — funny, absurd, or dramatic. Include specific events, places, former occupations>`;\n\n/** Prompt for buddy reactions via Ollama */\nconst REACTION_PROMPT = `You are {name}, a {species} companion in a terminal coding app. You are {personality}. Your backstory: {backstory}\n\nSomething just happened. React with a short, in-character quip based on the context below. Be specific — reference what actually happened, not just that something happened. Max 20 words. No quotes, no prefixes, just the quip.\n\nContext:\n{event}`;\n\nconst NAME_CALL_PROMPT = `You are {name}, a {species} companion in a terminal coding app. You are {personality}. Your backstory: {backstory}\n\nThe user just said: \"{message}\"\nRecent context: {context}\n\nRespond to what the user said directly. Be in-character, reference your backstory occasionally. Max 30 words. No quotes, no prefixes, just your response.`;\n\n// =============================================================================\n// Ollama availability\n// =============================================================================\n\nexport interface OllamaStatus {\n\tavailable: boolean;\n\tmodels: string[];\n\terror?: string;\n}\n\n/**\n * Check if Ollama is running and has models available.\n * Uses the /api/tags endpoint.\n */\nexport async function checkOllama(): Promise<OllamaStatus> {\n\ttry {\n\t\tconst res = await fetch(\"http://localhost:11434/api/tags\", { signal: AbortSignal.timeout(3000) });\n\t\tif (!res.ok) {\n\t\t\treturn { available: false, models: [], error: `Ollama returned ${res.status}` };\n\t\t}\n\t\tconst data = (await res.json()) as { models?: { name: string }[] };\n\t\tconst models = (data.models ?? []).map((m) => m.name);\n\t\tif (models.length === 0) {\n\t\t\treturn { available: false, models: [], error: \"No models installed. Run: ollama pull llama3.2\" };\n\t\t}\n\t\treturn { available: true, models };\n\t} catch {\n\t\t/* Ollama not running or unreachable — report as unavailable */\n\t\treturn { available: false, models: [], error: \"Ollama is not running. Start it with: ollama serve\" };\n\t}\n}\n\n/**\n * Pick the Ollama model for the buddy.\n * Returns the stored model name if it's available, otherwise null.\n */\nfunction pickOllamaModel(storedModel: string | undefined, availableModels: string[]): string | null {\n\tif (!storedModel) return null;\n\t// Check if the stored model is installed (exact match or prefix match without tag)\n\tconst match = availableModels.find((m) => m === storedModel || m.startsWith(`${storedModel}:`));\n\treturn match ?? null;\n}\n\n/**\n * Truncate response to a maximum word count, appending \"...[truncated]\" if exceeded.\n */\nexport function truncateResponse(text: string, maxWords: number): string {\n\tconst words = text.split(/\\s+/);\n\tif (words.length <= maxWords) return text;\n\treturn `${words.slice(0, maxWords).join(\" \")} ...[truncated]`;\n}\n\n// =============================================================================\n// Persistence\n// =============================================================================\n\nfunction getBuddyPath(): string {\n\treturn join(getAgentDir(), BUDDY_FILENAME);\n}\n\nfunction loadStored(): StoredCompanion | null {\n\tconst path = getBuddyPath();\n\tif (!existsSync(path)) return null;\n\ttry {\n\t\tconst data = JSON.parse(readFileSync(path, \"utf-8\"));\n\t\t// Validate required fields\n\t\tif (\n\t\t\ttypeof data.rerollCount === \"number\" &&\n\t\t\ttypeof data.name === \"string\" &&\n\t\t\ttypeof data.personality === \"string\"\n\t\t) {\n\t\t\treturn {\n\t\t\t\trerollCount: data.rerollCount,\n\t\t\t\tname: data.name,\n\t\t\t\tpersonality: data.personality,\n\t\t\t\tbackstory: typeof data.backstory === \"string\" ? data.backstory : DEFAULT_BACKSTORY,\n\t\t\t\thatchedAt: data.hatchedAt ?? new Date().toISOString(),\n\t\t\t\t...(data.hidden !== undefined ? { hidden: data.hidden } : {}),\n\t\t\t\t...(typeof data.ollamaModel === \"string\" ? { ollamaModel: data.ollamaModel } : {}),\n\t\t\t};\n\t\t}\n\t\treturn null;\n\t} catch {\n\t\t/* Corrupt or missing companion file — return null to trigger fresh creation */\n\t\treturn null;\n\t}\n}\n\nfunction saveStored(stored: StoredCompanion): void {\n\tconst path = getBuddyPath();\n\tconst dir = join(path, \"..\");\n\tif (!existsSync(dir)) {\n\t\tmkdirSync(dir, { recursive: true });\n\t}\n\n\tlet tempPath: string | undefined = join(dir, `.buddy-${process.pid}-${Date.now()}.json.tmp`);\n\ttry {\n\t\twriteFileSync(tempPath, JSON.stringify(stored, null, 2), \"utf8\");\n\t\trenameSync(tempPath, path);\n\t\ttempPath = undefined;\n\t} finally {\n\t\tif (tempPath) {\n\t\t\ttry {\n\t\t\t\trmSync(tempPath, { force: true });\n\t\t\t} catch {\n\t\t\t\t// Best-effort cleanup only\n\t\t\t}\n\t\t}\n\t}\n}\n\n// =============================================================================\n// Bone rolling\n// =============================================================================\n\nfunction rollBones(rerollCount: number): CompanionBones {\n\tconst username = process.env.USER ?? process.env.LOGNAME ?? \"user\";\n\tconst host = hostname();\n\tconst rng = createBuddyRng(username, host, BUDDY_SALT, rerollCount);\n\n\t// Roll species + rarity\n\tconst { species, rarity } = rollSpecies(rng);\n\n\t// Roll shiny (1% chance)\n\tconst shiny = rng() < 0.01;\n\n\t// Roll eyes and hat\n\tconst eyes = rollEyes(rng);\n\tconst hat = rollHat(rng);\n\n\t// Roll stats\n\tconst stats = rollStats(rng, rarity);\n\n\treturn { species, rarity, shiny, stats, eyeStyle: eyes, hat };\n}\n\n// =============================================================================\n// Soul generation\n// =============================================================================\n\n/**\n * Generate a soul (name + personality + backstory) using the parent LLM.\n * Only called on first hatch or reroll.\n */\nasync function generateSoul(\n\tbones: CompanionBones,\n\tparentModel: Model<\"openai-completions\">,\n\tapiKey: string,\n\tsessionId?: string,\n): Promise<{ name: string; personality: string; backstory: string }> {\n\tconst statsStr = STAT_NAMES.map((s) => `${s}: ${bones.stats[s]}`).join(\", \");\n\tconst prompt = SOUL_GENERATION_PROMPT.replace(\"{species}\", bones.species)\n\t\t.replace(\"{rarity}\", bones.rarity)\n\t\t.replace(\"{stats}\", statsStr)\n\t\t.replace(\"{shiny}\", bones.shiny ? \"YES ✨\" : \"no\");\n\n\tconst context: Context = {\n\t\tsystemPrompt: \"Generate a companion character. Respond in the exact format requested.\",\n\t\tmessages: [{ role: \"user\", content: prompt, timestamp: Date.now() }],\n\t};\n\n\ttry {\n\t\tconst response = await completeSimple(parentModel, context, { apiKey, ...(sessionId ? { sessionId } : {}) });\n\t\tconst text = response.content\n\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t.map((c) => c.text)\n\t\t\t.join(\"\");\n\n\t\t// Parse NAME: ... and PERSONALITY: ... and BACKSTORY: ...\n\t\tconst nameMatch = text.match(/NAME:\\s*(.+)/i);\n\t\tconst personalityMatch = text.match(/PERSONALITY:\\s*(.+)/i);\n\t\tconst backstoryMatch = text.match(/BACKSTORY:\\s*([\\s\\S]+)/i);\n\n\t\tlet name = nameMatch?.[1]?.trim() ?? bones.species;\n\t\tconst personality = personalityMatch?.[1]?.trim() ?? `A ${bones.rarity} ${bones.species} companion.`;\n\t\tconst backstory = backstoryMatch?.[1]?.trim() ?? DEFAULT_BACKSTORY;\n\n\t\t// Enforce name length\n\t\tif (name.length > 8) name = name.slice(0, 8);\n\n\t\treturn { name, personality, backstory };\n\t} catch {\n\t\t// Fallback if LLM fails\n\t\treturn {\n\t\t\tname: bones.species,\n\t\t\tpersonality: `A ${bones.rarity} ${bones.species} companion.`,\n\t\t\tbackstory: DEFAULT_BACKSTORY,\n\t\t};\n\t}\n}\n\n// =============================================================================\n// BuddyManager\n// =============================================================================\n\nexport class BuddyManager {\n\tprivate state: BuddyState | null = null;\n\tprivate ollamaStatus: OllamaStatus | null = null;\n\n\t/** Get current buddy state (null if not loaded) */\n\tgetState(): BuddyState | null {\n\t\treturn this.state;\n\t}\n\n\t/** Check if buddy exists on disk */\n\thasStoredBuddy(): boolean {\n\t\treturn loadStored() !== null;\n\t}\n\n\t/**\n\t * Load or create buddy state.\n\t * If stored buddy exists, loads soul and re-rolls bones.\n\t * If no stored buddy, returns null (need to hatch first).\n\t */\n\tload(): BuddyState | null {\n\t\tconst stored = loadStored();\n\t\tif (!stored) return null;\n\n\t\tconst bones = rollBones(stored.rerollCount);\n\t\tthis.state = { ...bones, ...stored };\n\t\treturn this.state;\n\t}\n\n\t/**\n\t * Hatch a new buddy. Generates bones, then uses parent LLM for soul.\n\t * Returns the new state.\n\t */\n\tasync hatch(\n\t\tparentModel: Model<\"openai-completions\">,\n\t\tapiKey: string,\n\t\t/** Stable conversation UUID forwarded to the soul-generation request (issue 500). */\n\t\tsessionId?: string,\n\t): Promise<BuddyState> {\n\t\tconst stored = loadStored();\n\t\tconst rerollCount = stored?.rerollCount ?? 0;\n\n\t\tconst bones = rollBones(rerollCount);\n\t\tconst { name, personality, backstory } = await generateSoul(bones, parentModel, apiKey, sessionId);\n\n\t\tconst newStored: StoredCompanion = {\n\t\t\trerollCount,\n\t\t\tname,\n\t\t\tpersonality,\n\t\t\tbackstory,\n\t\t\thatchedAt: new Date().toISOString(),\n\t\t\t...(stored?.ollamaModel ? { ollamaModel: stored.ollamaModel } : {}),\n\t\t};\n\n\t\tsaveStored(newStored);\n\t\tthis.state = { ...bones, ...newStored };\n\t\treturn this.state;\n\t}\n\n\t/**\n\t * Reroll the buddy — new bones + new soul.\n\t */\n\tasync reroll(\n\t\tparentModel: Model<\"openai-completions\">,\n\t\tapiKey: string,\n\t\t/** Stable conversation UUID forwarded to the soul-generation request (issue 500). */\n\t\tsessionId?: string,\n\t): Promise<BuddyState> {\n\t\tconst stored = loadStored();\n\t\tconst newRerollCount = (stored?.rerollCount ?? 0) + 1;\n\n\t\tconst bones = rollBones(newRerollCount);\n\t\tconst { name, personality, backstory } = await generateSoul(bones, parentModel, apiKey, sessionId);\n\n\t\tconst newStored: StoredCompanion = {\n\t\t\trerollCount: newRerollCount,\n\t\t\tname,\n\t\t\tpersonality,\n\t\t\tbackstory,\n\t\t\thatchedAt: new Date().toISOString(),\n\t\t\t...(stored?.ollamaModel ? { ollamaModel: stored.ollamaModel } : {}),\n\t\t};\n\n\t\tsaveStored(newStored);\n\t\tthis.state = { ...bones, ...newStored };\n\t\treturn this.state;\n\t}\n\n\t/** Get buddy's name (for name-call detection) */\n\tgetName(): string | null {\n\t\treturn this.state?.name ?? loadStored()?.name ?? null;\n\t}\n\n\t/**\n\t * Shared Ollama chat helper. Checks availability, picks model, runs completion.\n\t * Returns the response text, or null if Ollama is unavailable or no model configured.\n\t */\n\tprivate async ollamaChat(context: Context): Promise<string | null> {\n\t\t// Check Ollama lazily, retry if previously unavailable\n\t\tif (!this.ollamaStatus || !this.ollamaStatus.available) {\n\t\t\tthis.ollamaStatus = await checkOllama();\n\t\t}\n\t\tif (!this.ollamaStatus.available) return null;\n\n\t\tconst modelName = pickOllamaModel(this.state?.ollamaModel, this.ollamaStatus.models);\n\t\tif (!modelName) return null;\n\t\tconst model: Model<\"openai-completions\"> = {\n\t\t\t...OLLAMA_MODEL_BASE,\n\t\t\tid: modelName,\n\t\t\tname: `${modelName} (Ollama)`,\n\t\t};\n\n\t\tlet response: import(\"@dreb/ai\").AssistantMessage;\n\t\ttry {\n\t\t\tresponse = await completeSimple(model, context, {\n\t\t\t\tapiKey: \"ollama\",\n\t\t\t\tsignal: AbortSignal.timeout(120000),\n\t\t\t});\n\t\t} catch {\n\t\t\t// Safety net for unexpected sync errors (e.g. provider not found).\n\t\t\t// Normal runtime errors (timeout, connection) are handled via stopReason below.\n\t\t\tthis.ollamaStatus = null;\n\t\t\treturn null;\n\t\t}\n\n\t\t// Connection error — invalidate cache so next attempt re-checks Ollama\n\t\tif (response.stopReason === \"error\") {\n\t\t\tthis.ollamaStatus = null;\n\t\t\treturn null;\n\t\t}\n\n\t\t// Timeout or abort — preserve cache (model is just slow, Ollama is fine)\n\t\tif (response.stopReason === \"aborted\") {\n\t\t\treturn null;\n\t\t}\n\n\t\tlet text = response.content\n\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t.map((c) => c.text)\n\t\t\t.join(\"\")\n\t\t\t.trim();\n\n\t\t// Truncate overly long responses\n\t\ttext = truncateResponse(text, MAX_RESPONSE_WORDS);\n\n\t\treturn text || null;\n\t}\n\n\t/**\n\t * Generate a reaction to an event using Ollama.\n\t * Returns null if Ollama is unavailable.\n\t */\n\tasync react(event: string): Promise<string | null> {\n\t\tif (!this.state) return null;\n\n\t\tconst prompt = REACTION_PROMPT.replace(\"{name}\", this.state.name)\n\t\t\t.replace(\"{species}\", this.state.species)\n\t\t\t.replace(\"{personality}\", this.state.personality)\n\t\t\t.replace(\"{backstory}\", this.state.backstory)\n\t\t\t.replace(\"{event}\", event);\n\n\t\tconst context: Context = {\n\t\t\tsystemPrompt: \"Respond with a short in-character quip. Max 20 words.\",\n\t\t\tmessages: [{ role: \"user\", content: prompt, timestamp: Date.now() }],\n\t\t};\n\n\t\treturn this.ollamaChat(context);\n\t}\n\n\t/**\n\t * Respond to the user calling the buddy's name.\n\t * Uses Ollama for the response.\n\t */\n\tasync respondToNameCall(userMessage: string, recentContext: string): Promise<string | null> {\n\t\tif (!this.state) return null;\n\n\t\tconst prompt = NAME_CALL_PROMPT.replace(\"{name}\", this.state.name)\n\t\t\t.replace(\"{species}\", this.state.species)\n\t\t\t.replace(\"{personality}\", this.state.personality)\n\t\t\t.replace(\"{backstory}\", this.state.backstory)\n\t\t\t.replace(\"{message}\", userMessage)\n\t\t\t.replace(\"{context}\", recentContext);\n\n\t\tconst context: Context = {\n\t\t\tsystemPrompt: \"Respond with a short friendly greeting. Max 30 words.\",\n\t\t\tmessages: [{ role: \"user\", content: prompt, timestamp: Date.now() }],\n\t\t};\n\n\t\treturn this.ollamaChat(context);\n\t}\n\n\t/** Get the configured Ollama model name, or null if not set */\n\tgetOllamaModel(): string | null {\n\t\treturn this.state?.ollamaModel ?? loadStored()?.ollamaModel ?? null;\n\t}\n\n\t/** Set the Ollama model for buddy reactions. Persists to disk. */\n\tsetOllamaModel(modelName: string): void {\n\t\tconst stored = loadStored();\n\t\tif (stored) {\n\t\t\tstored.ollamaModel = modelName;\n\t\t\tsaveStored(stored);\n\t\t}\n\t\tif (this.state) {\n\t\t\tthis.state.ollamaModel = modelName;\n\t\t}\n\t\t// Invalidate Ollama status cache so next call picks up the new model\n\t\tthis.ollamaStatus = null;\n\t}\n\n\t/** Reset Ollama status cache (e.g. after detecting it became available) */\n\tresetOllamaCache(): void {\n\t\tthis.ollamaStatus = null;\n\t}\n\n\t/**\n\t * Fresh-read the persisted `hidden` flag from disk (bypasses the in-memory\n\t * cache). Returns false when no buddy is stored. Used to detect a `/buddy off`\n\t * (or re-enable) performed by another concurrently-running dreb instance.\n\t */\n\tisHidden(): boolean {\n\t\treturn loadStored()?.hidden ?? false;\n\t}\n\n\t/** Update the hidden flag in persisted storage */\n\tsetHidden(hidden: boolean): void {\n\t\tconst stored = loadStored();\n\t\tif (stored) {\n\t\t\tstored.hidden = hidden;\n\t\t\tsaveStored(stored);\n\t\t}\n\t\t// Keep in-memory state in sync so reset() reads current hidden flag\n\t\tif (this.state) {\n\t\t\tthis.state.hidden = hidden;\n\t\t}\n\t}\n}\n"]}
@@ -48,6 +48,8 @@ export interface GenerateBranchSummaryOptions {
48
48
  replaceInstructions?: boolean;
49
49
  /** Tokens reserved for prompt + LLM response (default 16384) */
50
50
  reserveTokens?: number;
51
+ /** Stable conversation ID forwarded to providers that use session-based routing */
52
+ sessionId?: string;
51
53
  }
52
54
  /**
53
55
  * Collect entries that should be summarized when navigating from one position to another.
@@ -1 +1 @@
1
- {"version":3,"file":"branch-summarization.d.ts","sourceRoot":"","sources":["../../../src/core/compaction/branch-summarization.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAQtC,OAAO,KAAK,EAAE,sBAAsB,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAElF,OAAO,EAIN,KAAK,cAAc,EAInB,MAAM,YAAY,CAAC;AAMpB,MAAM,WAAW,mBAAmB;IACnC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,qEAAqE;AACrE,MAAM,WAAW,oBAAoB;IACpC,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,aAAa,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,YAAY,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjD,MAAM,WAAW,iBAAiB;IACjC,mEAAmE;IACnE,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,gDAAgD;IAChD,OAAO,EAAE,cAAc,CAAC;IACxB,yCAAyC;IACzC,WAAW,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,oBAAoB;IACpC,mDAAmD;IACnD,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,2DAA2D;IAC3D,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,4BAA4B;IAC5C,qCAAqC;IACrC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;IAClB,4BAA4B;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,oCAAoC;IACpC,MAAM,EAAE,WAAW,CAAC;IACpB,qDAAqD;IACrD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,wFAAwF;IACxF,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AAMD;;;;;;;;;;;GAWG;AACH,wBAAgB,8BAA8B,CAC7C,OAAO,EAAE,sBAAsB,EAC/B,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,QAAQ,EAAE,MAAM,GACd,oBAAoB,CAkCtB;AAoCD;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,YAAY,EAAE,EAAE,WAAW,GAAE,MAAU,GAAG,iBAAiB,CAoDxG;AAwCD;;;;;GAKG;AACH,wBAAsB,qBAAqB,CAC1C,OAAO,EAAE,YAAY,EAAE,EACvB,OAAO,EAAE,4BAA4B,GACnC,OAAO,CAAC,mBAAmB,CAAC,CAqE9B","sourcesContent":["/**\n * Branch summarization for tree navigation.\n *\n * When navigating to a different point in the session tree, this generates\n * a summary of the branch being left so context isn't lost.\n */\n\nimport type { AgentMessage } from \"@dreb/agent-core\";\nimport type { Model } from \"@dreb/ai\";\nimport { completeSimple } from \"@dreb/ai\";\nimport {\n\tconvertToLlm,\n\tcreateBranchSummaryMessage,\n\tcreateCompactionSummaryMessage,\n\tcreateCustomMessage,\n} from \"../messages.js\";\nimport type { ReadonlySessionManager, SessionEntry } from \"../session-manager.js\";\nimport { estimateTokens } from \"./compaction.js\";\nimport {\n\tcomputeFileLists,\n\tcreateFileOps,\n\textractFileOpsFromMessage,\n\ttype FileOperations,\n\tformatFileOperations,\n\tSUMMARIZATION_SYSTEM_PROMPT,\n\tserializeConversation,\n} from \"./utils.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface BranchSummaryResult {\n\tsummary?: string;\n\treadFiles?: string[];\n\tmodifiedFiles?: string[];\n\taborted?: boolean;\n\terror?: string;\n}\n\n/** Details stored in BranchSummaryEntry.details for file tracking */\nexport interface BranchSummaryDetails {\n\treadFiles: string[];\n\tmodifiedFiles: string[];\n}\n\nexport type { FileOperations } from \"./utils.js\";\n\nexport interface BranchPreparation {\n\t/** Messages extracted for summarization, in chronological order */\n\tmessages: AgentMessage[];\n\t/** File operations extracted from tool calls */\n\tfileOps: FileOperations;\n\t/** Total estimated tokens in messages */\n\ttotalTokens: number;\n}\n\nexport interface CollectEntriesResult {\n\t/** Entries to summarize, in chronological order */\n\tentries: SessionEntry[];\n\t/** Common ancestor between old and new position, if any */\n\tcommonAncestorId: string | null;\n}\n\nexport interface GenerateBranchSummaryOptions {\n\t/** Model to use for summarization */\n\tmodel: Model<any>;\n\t/** API key for the model */\n\tapiKey: string;\n\t/** Abort signal for cancellation */\n\tsignal: AbortSignal;\n\t/** Optional custom instructions for summarization */\n\tcustomInstructions?: string;\n\t/** If true, customInstructions replaces the default prompt instead of being appended */\n\treplaceInstructions?: boolean;\n\t/** Tokens reserved for prompt + LLM response (default 16384) */\n\treserveTokens?: number;\n}\n\n// ============================================================================\n// Entry Collection\n// ============================================================================\n\n/**\n * Collect entries that should be summarized when navigating from one position to another.\n *\n * Walks from oldLeafId back to the common ancestor with targetId, collecting entries\n * along the way. Does NOT stop at compaction boundaries - those are included and their\n * summaries become context.\n *\n * @param session - Session manager (read-only access)\n * @param oldLeafId - Current position (where we're navigating from)\n * @param targetId - Target position (where we're navigating to)\n * @returns Entries to summarize and the common ancestor\n */\nexport function collectEntriesForBranchSummary(\n\tsession: ReadonlySessionManager,\n\toldLeafId: string | null,\n\ttargetId: string,\n): CollectEntriesResult {\n\t// If no old position, nothing to summarize\n\tif (!oldLeafId) {\n\t\treturn { entries: [], commonAncestorId: null };\n\t}\n\n\t// Find common ancestor (deepest node that's on both paths)\n\tconst oldPath = new Set(session.getBranch(oldLeafId).map((e) => e.id));\n\tconst targetPath = session.getBranch(targetId);\n\n\t// targetPath is root-first, so iterate backwards to find deepest common ancestor\n\tlet commonAncestorId: string | null = null;\n\tfor (let i = targetPath.length - 1; i >= 0; i--) {\n\t\tif (oldPath.has(targetPath[i].id)) {\n\t\t\tcommonAncestorId = targetPath[i].id;\n\t\t\tbreak;\n\t\t}\n\t}\n\n\t// Collect entries from old leaf back to common ancestor\n\tconst entries: SessionEntry[] = [];\n\tlet current: string | null = oldLeafId;\n\n\twhile (current && current !== commonAncestorId) {\n\t\tconst entry = session.getEntry(current);\n\t\tif (!entry) break;\n\t\tentries.push(entry);\n\t\tcurrent = entry.parentId;\n\t}\n\n\t// Reverse to get chronological order\n\tentries.reverse();\n\n\treturn { entries, commonAncestorId };\n}\n\n// ============================================================================\n// Entry to Message Conversion\n// ============================================================================\n\n/**\n * Extract AgentMessage from a session entry.\n * Similar to getMessageFromEntry in compaction.ts but also handles compaction entries.\n */\nfunction getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {\n\tswitch (entry.type) {\n\t\tcase \"message\":\n\t\t\t// Skip tool results - context is in assistant's tool call\n\t\t\tif (entry.message.role === \"toolResult\") return undefined;\n\t\t\treturn entry.message;\n\n\t\tcase \"custom_message\":\n\t\t\treturn createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);\n\n\t\tcase \"branch_summary\":\n\t\t\treturn createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);\n\n\t\tcase \"compaction\":\n\t\t\treturn createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);\n\n\t\t// These don't contribute to conversation content\n\t\tcase \"thinking_level_change\":\n\t\tcase \"model_change\":\n\t\tcase \"custom\":\n\t\tcase \"label\":\n\t\tcase \"session_info\":\n\t\t\treturn undefined;\n\t}\n}\n\n/**\n * Prepare entries for summarization with token budget.\n *\n * Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget.\n * This ensures we keep the most recent context when the branch is too long.\n *\n * Also collects file operations from:\n * - Tool calls in assistant messages\n * - Existing branch_summary entries' details (for cumulative tracking)\n *\n * @param entries - Entries in chronological order\n * @param tokenBudget - Maximum tokens to include (0 = no limit)\n */\nexport function prepareBranchEntries(entries: SessionEntry[], tokenBudget: number = 0): BranchPreparation {\n\tconst messages: AgentMessage[] = [];\n\tconst fileOps = createFileOps();\n\tlet totalTokens = 0;\n\n\t// First pass: collect file ops from ALL entries (even if they don't fit in token budget)\n\t// This ensures we capture cumulative file tracking from nested branch summaries\n\t// Only extract from dreb-generated summaries (fromHook !== true), not extension-generated ones\n\tfor (const entry of entries) {\n\t\tif (entry.type === \"branch_summary\" && !entry.fromHook && entry.details) {\n\t\t\tconst details = entry.details as BranchSummaryDetails;\n\t\t\tif (Array.isArray(details.readFiles)) {\n\t\t\t\tfor (const f of details.readFiles) fileOps.read.add(f);\n\t\t\t}\n\t\t\tif (Array.isArray(details.modifiedFiles)) {\n\t\t\t\t// Modified files go into both edited and written for proper deduplication\n\t\t\t\tfor (const f of details.modifiedFiles) {\n\t\t\t\t\tfileOps.edited.add(f);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Second pass: walk from newest to oldest, adding messages until token budget\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tconst entry = entries[i];\n\t\tconst message = getMessageFromEntry(entry);\n\t\tif (!message) continue;\n\n\t\t// Extract file ops from assistant messages (tool calls)\n\t\textractFileOpsFromMessage(message, fileOps);\n\n\t\tconst tokens = estimateTokens(message);\n\n\t\t// Check budget before adding\n\t\tif (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {\n\t\t\t// If this is a summary entry, try to fit it anyway as it's important context\n\t\t\tif (entry.type === \"compaction\" || entry.type === \"branch_summary\") {\n\t\t\t\tif (totalTokens < tokenBudget * 0.9) {\n\t\t\t\t\tmessages.unshift(message);\n\t\t\t\t\ttotalTokens += tokens;\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Stop - we've hit the budget\n\t\t\tbreak;\n\t\t}\n\n\t\tmessages.unshift(message);\n\t\ttotalTokens += tokens;\n\t}\n\n\treturn { messages, fileOps, totalTokens };\n}\n\n// ============================================================================\n// Summary Generation\n// ============================================================================\n\nconst BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.\nSummary of that exploration:\n\n`;\n\nconst BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later.\n\nUse this EXACT format:\n\n## Goal\n[What was the user trying to accomplish in this branch?]\n\n## Constraints & Preferences\n- [Any constraints, preferences, or requirements mentioned]\n- [Or \"(none)\" if none were mentioned]\n\n## Progress\n### Done\n- [x] [Completed tasks/changes]\n\n### In Progress\n- [ ] [Work that was started but not finished]\n\n### Blocked\n- [Issues preventing progress, if any]\n\n## Key Decisions\n- **[Decision]**: [Brief rationale]\n\n## Next Steps\n1. [What should happen next to continue this work]\n\nKeep each section concise. Preserve exact file paths, function names, and error messages.`;\n\n/**\n * Generate a summary of abandoned branch entries.\n *\n * @param entries - Session entries to summarize (chronological order)\n * @param options - Generation options\n */\nexport async function generateBranchSummary(\n\tentries: SessionEntry[],\n\toptions: GenerateBranchSummaryOptions,\n): Promise<BranchSummaryResult> {\n\tconst { model, apiKey, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options;\n\n\t// Token budget = context window minus reserved space for prompt + response\n\tconst contextWindow = model.contextWindow || 128000;\n\tconst tokenBudget = contextWindow - reserveTokens;\n\n\tconst { messages, fileOps } = prepareBranchEntries(entries, tokenBudget);\n\n\tif (messages.length === 0) {\n\t\treturn { summary: \"No content to summarize\" };\n\t}\n\n\t// Transform to LLM-compatible messages, then serialize to text\n\t// Serialization prevents the model from treating it as a conversation to continue\n\tconst llmMessages = convertToLlm(messages);\n\tconst conversationText = serializeConversation(llmMessages);\n\n\t// Build prompt\n\tlet instructions: string;\n\tif (replaceInstructions && customInstructions) {\n\t\tinstructions = customInstructions;\n\t} else if (customInstructions) {\n\t\tinstructions = `${BRANCH_SUMMARY_PROMPT}\\n\\nAdditional focus: ${customInstructions}`;\n\t} else {\n\t\tinstructions = BRANCH_SUMMARY_PROMPT;\n\t}\n\tconst promptText = `<conversation>\\n${conversationText}\\n</conversation>\\n\\n${instructions}`;\n\n\tconst summarizationMessages = [\n\t\t{\n\t\t\trole: \"user\" as const,\n\t\t\tcontent: [{ type: \"text\" as const, text: promptText }],\n\t\t\ttimestamp: Date.now(),\n\t\t},\n\t];\n\n\t// Call LLM for summarization\n\tconst response = await completeSimple(\n\t\tmodel,\n\t\t{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },\n\t\t{ apiKey, signal, maxTokens: 2048 },\n\t);\n\n\t// Check if aborted or errored\n\tif (response.stopReason === \"aborted\") {\n\t\treturn { aborted: true };\n\t}\n\tif (response.stopReason === \"error\") {\n\t\treturn { error: response.errorMessage || \"Summarization failed\" };\n\t}\n\n\tlet summary = response.content\n\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t.map((c) => c.text)\n\t\t.join(\"\\n\");\n\n\t// Prepend preamble to provide context about the branch summary\n\tsummary = BRANCH_SUMMARY_PREAMBLE + summary;\n\n\t// Compute file lists and append to summary\n\tconst { readFiles, modifiedFiles } = computeFileLists(fileOps);\n\tsummary += formatFileOperations(readFiles, modifiedFiles);\n\n\treturn {\n\t\tsummary: summary || \"No summary generated\",\n\t\treadFiles,\n\t\tmodifiedFiles,\n\t};\n}\n"]}
1
+ {"version":3,"file":"branch-summarization.d.ts","sourceRoot":"","sources":["../../../src/core/compaction/branch-summarization.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAQtC,OAAO,KAAK,EAAE,sBAAsB,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAElF,OAAO,EAIN,KAAK,cAAc,EAInB,MAAM,YAAY,CAAC;AAMpB,MAAM,WAAW,mBAAmB;IACnC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,qEAAqE;AACrE,MAAM,WAAW,oBAAoB;IACpC,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,aAAa,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,YAAY,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjD,MAAM,WAAW,iBAAiB;IACjC,mEAAmE;IACnE,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,gDAAgD;IAChD,OAAO,EAAE,cAAc,CAAC;IACxB,yCAAyC;IACzC,WAAW,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,oBAAoB;IACpC,mDAAmD;IACnD,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,2DAA2D;IAC3D,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,4BAA4B;IAC5C,qCAAqC;IACrC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;IAClB,4BAA4B;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,oCAAoC;IACpC,MAAM,EAAE,WAAW,CAAC;IACpB,qDAAqD;IACrD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,wFAAwF;IACxF,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,mFAAmF;IACnF,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AAMD;;;;;;;;;;;GAWG;AACH,wBAAgB,8BAA8B,CAC7C,OAAO,EAAE,sBAAsB,EAC/B,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,QAAQ,EAAE,MAAM,GACd,oBAAoB,CAkCtB;AAoCD;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,YAAY,EAAE,EAAE,WAAW,GAAE,MAAU,GAAG,iBAAiB,CAoDxG;AAwCD;;;;;GAKG;AACH,wBAAsB,qBAAqB,CAC1C,OAAO,EAAE,YAAY,EAAE,EACvB,OAAO,EAAE,4BAA4B,GACnC,OAAO,CAAC,mBAAmB,CAAC,CAqE9B","sourcesContent":["/**\n * Branch summarization for tree navigation.\n *\n * When navigating to a different point in the session tree, this generates\n * a summary of the branch being left so context isn't lost.\n */\n\nimport type { AgentMessage } from \"@dreb/agent-core\";\nimport type { Model } from \"@dreb/ai\";\nimport { completeSimple } from \"@dreb/ai\";\nimport {\n\tconvertToLlm,\n\tcreateBranchSummaryMessage,\n\tcreateCompactionSummaryMessage,\n\tcreateCustomMessage,\n} from \"../messages.js\";\nimport type { ReadonlySessionManager, SessionEntry } from \"../session-manager.js\";\nimport { estimateTokens } from \"./compaction.js\";\nimport {\n\tcomputeFileLists,\n\tcreateFileOps,\n\textractFileOpsFromMessage,\n\ttype FileOperations,\n\tformatFileOperations,\n\tSUMMARIZATION_SYSTEM_PROMPT,\n\tserializeConversation,\n} from \"./utils.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface BranchSummaryResult {\n\tsummary?: string;\n\treadFiles?: string[];\n\tmodifiedFiles?: string[];\n\taborted?: boolean;\n\terror?: string;\n}\n\n/** Details stored in BranchSummaryEntry.details for file tracking */\nexport interface BranchSummaryDetails {\n\treadFiles: string[];\n\tmodifiedFiles: string[];\n}\n\nexport type { FileOperations } from \"./utils.js\";\n\nexport interface BranchPreparation {\n\t/** Messages extracted for summarization, in chronological order */\n\tmessages: AgentMessage[];\n\t/** File operations extracted from tool calls */\n\tfileOps: FileOperations;\n\t/** Total estimated tokens in messages */\n\ttotalTokens: number;\n}\n\nexport interface CollectEntriesResult {\n\t/** Entries to summarize, in chronological order */\n\tentries: SessionEntry[];\n\t/** Common ancestor between old and new position, if any */\n\tcommonAncestorId: string | null;\n}\n\nexport interface GenerateBranchSummaryOptions {\n\t/** Model to use for summarization */\n\tmodel: Model<any>;\n\t/** API key for the model */\n\tapiKey: string;\n\t/** Abort signal for cancellation */\n\tsignal: AbortSignal;\n\t/** Optional custom instructions for summarization */\n\tcustomInstructions?: string;\n\t/** If true, customInstructions replaces the default prompt instead of being appended */\n\treplaceInstructions?: boolean;\n\t/** Tokens reserved for prompt + LLM response (default 16384) */\n\treserveTokens?: number;\n\t/** Stable conversation ID forwarded to providers that use session-based routing */\n\tsessionId?: string;\n}\n\n// ============================================================================\n// Entry Collection\n// ============================================================================\n\n/**\n * Collect entries that should be summarized when navigating from one position to another.\n *\n * Walks from oldLeafId back to the common ancestor with targetId, collecting entries\n * along the way. Does NOT stop at compaction boundaries - those are included and their\n * summaries become context.\n *\n * @param session - Session manager (read-only access)\n * @param oldLeafId - Current position (where we're navigating from)\n * @param targetId - Target position (where we're navigating to)\n * @returns Entries to summarize and the common ancestor\n */\nexport function collectEntriesForBranchSummary(\n\tsession: ReadonlySessionManager,\n\toldLeafId: string | null,\n\ttargetId: string,\n): CollectEntriesResult {\n\t// If no old position, nothing to summarize\n\tif (!oldLeafId) {\n\t\treturn { entries: [], commonAncestorId: null };\n\t}\n\n\t// Find common ancestor (deepest node that's on both paths)\n\tconst oldPath = new Set(session.getBranch(oldLeafId).map((e) => e.id));\n\tconst targetPath = session.getBranch(targetId);\n\n\t// targetPath is root-first, so iterate backwards to find deepest common ancestor\n\tlet commonAncestorId: string | null = null;\n\tfor (let i = targetPath.length - 1; i >= 0; i--) {\n\t\tif (oldPath.has(targetPath[i].id)) {\n\t\t\tcommonAncestorId = targetPath[i].id;\n\t\t\tbreak;\n\t\t}\n\t}\n\n\t// Collect entries from old leaf back to common ancestor\n\tconst entries: SessionEntry[] = [];\n\tlet current: string | null = oldLeafId;\n\n\twhile (current && current !== commonAncestorId) {\n\t\tconst entry = session.getEntry(current);\n\t\tif (!entry) break;\n\t\tentries.push(entry);\n\t\tcurrent = entry.parentId;\n\t}\n\n\t// Reverse to get chronological order\n\tentries.reverse();\n\n\treturn { entries, commonAncestorId };\n}\n\n// ============================================================================\n// Entry to Message Conversion\n// ============================================================================\n\n/**\n * Extract AgentMessage from a session entry.\n * Similar to getMessageFromEntry in compaction.ts but also handles compaction entries.\n */\nfunction getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {\n\tswitch (entry.type) {\n\t\tcase \"message\":\n\t\t\t// Skip tool results - context is in assistant's tool call\n\t\t\tif (entry.message.role === \"toolResult\") return undefined;\n\t\t\treturn entry.message;\n\n\t\tcase \"custom_message\":\n\t\t\treturn createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);\n\n\t\tcase \"branch_summary\":\n\t\t\treturn createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);\n\n\t\tcase \"compaction\":\n\t\t\treturn createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);\n\n\t\t// These don't contribute to conversation content\n\t\tcase \"thinking_level_change\":\n\t\tcase \"model_change\":\n\t\tcase \"custom\":\n\t\tcase \"label\":\n\t\tcase \"session_info\":\n\t\t\treturn undefined;\n\t}\n}\n\n/**\n * Prepare entries for summarization with token budget.\n *\n * Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget.\n * This ensures we keep the most recent context when the branch is too long.\n *\n * Also collects file operations from:\n * - Tool calls in assistant messages\n * - Existing branch_summary entries' details (for cumulative tracking)\n *\n * @param entries - Entries in chronological order\n * @param tokenBudget - Maximum tokens to include (0 = no limit)\n */\nexport function prepareBranchEntries(entries: SessionEntry[], tokenBudget: number = 0): BranchPreparation {\n\tconst messages: AgentMessage[] = [];\n\tconst fileOps = createFileOps();\n\tlet totalTokens = 0;\n\n\t// First pass: collect file ops from ALL entries (even if they don't fit in token budget)\n\t// This ensures we capture cumulative file tracking from nested branch summaries\n\t// Only extract from dreb-generated summaries (fromHook !== true), not extension-generated ones\n\tfor (const entry of entries) {\n\t\tif (entry.type === \"branch_summary\" && !entry.fromHook && entry.details) {\n\t\t\tconst details = entry.details as BranchSummaryDetails;\n\t\t\tif (Array.isArray(details.readFiles)) {\n\t\t\t\tfor (const f of details.readFiles) fileOps.read.add(f);\n\t\t\t}\n\t\t\tif (Array.isArray(details.modifiedFiles)) {\n\t\t\t\t// Modified files go into both edited and written for proper deduplication\n\t\t\t\tfor (const f of details.modifiedFiles) {\n\t\t\t\t\tfileOps.edited.add(f);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Second pass: walk from newest to oldest, adding messages until token budget\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tconst entry = entries[i];\n\t\tconst message = getMessageFromEntry(entry);\n\t\tif (!message) continue;\n\n\t\t// Extract file ops from assistant messages (tool calls)\n\t\textractFileOpsFromMessage(message, fileOps);\n\n\t\tconst tokens = estimateTokens(message);\n\n\t\t// Check budget before adding\n\t\tif (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {\n\t\t\t// If this is a summary entry, try to fit it anyway as it's important context\n\t\t\tif (entry.type === \"compaction\" || entry.type === \"branch_summary\") {\n\t\t\t\tif (totalTokens < tokenBudget * 0.9) {\n\t\t\t\t\tmessages.unshift(message);\n\t\t\t\t\ttotalTokens += tokens;\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Stop - we've hit the budget\n\t\t\tbreak;\n\t\t}\n\n\t\tmessages.unshift(message);\n\t\ttotalTokens += tokens;\n\t}\n\n\treturn { messages, fileOps, totalTokens };\n}\n\n// ============================================================================\n// Summary Generation\n// ============================================================================\n\nconst BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.\nSummary of that exploration:\n\n`;\n\nconst BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later.\n\nUse this EXACT format:\n\n## Goal\n[What was the user trying to accomplish in this branch?]\n\n## Constraints & Preferences\n- [Any constraints, preferences, or requirements mentioned]\n- [Or \"(none)\" if none were mentioned]\n\n## Progress\n### Done\n- [x] [Completed tasks/changes]\n\n### In Progress\n- [ ] [Work that was started but not finished]\n\n### Blocked\n- [Issues preventing progress, if any]\n\n## Key Decisions\n- **[Decision]**: [Brief rationale]\n\n## Next Steps\n1. [What should happen next to continue this work]\n\nKeep each section concise. Preserve exact file paths, function names, and error messages.`;\n\n/**\n * Generate a summary of abandoned branch entries.\n *\n * @param entries - Session entries to summarize (chronological order)\n * @param options - Generation options\n */\nexport async function generateBranchSummary(\n\tentries: SessionEntry[],\n\toptions: GenerateBranchSummaryOptions,\n): Promise<BranchSummaryResult> {\n\tconst { model, apiKey, signal, customInstructions, replaceInstructions, reserveTokens = 16384, sessionId } = options;\n\n\t// Token budget = context window minus reserved space for prompt + response\n\tconst contextWindow = model.contextWindow || 128000;\n\tconst tokenBudget = contextWindow - reserveTokens;\n\n\tconst { messages, fileOps } = prepareBranchEntries(entries, tokenBudget);\n\n\tif (messages.length === 0) {\n\t\treturn { summary: \"No content to summarize\" };\n\t}\n\n\t// Transform to LLM-compatible messages, then serialize to text\n\t// Serialization prevents the model from treating it as a conversation to continue\n\tconst llmMessages = convertToLlm(messages);\n\tconst conversationText = serializeConversation(llmMessages);\n\n\t// Build prompt\n\tlet instructions: string;\n\tif (replaceInstructions && customInstructions) {\n\t\tinstructions = customInstructions;\n\t} else if (customInstructions) {\n\t\tinstructions = `${BRANCH_SUMMARY_PROMPT}\\n\\nAdditional focus: ${customInstructions}`;\n\t} else {\n\t\tinstructions = BRANCH_SUMMARY_PROMPT;\n\t}\n\tconst promptText = `<conversation>\\n${conversationText}\\n</conversation>\\n\\n${instructions}`;\n\n\tconst summarizationMessages = [\n\t\t{\n\t\t\trole: \"user\" as const,\n\t\t\tcontent: [{ type: \"text\" as const, text: promptText }],\n\t\t\ttimestamp: Date.now(),\n\t\t},\n\t];\n\n\t// Call LLM for summarization\n\tconst response = await completeSimple(\n\t\tmodel,\n\t\t{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },\n\t\t{ apiKey, signal, maxTokens: 2048, ...(sessionId ? { sessionId } : {}) },\n\t);\n\n\t// Check if aborted or errored\n\tif (response.stopReason === \"aborted\") {\n\t\treturn { aborted: true };\n\t}\n\tif (response.stopReason === \"error\") {\n\t\treturn { error: response.errorMessage || \"Summarization failed\" };\n\t}\n\n\tlet summary = response.content\n\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t.map((c) => c.text)\n\t\t.join(\"\\n\");\n\n\t// Prepend preamble to provide context about the branch summary\n\tsummary = BRANCH_SUMMARY_PREAMBLE + summary;\n\n\t// Compute file lists and append to summary\n\tconst { readFiles, modifiedFiles } = computeFileLists(fileOps);\n\tsummary += formatFileOperations(readFiles, modifiedFiles);\n\n\treturn {\n\t\tsummary: summary || \"No summary generated\",\n\t\treadFiles,\n\t\tmodifiedFiles,\n\t};\n}\n"]}
@@ -185,7 +185,7 @@ Keep each section concise. Preserve exact file paths, function names, and error
185
185
  * @param options - Generation options
186
186
  */
187
187
  export async function generateBranchSummary(entries, options) {
188
- const { model, apiKey, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options;
188
+ const { model, apiKey, signal, customInstructions, replaceInstructions, reserveTokens = 16384, sessionId } = options;
189
189
  // Token budget = context window minus reserved space for prompt + response
190
190
  const contextWindow = model.contextWindow || 128000;
191
191
  const tokenBudget = contextWindow - reserveTokens;
@@ -217,7 +217,7 @@ export async function generateBranchSummary(entries, options) {
217
217
  },
218
218
  ];
219
219
  // Call LLM for summarization
220
- const response = await completeSimple(model, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, { apiKey, signal, maxTokens: 2048 });
220
+ const response = await completeSimple(model, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, { apiKey, signal, maxTokens: 2048, ...(sessionId ? { sessionId } : {}) });
221
221
  // Check if aborted or errored
222
222
  if (response.stopReason === "aborted") {
223
223
  return { aborted: true };
@@ -1 +1 @@
1
- {"version":3,"file":"branch-summarization.js","sourceRoot":"","sources":["../../../src/core/compaction/branch-summarization.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Branch summarization for tree navigation.\n *\n * When navigating to a different point in the session tree, this generates\n * a summary of the branch being left so context isn't lost.\n */\n\nimport type { AgentMessage } from \"@dreb/agent-core\";\nimport type { Model } from \"@dreb/ai\";\nimport { completeSimple } from \"@dreb/ai\";\nimport {\n\tconvertToLlm,\n\tcreateBranchSummaryMessage,\n\tcreateCompactionSummaryMessage,\n\tcreateCustomMessage,\n} from \"../messages.js\";\nimport type { ReadonlySessionManager, SessionEntry } from \"../session-manager.js\";\nimport { estimateTokens } from \"./compaction.js\";\nimport {\n\tcomputeFileLists,\n\tcreateFileOps,\n\textractFileOpsFromMessage,\n\ttype FileOperations,\n\tformatFileOperations,\n\tSUMMARIZATION_SYSTEM_PROMPT,\n\tserializeConversation,\n} from \"./utils.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface BranchSummaryResult {\n\tsummary?: string;\n\treadFiles?: string[];\n\tmodifiedFiles?: string[];\n\taborted?: boolean;\n\terror?: string;\n}\n\n/** Details stored in BranchSummaryEntry.details for file tracking */\nexport interface BranchSummaryDetails {\n\treadFiles: string[];\n\tmodifiedFiles: string[];\n}\n\nexport type { FileOperations } from \"./utils.js\";\n\nexport interface BranchPreparation {\n\t/** Messages extracted for summarization, in chronological order */\n\tmessages: AgentMessage[];\n\t/** File operations extracted from tool calls */\n\tfileOps: FileOperations;\n\t/** Total estimated tokens in messages */\n\ttotalTokens: number;\n}\n\nexport interface CollectEntriesResult {\n\t/** Entries to summarize, in chronological order */\n\tentries: SessionEntry[];\n\t/** Common ancestor between old and new position, if any */\n\tcommonAncestorId: string | null;\n}\n\nexport interface GenerateBranchSummaryOptions {\n\t/** Model to use for summarization */\n\tmodel: Model<any>;\n\t/** API key for the model */\n\tapiKey: string;\n\t/** Abort signal for cancellation */\n\tsignal: AbortSignal;\n\t/** Optional custom instructions for summarization */\n\tcustomInstructions?: string;\n\t/** If true, customInstructions replaces the default prompt instead of being appended */\n\treplaceInstructions?: boolean;\n\t/** Tokens reserved for prompt + LLM response (default 16384) */\n\treserveTokens?: number;\n}\n\n// ============================================================================\n// Entry Collection\n// ============================================================================\n\n/**\n * Collect entries that should be summarized when navigating from one position to another.\n *\n * Walks from oldLeafId back to the common ancestor with targetId, collecting entries\n * along the way. Does NOT stop at compaction boundaries - those are included and their\n * summaries become context.\n *\n * @param session - Session manager (read-only access)\n * @param oldLeafId - Current position (where we're navigating from)\n * @param targetId - Target position (where we're navigating to)\n * @returns Entries to summarize and the common ancestor\n */\nexport function collectEntriesForBranchSummary(\n\tsession: ReadonlySessionManager,\n\toldLeafId: string | null,\n\ttargetId: string,\n): CollectEntriesResult {\n\t// If no old position, nothing to summarize\n\tif (!oldLeafId) {\n\t\treturn { entries: [], commonAncestorId: null };\n\t}\n\n\t// Find common ancestor (deepest node that's on both paths)\n\tconst oldPath = new Set(session.getBranch(oldLeafId).map((e) => e.id));\n\tconst targetPath = session.getBranch(targetId);\n\n\t// targetPath is root-first, so iterate backwards to find deepest common ancestor\n\tlet commonAncestorId: string | null = null;\n\tfor (let i = targetPath.length - 1; i >= 0; i--) {\n\t\tif (oldPath.has(targetPath[i].id)) {\n\t\t\tcommonAncestorId = targetPath[i].id;\n\t\t\tbreak;\n\t\t}\n\t}\n\n\t// Collect entries from old leaf back to common ancestor\n\tconst entries: SessionEntry[] = [];\n\tlet current: string | null = oldLeafId;\n\n\twhile (current && current !== commonAncestorId) {\n\t\tconst entry = session.getEntry(current);\n\t\tif (!entry) break;\n\t\tentries.push(entry);\n\t\tcurrent = entry.parentId;\n\t}\n\n\t// Reverse to get chronological order\n\tentries.reverse();\n\n\treturn { entries, commonAncestorId };\n}\n\n// ============================================================================\n// Entry to Message Conversion\n// ============================================================================\n\n/**\n * Extract AgentMessage from a session entry.\n * Similar to getMessageFromEntry in compaction.ts but also handles compaction entries.\n */\nfunction getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {\n\tswitch (entry.type) {\n\t\tcase \"message\":\n\t\t\t// Skip tool results - context is in assistant's tool call\n\t\t\tif (entry.message.role === \"toolResult\") return undefined;\n\t\t\treturn entry.message;\n\n\t\tcase \"custom_message\":\n\t\t\treturn createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);\n\n\t\tcase \"branch_summary\":\n\t\t\treturn createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);\n\n\t\tcase \"compaction\":\n\t\t\treturn createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);\n\n\t\t// These don't contribute to conversation content\n\t\tcase \"thinking_level_change\":\n\t\tcase \"model_change\":\n\t\tcase \"custom\":\n\t\tcase \"label\":\n\t\tcase \"session_info\":\n\t\t\treturn undefined;\n\t}\n}\n\n/**\n * Prepare entries for summarization with token budget.\n *\n * Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget.\n * This ensures we keep the most recent context when the branch is too long.\n *\n * Also collects file operations from:\n * - Tool calls in assistant messages\n * - Existing branch_summary entries' details (for cumulative tracking)\n *\n * @param entries - Entries in chronological order\n * @param tokenBudget - Maximum tokens to include (0 = no limit)\n */\nexport function prepareBranchEntries(entries: SessionEntry[], tokenBudget: number = 0): BranchPreparation {\n\tconst messages: AgentMessage[] = [];\n\tconst fileOps = createFileOps();\n\tlet totalTokens = 0;\n\n\t// First pass: collect file ops from ALL entries (even if they don't fit in token budget)\n\t// This ensures we capture cumulative file tracking from nested branch summaries\n\t// Only extract from dreb-generated summaries (fromHook !== true), not extension-generated ones\n\tfor (const entry of entries) {\n\t\tif (entry.type === \"branch_summary\" && !entry.fromHook && entry.details) {\n\t\t\tconst details = entry.details as BranchSummaryDetails;\n\t\t\tif (Array.isArray(details.readFiles)) {\n\t\t\t\tfor (const f of details.readFiles) fileOps.read.add(f);\n\t\t\t}\n\t\t\tif (Array.isArray(details.modifiedFiles)) {\n\t\t\t\t// Modified files go into both edited and written for proper deduplication\n\t\t\t\tfor (const f of details.modifiedFiles) {\n\t\t\t\t\tfileOps.edited.add(f);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Second pass: walk from newest to oldest, adding messages until token budget\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tconst entry = entries[i];\n\t\tconst message = getMessageFromEntry(entry);\n\t\tif (!message) continue;\n\n\t\t// Extract file ops from assistant messages (tool calls)\n\t\textractFileOpsFromMessage(message, fileOps);\n\n\t\tconst tokens = estimateTokens(message);\n\n\t\t// Check budget before adding\n\t\tif (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {\n\t\t\t// If this is a summary entry, try to fit it anyway as it's important context\n\t\t\tif (entry.type === \"compaction\" || entry.type === \"branch_summary\") {\n\t\t\t\tif (totalTokens < tokenBudget * 0.9) {\n\t\t\t\t\tmessages.unshift(message);\n\t\t\t\t\ttotalTokens += tokens;\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Stop - we've hit the budget\n\t\t\tbreak;\n\t\t}\n\n\t\tmessages.unshift(message);\n\t\ttotalTokens += tokens;\n\t}\n\n\treturn { messages, fileOps, totalTokens };\n}\n\n// ============================================================================\n// Summary Generation\n// ============================================================================\n\nconst BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.\nSummary of that exploration:\n\n`;\n\nconst BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later.\n\nUse this EXACT format:\n\n## Goal\n[What was the user trying to accomplish in this branch?]\n\n## Constraints & Preferences\n- [Any constraints, preferences, or requirements mentioned]\n- [Or \"(none)\" if none were mentioned]\n\n## Progress\n### Done\n- [x] [Completed tasks/changes]\n\n### In Progress\n- [ ] [Work that was started but not finished]\n\n### Blocked\n- [Issues preventing progress, if any]\n\n## Key Decisions\n- **[Decision]**: [Brief rationale]\n\n## Next Steps\n1. [What should happen next to continue this work]\n\nKeep each section concise. Preserve exact file paths, function names, and error messages.`;\n\n/**\n * Generate a summary of abandoned branch entries.\n *\n * @param entries - Session entries to summarize (chronological order)\n * @param options - Generation options\n */\nexport async function generateBranchSummary(\n\tentries: SessionEntry[],\n\toptions: GenerateBranchSummaryOptions,\n): Promise<BranchSummaryResult> {\n\tconst { model, apiKey, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options;\n\n\t// Token budget = context window minus reserved space for prompt + response\n\tconst contextWindow = model.contextWindow || 128000;\n\tconst tokenBudget = contextWindow - reserveTokens;\n\n\tconst { messages, fileOps } = prepareBranchEntries(entries, tokenBudget);\n\n\tif (messages.length === 0) {\n\t\treturn { summary: \"No content to summarize\" };\n\t}\n\n\t// Transform to LLM-compatible messages, then serialize to text\n\t// Serialization prevents the model from treating it as a conversation to continue\n\tconst llmMessages = convertToLlm(messages);\n\tconst conversationText = serializeConversation(llmMessages);\n\n\t// Build prompt\n\tlet instructions: string;\n\tif (replaceInstructions && customInstructions) {\n\t\tinstructions = customInstructions;\n\t} else if (customInstructions) {\n\t\tinstructions = `${BRANCH_SUMMARY_PROMPT}\\n\\nAdditional focus: ${customInstructions}`;\n\t} else {\n\t\tinstructions = BRANCH_SUMMARY_PROMPT;\n\t}\n\tconst promptText = `<conversation>\\n${conversationText}\\n</conversation>\\n\\n${instructions}`;\n\n\tconst summarizationMessages = [\n\t\t{\n\t\t\trole: \"user\" as const,\n\t\t\tcontent: [{ type: \"text\" as const, text: promptText }],\n\t\t\ttimestamp: Date.now(),\n\t\t},\n\t];\n\n\t// Call LLM for summarization\n\tconst response = await completeSimple(\n\t\tmodel,\n\t\t{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },\n\t\t{ apiKey, signal, maxTokens: 2048 },\n\t);\n\n\t// Check if aborted or errored\n\tif (response.stopReason === \"aborted\") {\n\t\treturn { aborted: true };\n\t}\n\tif (response.stopReason === \"error\") {\n\t\treturn { error: response.errorMessage || \"Summarization failed\" };\n\t}\n\n\tlet summary = response.content\n\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t.map((c) => c.text)\n\t\t.join(\"\\n\");\n\n\t// Prepend preamble to provide context about the branch summary\n\tsummary = BRANCH_SUMMARY_PREAMBLE + summary;\n\n\t// Compute file lists and append to summary\n\tconst { readFiles, modifiedFiles } = computeFileLists(fileOps);\n\tsummary += formatFileOperations(readFiles, modifiedFiles);\n\n\treturn {\n\t\tsummary: summary || \"No summary generated\",\n\t\treadFiles,\n\t\tmodifiedFiles,\n\t};\n}\n"]}
1
+ {"version":3,"file":"branch-summarization.js","sourceRoot":"","sources":["../../../src/core/compaction/branch-summarization.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Branch summarization for tree navigation.\n *\n * When navigating to a different point in the session tree, this generates\n * a summary of the branch being left so context isn't lost.\n */\n\nimport type { AgentMessage } from \"@dreb/agent-core\";\nimport type { Model } from \"@dreb/ai\";\nimport { completeSimple } from \"@dreb/ai\";\nimport {\n\tconvertToLlm,\n\tcreateBranchSummaryMessage,\n\tcreateCompactionSummaryMessage,\n\tcreateCustomMessage,\n} from \"../messages.js\";\nimport type { ReadonlySessionManager, SessionEntry } from \"../session-manager.js\";\nimport { estimateTokens } from \"./compaction.js\";\nimport {\n\tcomputeFileLists,\n\tcreateFileOps,\n\textractFileOpsFromMessage,\n\ttype FileOperations,\n\tformatFileOperations,\n\tSUMMARIZATION_SYSTEM_PROMPT,\n\tserializeConversation,\n} from \"./utils.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface BranchSummaryResult {\n\tsummary?: string;\n\treadFiles?: string[];\n\tmodifiedFiles?: string[];\n\taborted?: boolean;\n\terror?: string;\n}\n\n/** Details stored in BranchSummaryEntry.details for file tracking */\nexport interface BranchSummaryDetails {\n\treadFiles: string[];\n\tmodifiedFiles: string[];\n}\n\nexport type { FileOperations } from \"./utils.js\";\n\nexport interface BranchPreparation {\n\t/** Messages extracted for summarization, in chronological order */\n\tmessages: AgentMessage[];\n\t/** File operations extracted from tool calls */\n\tfileOps: FileOperations;\n\t/** Total estimated tokens in messages */\n\ttotalTokens: number;\n}\n\nexport interface CollectEntriesResult {\n\t/** Entries to summarize, in chronological order */\n\tentries: SessionEntry[];\n\t/** Common ancestor between old and new position, if any */\n\tcommonAncestorId: string | null;\n}\n\nexport interface GenerateBranchSummaryOptions {\n\t/** Model to use for summarization */\n\tmodel: Model<any>;\n\t/** API key for the model */\n\tapiKey: string;\n\t/** Abort signal for cancellation */\n\tsignal: AbortSignal;\n\t/** Optional custom instructions for summarization */\n\tcustomInstructions?: string;\n\t/** If true, customInstructions replaces the default prompt instead of being appended */\n\treplaceInstructions?: boolean;\n\t/** Tokens reserved for prompt + LLM response (default 16384) */\n\treserveTokens?: number;\n\t/** Stable conversation ID forwarded to providers that use session-based routing */\n\tsessionId?: string;\n}\n\n// ============================================================================\n// Entry Collection\n// ============================================================================\n\n/**\n * Collect entries that should be summarized when navigating from one position to another.\n *\n * Walks from oldLeafId back to the common ancestor with targetId, collecting entries\n * along the way. Does NOT stop at compaction boundaries - those are included and their\n * summaries become context.\n *\n * @param session - Session manager (read-only access)\n * @param oldLeafId - Current position (where we're navigating from)\n * @param targetId - Target position (where we're navigating to)\n * @returns Entries to summarize and the common ancestor\n */\nexport function collectEntriesForBranchSummary(\n\tsession: ReadonlySessionManager,\n\toldLeafId: string | null,\n\ttargetId: string,\n): CollectEntriesResult {\n\t// If no old position, nothing to summarize\n\tif (!oldLeafId) {\n\t\treturn { entries: [], commonAncestorId: null };\n\t}\n\n\t// Find common ancestor (deepest node that's on both paths)\n\tconst oldPath = new Set(session.getBranch(oldLeafId).map((e) => e.id));\n\tconst targetPath = session.getBranch(targetId);\n\n\t// targetPath is root-first, so iterate backwards to find deepest common ancestor\n\tlet commonAncestorId: string | null = null;\n\tfor (let i = targetPath.length - 1; i >= 0; i--) {\n\t\tif (oldPath.has(targetPath[i].id)) {\n\t\t\tcommonAncestorId = targetPath[i].id;\n\t\t\tbreak;\n\t\t}\n\t}\n\n\t// Collect entries from old leaf back to common ancestor\n\tconst entries: SessionEntry[] = [];\n\tlet current: string | null = oldLeafId;\n\n\twhile (current && current !== commonAncestorId) {\n\t\tconst entry = session.getEntry(current);\n\t\tif (!entry) break;\n\t\tentries.push(entry);\n\t\tcurrent = entry.parentId;\n\t}\n\n\t// Reverse to get chronological order\n\tentries.reverse();\n\n\treturn { entries, commonAncestorId };\n}\n\n// ============================================================================\n// Entry to Message Conversion\n// ============================================================================\n\n/**\n * Extract AgentMessage from a session entry.\n * Similar to getMessageFromEntry in compaction.ts but also handles compaction entries.\n */\nfunction getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {\n\tswitch (entry.type) {\n\t\tcase \"message\":\n\t\t\t// Skip tool results - context is in assistant's tool call\n\t\t\tif (entry.message.role === \"toolResult\") return undefined;\n\t\t\treturn entry.message;\n\n\t\tcase \"custom_message\":\n\t\t\treturn createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);\n\n\t\tcase \"branch_summary\":\n\t\t\treturn createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);\n\n\t\tcase \"compaction\":\n\t\t\treturn createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);\n\n\t\t// These don't contribute to conversation content\n\t\tcase \"thinking_level_change\":\n\t\tcase \"model_change\":\n\t\tcase \"custom\":\n\t\tcase \"label\":\n\t\tcase \"session_info\":\n\t\t\treturn undefined;\n\t}\n}\n\n/**\n * Prepare entries for summarization with token budget.\n *\n * Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget.\n * This ensures we keep the most recent context when the branch is too long.\n *\n * Also collects file operations from:\n * - Tool calls in assistant messages\n * - Existing branch_summary entries' details (for cumulative tracking)\n *\n * @param entries - Entries in chronological order\n * @param tokenBudget - Maximum tokens to include (0 = no limit)\n */\nexport function prepareBranchEntries(entries: SessionEntry[], tokenBudget: number = 0): BranchPreparation {\n\tconst messages: AgentMessage[] = [];\n\tconst fileOps = createFileOps();\n\tlet totalTokens = 0;\n\n\t// First pass: collect file ops from ALL entries (even if they don't fit in token budget)\n\t// This ensures we capture cumulative file tracking from nested branch summaries\n\t// Only extract from dreb-generated summaries (fromHook !== true), not extension-generated ones\n\tfor (const entry of entries) {\n\t\tif (entry.type === \"branch_summary\" && !entry.fromHook && entry.details) {\n\t\t\tconst details = entry.details as BranchSummaryDetails;\n\t\t\tif (Array.isArray(details.readFiles)) {\n\t\t\t\tfor (const f of details.readFiles) fileOps.read.add(f);\n\t\t\t}\n\t\t\tif (Array.isArray(details.modifiedFiles)) {\n\t\t\t\t// Modified files go into both edited and written for proper deduplication\n\t\t\t\tfor (const f of details.modifiedFiles) {\n\t\t\t\t\tfileOps.edited.add(f);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Second pass: walk from newest to oldest, adding messages until token budget\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tconst entry = entries[i];\n\t\tconst message = getMessageFromEntry(entry);\n\t\tif (!message) continue;\n\n\t\t// Extract file ops from assistant messages (tool calls)\n\t\textractFileOpsFromMessage(message, fileOps);\n\n\t\tconst tokens = estimateTokens(message);\n\n\t\t// Check budget before adding\n\t\tif (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {\n\t\t\t// If this is a summary entry, try to fit it anyway as it's important context\n\t\t\tif (entry.type === \"compaction\" || entry.type === \"branch_summary\") {\n\t\t\t\tif (totalTokens < tokenBudget * 0.9) {\n\t\t\t\t\tmessages.unshift(message);\n\t\t\t\t\ttotalTokens += tokens;\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Stop - we've hit the budget\n\t\t\tbreak;\n\t\t}\n\n\t\tmessages.unshift(message);\n\t\ttotalTokens += tokens;\n\t}\n\n\treturn { messages, fileOps, totalTokens };\n}\n\n// ============================================================================\n// Summary Generation\n// ============================================================================\n\nconst BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.\nSummary of that exploration:\n\n`;\n\nconst BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later.\n\nUse this EXACT format:\n\n## Goal\n[What was the user trying to accomplish in this branch?]\n\n## Constraints & Preferences\n- [Any constraints, preferences, or requirements mentioned]\n- [Or \"(none)\" if none were mentioned]\n\n## Progress\n### Done\n- [x] [Completed tasks/changes]\n\n### In Progress\n- [ ] [Work that was started but not finished]\n\n### Blocked\n- [Issues preventing progress, if any]\n\n## Key Decisions\n- **[Decision]**: [Brief rationale]\n\n## Next Steps\n1. [What should happen next to continue this work]\n\nKeep each section concise. Preserve exact file paths, function names, and error messages.`;\n\n/**\n * Generate a summary of abandoned branch entries.\n *\n * @param entries - Session entries to summarize (chronological order)\n * @param options - Generation options\n */\nexport async function generateBranchSummary(\n\tentries: SessionEntry[],\n\toptions: GenerateBranchSummaryOptions,\n): Promise<BranchSummaryResult> {\n\tconst { model, apiKey, signal, customInstructions, replaceInstructions, reserveTokens = 16384, sessionId } = options;\n\n\t// Token budget = context window minus reserved space for prompt + response\n\tconst contextWindow = model.contextWindow || 128000;\n\tconst tokenBudget = contextWindow - reserveTokens;\n\n\tconst { messages, fileOps } = prepareBranchEntries(entries, tokenBudget);\n\n\tif (messages.length === 0) {\n\t\treturn { summary: \"No content to summarize\" };\n\t}\n\n\t// Transform to LLM-compatible messages, then serialize to text\n\t// Serialization prevents the model from treating it as a conversation to continue\n\tconst llmMessages = convertToLlm(messages);\n\tconst conversationText = serializeConversation(llmMessages);\n\n\t// Build prompt\n\tlet instructions: string;\n\tif (replaceInstructions && customInstructions) {\n\t\tinstructions = customInstructions;\n\t} else if (customInstructions) {\n\t\tinstructions = `${BRANCH_SUMMARY_PROMPT}\\n\\nAdditional focus: ${customInstructions}`;\n\t} else {\n\t\tinstructions = BRANCH_SUMMARY_PROMPT;\n\t}\n\tconst promptText = `<conversation>\\n${conversationText}\\n</conversation>\\n\\n${instructions}`;\n\n\tconst summarizationMessages = [\n\t\t{\n\t\t\trole: \"user\" as const,\n\t\t\tcontent: [{ type: \"text\" as const, text: promptText }],\n\t\t\ttimestamp: Date.now(),\n\t\t},\n\t];\n\n\t// Call LLM for summarization\n\tconst response = await completeSimple(\n\t\tmodel,\n\t\t{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },\n\t\t{ apiKey, signal, maxTokens: 2048, ...(sessionId ? { sessionId } : {}) },\n\t);\n\n\t// Check if aborted or errored\n\tif (response.stopReason === \"aborted\") {\n\t\treturn { aborted: true };\n\t}\n\tif (response.stopReason === \"error\") {\n\t\treturn { error: response.errorMessage || \"Summarization failed\" };\n\t}\n\n\tlet summary = response.content\n\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t.map((c) => c.text)\n\t\t.join(\"\\n\");\n\n\t// Prepend preamble to provide context about the branch summary\n\tsummary = BRANCH_SUMMARY_PREAMBLE + summary;\n\n\t// Compute file lists and append to summary\n\tconst { readFiles, modifiedFiles } = computeFileLists(fileOps);\n\tsummary += formatFileOperations(readFiles, modifiedFiles);\n\n\treturn {\n\t\tsummary: summary || \"No summary generated\",\n\t\treadFiles,\n\t\tmodifiedFiles,\n\t};\n}\n"]}
@@ -91,7 +91,7 @@ export declare function findCutPoint(entries: SessionEntry[], startIndex: number
91
91
  * Generate a summary of the conversation using the LLM.
92
92
  * If previousSummary is provided, uses the update prompt to merge.
93
93
  */
94
- export declare function generateSummary(currentMessages: AgentMessage[], model: Model<any>, reserveTokens: number, apiKey: string, signal?: AbortSignal, customInstructions?: string, previousSummary?: string): Promise<string>;
94
+ export declare function generateSummary(currentMessages: AgentMessage[], model: Model<any>, reserveTokens: number, apiKey: string, signal?: AbortSignal, customInstructions?: string, previousSummary?: string, sessionId?: string): Promise<string>;
95
95
  export interface CompactionPreparation {
96
96
  /** UUID of first entry to keep */
97
97
  firstKeptEntryId: string;
@@ -117,5 +117,5 @@ export declare function prepareCompaction(pathEntries: SessionEntry[], settings:
117
117
  * @param preparation - Pre-calculated preparation from prepareCompaction()
118
118
  * @param customInstructions - Optional custom focus for the summary
119
119
  */
120
- export declare function compact(preparation: CompactionPreparation, model: Model<any>, apiKey: string, customInstructions?: string, signal?: AbortSignal): Promise<CompactionResult>;
120
+ export declare function compact(preparation: CompactionPreparation, model: Model<any>, apiKey: string, customInstructions?: string, signal?: AbortSignal, sessionId?: string): Promise<CompactionResult>;
121
121
  //# sourceMappingURL=compaction.d.ts.map