@dreb/coding-agent 2.34.2 → 2.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/dist/cli/args.d.ts +3 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +3 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/core/agent-session.d.ts +30 -2
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +172 -130
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/buddy/buddy-controller.d.ts +35 -1
- package/dist/core/buddy/buddy-controller.d.ts.map +1 -1
- package/dist/core/buddy/buddy-controller.js +65 -4
- package/dist/core/buddy/buddy-controller.js.map +1 -1
- package/dist/core/buddy/buddy-manager.d.ts +6 -0
- package/dist/core/buddy/buddy-manager.d.ts.map +1 -1
- package/dist/core/buddy/buddy-manager.js +25 -2
- package/dist/core/buddy/buddy-manager.js.map +1 -1
- package/dist/core/export-html/template.js +1 -1
- package/dist/core/footer-data-provider.d.ts.map +1 -1
- package/dist/core/footer-data-provider.js +5 -102
- package/dist/core/footer-data-provider.js.map +1 -1
- package/dist/core/git-branch.d.ts +19 -0
- package/dist/core/git-branch.d.ts.map +1 -0
- package/dist/core/git-branch.js +109 -0
- package/dist/core/git-branch.js.map +1 -0
- package/dist/core/sdk.d.ts.map +1 -1
- package/dist/core/sdk.js +1 -1
- package/dist/core/sdk.js.map +1 -1
- package/dist/core/session-manager.d.ts +21 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +117 -42
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/settings-manager.d.ts +6 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +8 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/{modes/interactive → core}/tab-title.d.ts +12 -3
- package/dist/core/tab-title.d.ts.map +1 -0
- package/dist/{modes/interactive → core}/tab-title.js +58 -16
- package/dist/core/tab-title.js.map +1 -0
- package/dist/core/tools/search.d.ts.map +1 -1
- package/dist/core/tools/search.js +4 -1
- package/dist/core/tools/search.js.map +1 -1
- package/dist/core/tools/subagent.d.ts +63 -2
- package/dist/core/tools/subagent.d.ts.map +1 -1
- package/dist/core/tools/subagent.js +459 -73
- package/dist/core/tools/subagent.js.map +1 -1
- package/dist/core/tools/web.d.ts +1 -1
- package/dist/core/tools/web.d.ts.map +1 -1
- package/dist/core/tools/web.js +60 -16
- package/dist/core/tools/web.js.map +1 -1
- package/dist/main.d.ts +10 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +54 -4
- package/dist/main.js.map +1 -1
- package/dist/modes/index.d.ts +2 -2
- package/dist/modes/index.d.ts.map +1 -1
- package/dist/modes/index.js +1 -1
- package/dist/modes/index.js.map +1 -1
- package/dist/modes/interactive/components/session-selector.d.ts +1 -1
- package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/session-selector.js +4 -40
- package/dist/modes/interactive/components/session-selector.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +1 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +20 -3
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/rpc/index.d.ts +2 -2
- package/dist/modes/rpc/index.d.ts.map +1 -1
- package/dist/modes/rpc/index.js.map +1 -1
- package/dist/modes/rpc/rpc-client.d.ts +114 -6
- package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-client.js +140 -2
- package/dist/modes/rpc/rpc-client.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts +97 -0
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +656 -27
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-types.d.ts +312 -1
- package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-types.js.map +1 -1
- package/docs/buddy.md +9 -1
- package/docs/dashboard.md +152 -0
- package/docs/development.md +8 -5
- package/docs/rpc.md +652 -5
- package/examples/sdk/12-full-control.ts +1 -1
- package/package.json +1 -1
- package/dist/modes/interactive/tab-title.d.ts.map +0 -1
- package/dist/modes/interactive/tab-title.js.map +0 -1
|
@@ -26,6 +26,14 @@ export interface BuddyCallbacks {
|
|
|
26
26
|
onHatch: (manager: BuddyManager) => Promise<BuddyState>;
|
|
27
27
|
/** Reroll the buddy — frontend resolves API key and calls manager.reroll() */
|
|
28
28
|
onReroll: (manager: BuddyManager) => Promise<BuddyState>;
|
|
29
|
+
/**
|
|
30
|
+
* Optional: notify the host that the buddy's persisted visibility changed —
|
|
31
|
+
* e.g. another concurrently-running dreb instance ran `/buddy off` (visible
|
|
32
|
+
* false) or brought it back with `/buddy` (visible true). The host should
|
|
33
|
+
* mount/unmount its visual representation accordingly. Fired only on a
|
|
34
|
+
* transition, never on every sync.
|
|
35
|
+
*/
|
|
36
|
+
onVisibilityChange?: (visible: boolean) => void;
|
|
29
37
|
}
|
|
30
38
|
/** Configuration for buddy behavior — differs between TUI and Telegram */
|
|
31
39
|
export interface BuddyControllerConfig {
|
|
@@ -77,6 +85,10 @@ export declare class BuddyController {
|
|
|
77
85
|
/** When false, all active functionality is disabled: no reactions, name-calls,
|
|
78
86
|
* idle timer, or Ollama calls. Context capture (passive) still happens. */
|
|
79
87
|
enabled: boolean;
|
|
88
|
+
/** Last visibility observed from the persisted `hidden` flag. Tracks whether
|
|
89
|
+
* the buddy is currently shown/active so syncHiddenState() can detect a
|
|
90
|
+
* cross-instance transition and fire onVisibilityChange exactly once. */
|
|
91
|
+
private lastVisible;
|
|
80
92
|
readonly manager: BuddyManager;
|
|
81
93
|
private readonly callbacks;
|
|
82
94
|
private readonly config;
|
|
@@ -133,10 +145,32 @@ export declare class BuddyController {
|
|
|
133
145
|
getModelNudge(): string | null;
|
|
134
146
|
/** Start the controller — auto-load buddy if one exists */
|
|
135
147
|
start(): BuddyState | null;
|
|
148
|
+
/**
|
|
149
|
+
* Re-read the persisted `hidden` flag from disk and converge local state to
|
|
150
|
+
* it. This is what makes `/buddy off` (or re-enable via `/buddy`) propagate
|
|
151
|
+
* across concurrently-running dreb instances: the flag lives in the shared
|
|
152
|
+
* buddy.json, and each instance re-checks it at turn-level sync points
|
|
153
|
+
* (user message, agent_end, bridge reset) rather than caching it for the
|
|
154
|
+
* life of the process.
|
|
155
|
+
*
|
|
156
|
+
* A buddy that is absent on disk (file deleted) or hidden counts as not
|
|
157
|
+
* visible. On a visibility transition the host is notified via
|
|
158
|
+
* onVisibilityChange so it can mount/unmount its widget.
|
|
159
|
+
*/
|
|
160
|
+
private syncHiddenState;
|
|
161
|
+
/**
|
|
162
|
+
* Public sync point for hosts that don't route user input through
|
|
163
|
+
* processUserMessage(). The TUI inlines its buddy wiring (appendContext /
|
|
164
|
+
* idle / name-call) rather than calling processUserMessage(), so it calls
|
|
165
|
+
* this on each user message to converge the buddy's visibility to the shared
|
|
166
|
+
* buddy.json — i.e. pick up a /buddy off (or re-enable) from another instance.
|
|
167
|
+
*/
|
|
168
|
+
refreshVisibility(): void;
|
|
136
169
|
/** Stop the controller — clear timers */
|
|
137
170
|
stop(): void;
|
|
138
171
|
/** Full reset — clear context buffer, idle timer, reaction budget.
|
|
139
|
-
*
|
|
172
|
+
* Re-reads the persisted hidden state from disk so bridge reconnects pick up
|
|
173
|
+
* a /buddy off performed in another instance (and don't undo it). */
|
|
140
174
|
reset(): void;
|
|
141
175
|
}
|
|
142
176
|
//# sourceMappingURL=buddy-controller.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"buddy-controller.d.ts","sourceRoot":"","sources":["../../../src/core/buddy/buddy-controller.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,OAAO,EAAE,KAAK,YAAY,EAAe,MAAM,oBAAoB,CAAC;AACpE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,sDAAsD;AACtD,MAAM,WAAW,cAAc;IAC9B,uDAAuD;IACvD,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,wCAAwC;IACxC,eAAe,EAAE,MAAM,IAAI,CAAC;IAC5B,0CAA0C;IAC1C,aAAa,EAAE,MAAM,IAAI,CAAC;IAC1B,gFAA8E;IAC9E,OAAO,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IACxD,gFAA8E;IAC9E,QAAQ,EAAE,CAAC,OAAO,EAAE,YAAY,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;CACzD;AAED,4EAA0E;AAC1E,MAAM,WAAW,qBAAqB;IACrC,sDAAsD;IACtD,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,yEAAyE;IACzE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,oDAAoD;IACpD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,wEAAwE;IACxE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,kDAAkD;IAClD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,+CAA+C;AAC/C,MAAM,MAAM,kBAAkB,GAC3B;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,GACpC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,GACnC;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,GACrC;IAAE,IAAI,EAAE,KAAK,CAAA;CAAE,GACf;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,GACpC;IAAE,IAAI,EAAE,KAAK,CAAA;CAAE,GACf;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACpC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtC,qBAAa,eAAe;IAC3B,OAAO,CAAC,aAAa,CAAgB;IACrC,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,SAAS,CAA8C;IAC/D,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,kBAAkB,CAAgB;IAC1C,OAAO,CAAC,kBAAkB,CAAK;IAC/B;gFAC4E;IAC5E,OAAO,UAAQ;IAEf,QAAQ,CAAC,OAAO,EAAE,YAAY,CAAC;IAC/B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAiB;IAC3C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkC;IAEzD,YAAY,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,CAAC,EAAE,qBAAqB,EAU3F;IAED,OAAO,CAAC,kBAAkB;IAO1B,OAAO,CAAC,mBAAmB;IAc3B,iFAAiF;IACjF,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAKjC;IAED,6DAA6D;IAC7D,YAAY,IAAI,MAAM,CAKrB;IAMD,6DAA6D;IAC7D,YAAY,IAAI,IAAI,CAEnB;IAED,4DAA0D;IAC1D,cAAc,IAAI,IAAI,CAwBrB;IAMD,uEAAuE;IACvE,OAAO,CAAC,QAAQ;IAsBhB;;;;OAIG;IACG,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA0BlD;IAED;;;OAGG;IACG,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA6BvD;IAED;qCACiC;IACjC,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAYpC;IAMD;;;;;OAKG;IACH,WAAW,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KAAE,GAAG,IAAI,CAkD7D;IAED;;;;OAIG;IACH,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAaxC;IAMD;;;OAGG;IACG,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAyEnE;YAOa,kBAAkB;IA+ChC,4EAA4E;IAC5E,aAAa,IAAI,MAAM,GAAG,IAAI,CAG7B;IAMD,6DAA2D;IAC3D,KAAK,IAAI,UAAU,GAAG,IAAI,CAQzB;IAED,2CAAyC;IACzC,IAAI,IAAI,IAAI,CAKX;IAED;sFACkF;IAClF,KAAK,IAAI,IAAI,CAQZ;CACD","sourcesContent":["/**\n * BuddyController — Frontend-agnostic controller for the buddy companion.\n *\n * Owns: context buffer, idle timer, reaction throttle, name-call detection.\n * Extracted from InteractiveMode so both TUI and Telegram can compose it\n * without duplicating ~150 lines of buddy wiring logic.\n *\n * The host (TUI or Telegram) provides callbacks for frontend-specific rendering:\n * - onSpeech(text) — display a speech bubble / message\n * - onThinkingStart() / onThinkingEnd() — show/hide thinking indicator\n *\n * Policies are configurable via BuddyControllerConfig so the TUI (no limits)\n * and Telegram (activity gating + reaction budget) can use different strategies.\n */\n\nimport { labelMessageEnd, labelToolEnd } from \"../context-buffer.js\";\nimport { log } from \"../logger.js\";\nimport { type BuddyManager, checkOllama } from \"./buddy-manager.js\";\nimport type { BuddyState } from \"./buddy-types.js\";\n\n/** Frontend-provided callbacks for buddy rendering */\nexport interface BuddyCallbacks {\n\t/** Display a speech/reaction message from the buddy */\n\tonSpeech: (text: string) => void;\n\t/** Show a thinking/loading indicator */\n\tonThinkingStart: () => void;\n\t/** Hide the thinking/loading indicator */\n\tonThinkingEnd: () => void;\n\t/** Hatch a new buddy — frontend resolves API key and calls manager.hatch() */\n\tonHatch: (manager: BuddyManager) => Promise<BuddyState>;\n\t/** Reroll the buddy — frontend resolves API key and calls manager.reroll() */\n\tonReroll: (manager: BuddyManager) => Promise<BuddyState>;\n}\n\n/** Configuration for buddy behavior — differs between TUI and Telegram */\nexport interface BuddyControllerConfig {\n\t/** Max entries in the context buffer (default: 20) */\n\tcontextMaxEntries?: number;\n\t/** Idle timeout in ms before buddy reacts to silence (default: 30000) */\n\tidleTimeoutMs?: number;\n\t/** Minimum ms between reactions (default: 60000) */\n\treactionCooldownMs?: number;\n\t/** If set, pause idle timer when user has been inactive this many ms */\n\tactivityGateMs?: number;\n\t/** If set, cap reactions to this many per hour */\n\treactionsPerHour?: number;\n}\n\n/** Subcommand result for frontend to render */\nexport type BuddyCommandResult =\n\t| { type: \"hatch\"; state: BuddyState }\n\t| { type: \"show\"; state: BuddyState }\n\t| { type: \"reroll\"; state: BuddyState }\n\t| { type: \"pet\" }\n\t| { type: \"stats\"; state: BuddyState }\n\t| { type: \"off\" }\n\t| { type: \"model\"; message: string }\n\t| { type: \"warning\"; message: string }\n\t| { type: \"error\"; message: string };\n\nexport class BuddyController {\n\tprivate contextBuffer: string[] = [];\n\tprivate lastReactionTime = 0;\n\tprivate idleTimer: ReturnType<typeof setTimeout> | null = null;\n\tprivate lastActivityTime = 0;\n\tprivate reactionTimestamps: number[] = []; // for budget tracking\n\tprivate pendingUtteranceId = 0;\n\t/** When false, all active functionality is disabled: no reactions, name-calls,\n\t * idle timer, or Ollama calls. Context capture (passive) still happens. */\n\tenabled = true;\n\n\treadonly manager: BuddyManager;\n\tprivate readonly callbacks: BuddyCallbacks;\n\tprivate readonly config: Required<BuddyControllerConfig>;\n\n\tconstructor(manager: BuddyManager, callbacks: BuddyCallbacks, config?: BuddyControllerConfig) {\n\t\tthis.manager = manager;\n\t\tthis.callbacks = callbacks;\n\t\tthis.config = {\n\t\t\tcontextMaxEntries: config?.contextMaxEntries ?? 20,\n\t\t\tidleTimeoutMs: config?.idleTimeoutMs ?? 30_000,\n\t\t\treactionCooldownMs: config?.reactionCooldownMs ?? 60_000,\n\t\t\tactivityGateMs: config?.activityGateMs ?? 0, // 0 = no gating (TUI default)\n\t\t\treactionsPerHour: config?.reactionsPerHour ?? 0, // 0 = unlimited (TUI default)\n\t\t};\n\t}\n\n\tprivate removeContextEntry(entry: string): void {\n\t\tconst idx = this.contextBuffer.indexOf(entry);\n\t\tif (idx !== -1) {\n\t\t\tthis.contextBuffer.splice(idx, 1);\n\t\t}\n\t}\n\n\tprivate replaceContextEntry(oldEntry: string, newEntry: string): void {\n\t\tconst idx = this.contextBuffer.indexOf(oldEntry);\n\t\tif (idx !== -1) {\n\t\t\tthis.contextBuffer[idx] = newEntry.slice(0, 2000);\n\t\t} else {\n\t\t\t// Evicted — append normally\n\t\t\tthis.appendContext(newEntry);\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Context buffer\n\t// =========================================================================\n\n\t/** Append an entry to the buddy context buffer (evicts oldest if at capacity) */\n\tappendContext(entry: string): void {\n\t\tthis.contextBuffer.push(entry.slice(0, 2000));\n\t\tif (this.contextBuffer.length > this.config.contextMaxEntries) {\n\t\t\tthis.contextBuffer.shift();\n\t\t}\n\t}\n\n\t/** Build the context buffer into a string for LLM prompts */\n\tbuildContext(): string {\n\t\tif (this.contextBuffer.length === 0) {\n\t\t\treturn \"No recent activity.\";\n\t\t}\n\t\treturn this.contextBuffer.join(\"\\n\").slice(-8000);\n\t}\n\n\t// =========================================================================\n\t// Activity & idle timer\n\t// =========================================================================\n\n\t/** Mark that user activity occurred (for activity gating) */\n\tmarkActivity(): void {\n\t\tthis.lastActivityTime = Date.now();\n\t}\n\n\t/** Reset the idle timer — called on every user message */\n\tresetIdleTimer(): void {\n\t\tif (this.idleTimer) {\n\t\t\tclearTimeout(this.idleTimer);\n\t\t}\n\n\t\tif (!this.enabled) return;\n\n\t\tif (!this.manager.getState()) return; // No buddy loaded, skip idle timer\n\n\t\t// Activity gating: skip idle timer if user has been inactive too long\n\t\tif (this.config.activityGateMs > 0 && this.lastActivityTime > 0) {\n\t\t\tconst elapsed = Date.now() - this.lastActivityTime;\n\t\t\tif (elapsed > this.config.activityGateMs) {\n\t\t\t\tthis.idleTimer = null;\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tthis.idleTimer = setTimeout(() => {\n\t\t\tconst ctx = this.buildContext();\n\t\t\tthis.triggerReaction(`It's been quiet for a moment. Recent activity:\\n${ctx}`).catch(() => {\n\t\t\t\t/* triggerReaction() logs errors internally — prevents unhandled rejection */\n\t\t\t});\n\t\t}, this.config.idleTimeoutMs);\n\t}\n\n\t// =========================================================================\n\t// Reactions\n\t// =========================================================================\n\n\t/** Check if a reaction is allowed under current throttle and budget */\n\tprivate canReact(): boolean {\n\t\tif (!this.enabled) return false;\n\n\t\tconst now = Date.now();\n\n\t\t// Cooldown throttle\n\t\tif (now - this.lastReactionTime < this.config.reactionCooldownMs) {\n\t\t\treturn false;\n\t\t}\n\n\t\t// Reaction budget (per hour)\n\t\tif (this.config.reactionsPerHour > 0) {\n\t\t\tconst oneHourAgo = now - 3_600_000;\n\t\t\tthis.reactionTimestamps = this.reactionTimestamps.filter((t) => t > oneHourAgo);\n\t\t\tif (this.reactionTimestamps.length >= this.config.reactionsPerHour) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\treturn true;\n\t}\n\n\t/**\n\t * Trigger a buddy reaction. Throttled by cooldown and budget.\n\t * Calls onThinkingStart/End and onSpeech callbacks.\n\t * No-op if disabled.\n\t */\n\tasync triggerReaction(event: string): Promise<void> {\n\t\tif (!this.canReact()) return;\n\t\tthis.lastReactionTime = Date.now();\n\n\t\tconst id = ++this.pendingUtteranceId;\n\t\tconst marker = `__BUDDY_PENDING_${id}__`;\n\t\tthis.appendContext(marker);\n\n\t\tlet thinkingEnded = false;\n\t\ttry {\n\t\t\tthis.callbacks.onThinkingStart();\n\t\t\tconst quip = await this.manager.react(event);\n\t\t\tthinkingEnded = true;\n\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\tif (quip) {\n\t\t\t\tthis.reactionTimestamps.push(Date.now());\n\t\t\t\tthis.replaceContextEntry(marker, `Buddy: ${quip}`);\n\t\t\t\tthis.callbacks.onSpeech(quip);\n\t\t\t} else {\n\t\t\t\tthis.removeContextEntry(marker);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tif (!thinkingEnded) this.callbacks.onThinkingEnd();\n\t\t\tthis.removeContextEntry(marker);\n\t\t\tlog.debug(`[buddy] triggerReaction failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\t/**\n\t * Handle a name-call from the user.\n\t * No-op if disabled — returns immediately without calling Ollama.\n\t */\n\tasync handleNameCall(userMessage: string): Promise<void> {\n\t\tif (!this.enabled) return;\n\t\tlet state = this.manager.getState();\n\t\tif (!state) {\n\t\t\tstate = this.manager.load();\n\t\t}\n\t\tif (!state) return;\n\n\t\tconst id = ++this.pendingUtteranceId;\n\t\tconst marker = `__BUDDY_PENDING_${id}__`;\n\t\tthis.appendContext(marker);\n\n\t\tlet thinkingEnded = false;\n\t\ttry {\n\t\t\tthis.callbacks.onThinkingStart();\n\t\t\tconst response = await this.manager.respondToNameCall(userMessage, this.buildContext());\n\t\t\tthinkingEnded = true;\n\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\tif (response) {\n\t\t\t\tthis.replaceContextEntry(marker, `Buddy: ${response}`);\n\t\t\t\tthis.callbacks.onSpeech(response);\n\t\t\t} else {\n\t\t\t\tthis.removeContextEntry(marker);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tif (!thinkingEnded) this.callbacks.onThinkingEnd();\n\t\t\tthis.removeContextEntry(marker);\n\t\t\tlog.debug(`[buddy] handleNameCall failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\t/** Check if a message contains the buddy's name (word-boundary matching).\n\t * Returns false if disabled. */\n\tdetectNameCall(text: string): boolean {\n\t\tif (!this.enabled) return false;\n\t\tconst name = this.manager.getName();\n\t\tif (!name) return false;\n\t\ttry {\n\t\t\tconst escaped = name.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\t\t\tconst regex = new RegExp(`\\\\b${escaped}\\\\b`, \"i\");\n\t\t\treturn regex.test(text);\n\t\t} catch {\n\t\t\t/* Invalid regex from buddy name — safe to return false */\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Event handling\n\t// =========================================================================\n\n\t/**\n\t * Process an agent event for buddy context capture and reaction triggers.\n\t * The host calls this from its event handler.\n\t *\n\t * Context capture always happens. Reactions are gated by `enabled`.\n\t */\n\thandleEvent(event: { type: string; [key: string]: any }): void {\n\t\tconst state = this.manager.getState();\n\t\tif (!state) return; // No buddy loaded\n\n\t\tswitch (event.type) {\n\t\t\tcase \"message_end\": {\n\t\t\t\tif (event.message?.role === \"assistant\") {\n\t\t\t\t\tfor (const entry of labelMessageEnd(event.message)) {\n\t\t\t\t\t\tthis.appendContext(entry);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"tool_execution_end\": {\n\t\t\t\t// Context capture (always)\n\t\t\t\tthis.appendContext(\n\t\t\t\t\tlabelToolEnd({ toolName: event.toolName, isError: event.isError, result: event.result }),\n\t\t\t\t);\n\n\t\t\t\t// Reaction on error (gated by enabled)\n\t\t\t\tif (event.isError && this.enabled) {\n\t\t\t\t\tlet errorText = \"unknown error\";\n\t\t\t\t\tconst result = event.result;\n\t\t\t\t\tif (result?.content && Array.isArray(result.content)) {\n\t\t\t\t\t\terrorText = result.content\n\t\t\t\t\t\t\t.filter((c: any) => c.type === \"text\")\n\t\t\t\t\t\t\t.map((c: any) => c.text)\n\t\t\t\t\t\t\t.join(\"\");\n\t\t\t\t\t} else if (typeof result?.error === \"string\") {\n\t\t\t\t\t\terrorText = result.error;\n\t\t\t\t\t}\n\t\t\t\t\tif (!errorText) errorText = \"unknown error\";\n\t\t\t\t\tthis.triggerReaction(`Tool \"${event.toolName}\" failed: ${errorText.slice(0, 2000)}`).catch(() => {\n\t\t\t\t\t\t/* triggerReaction() logs errors internally */\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"agent_end\": {\n\t\t\t\tif (this.enabled) {\n\t\t\t\t\tconst ctx = this.buildContext();\n\t\t\t\t\tthis.triggerReaction(`The agent finished responding. Recent activity:\\n${ctx}`).catch(() => {\n\t\t\t\t\t\t/* triggerReaction() logs errors internally */\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Process a user message — captures context, resets idle, checks name-call.\n\t * Context capture always happens. Active features gated by `enabled`.\n\t * Returns true if a name-call was detected and is being handled.\n\t */\n\tprocessUserMessage(text: string): boolean {\n\t\tthis.appendContext(`User: ${text}`);\n\t\tthis.markActivity();\n\t\tthis.resetIdleTimer();\n\n\t\t// Name-call detection (gated by enabled via detectNameCall)\n\t\tif (this.detectNameCall(text)) {\n\t\t\tthis.handleNameCall(text).catch(() => {\n\t\t\t\t/* handleNameCall() logs errors internally — prevents unhandled rejection */\n\t\t\t});\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\t// =========================================================================\n\t// Command handling\n\t// =========================================================================\n\n\t/**\n\t * Handle a /buddy command. Returns a result object for the frontend to render.\n\t * Hatch/reroll are delegated to the frontend via onHatch/onReroll callbacks.\n\t */\n\tasync handleCommand(subcommand: string): Promise<BuddyCommandResult> {\n\t\tswitch (subcommand) {\n\t\t\tcase \"pet\": {\n\t\t\t\tif (!this.manager.getState()) {\n\t\t\t\t\treturn { type: \"warning\", message: \"No buddy to pet! Use /buddy to hatch one first.\" };\n\t\t\t\t}\n\t\t\t\treturn { type: \"pet\" };\n\t\t\t}\n\t\t\tcase \"reroll\": {\n\t\t\t\tif (!this.manager.hasStoredBuddy()) {\n\t\t\t\t\treturn { type: \"warning\", message: \"No buddy to reroll! Use /buddy to hatch one first.\" };\n\t\t\t\t}\n\t\t\t\tthis.callbacks.onThinkingStart();\n\t\t\t\ttry {\n\t\t\t\t\tconst state = await this.callbacks.onReroll(this.manager);\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.manager.setHidden(false);\n\t\t\t\t\treturn { type: \"reroll\", state };\n\t\t\t\t} catch (err) {\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\treturn { type: \"error\", message: `Reroll failed: ${err instanceof Error ? err.message : String(err)}` };\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase \"stats\": {\n\t\t\t\tconst state = this.manager.getState();\n\t\t\t\tif (!state) {\n\t\t\t\t\treturn { type: \"warning\", message: \"No buddy to show stats for! Use /buddy to hatch one first.\" };\n\t\t\t\t}\n\t\t\t\treturn { type: \"stats\", state };\n\t\t\t}\n\t\t\tcase \"off\": {\n\t\t\t\tthis.enabled = false;\n\t\t\t\tthis.manager.setHidden(true);\n\t\t\t\tthis.stop();\n\t\t\t\treturn { type: \"off\" };\n\t\t\t}\n\t\t\tdefault: {\n\t\t\t\t// Handle \"/buddy model\" and \"/buddy model <name>\"\n\t\t\t\tif (subcommand === \"model\" || subcommand.startsWith(\"model \")) {\n\t\t\t\t\treturn this.handleModelCommand(subcommand);\n\t\t\t\t}\n\n\t\t\t\t// No subcommand: hatch or show\n\t\t\t\tconst current = this.manager.getState();\n\t\t\t\tif (current) {\n\t\t\t\t\t// Already showing — just enable and return\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.manager.setHidden(false);\n\t\t\t\t\treturn { type: \"show\", state: current };\n\t\t\t\t}\n\n\t\t\t\t// Try to load existing buddy\n\t\t\t\tconst existing = this.manager.load();\n\t\t\t\tif (existing) {\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.manager.setHidden(false);\n\t\t\t\t\treturn { type: \"show\", state: existing };\n\t\t\t\t}\n\n\t\t\t\t// Hatch new buddy\n\t\t\t\tthis.callbacks.onThinkingStart();\n\t\t\t\ttry {\n\t\t\t\t\tconst hatchState = await this.callbacks.onHatch(this.manager);\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\treturn { type: \"hatch\", state: hatchState };\n\t\t\t\t} catch (err) {\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\treturn { type: \"error\", message: `Hatch failed: ${err instanceof Error ? err.message : String(err)}` };\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Model selection\n\t// =========================================================================\n\n\t/** Handle /buddy model [name] — show current model or set a new one */\n\tprivate async handleModelCommand(subcommand: string): Promise<BuddyCommandResult> {\n\t\tconst modelArg = subcommand.slice(\"model\".length).trim();\n\n\t\tif (!modelArg) {\n\t\t\t// \"/buddy model\" with no argument — show current + available\n\t\t\tconst current = this.manager.getOllamaModel();\n\t\t\tconst status = await checkOllama();\n\t\t\tif (!status.available) {\n\t\t\t\treturn { type: \"model\", message: status.error ?? \"Ollama is not available.\" };\n\t\t\t}\n\t\t\tconst available = status.models.map((m) => ` • ${m}`).join(\"\\n\");\n\t\t\tif (current) {\n\t\t\t\treturn {\n\t\t\t\t\ttype: \"model\",\n\t\t\t\t\tmessage: `Current model: ${current}\\n\\nAvailable models:\\n${available}\\n\\nChange with: /buddy model <name>`,\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn {\n\t\t\t\ttype: \"model\",\n\t\t\t\tmessage: `No model set. Choose one with: /buddy model <name>\\n\\nAvailable models:\\n${available}`,\n\t\t\t};\n\t\t}\n\n\t\t// \"/buddy model <name>\" — set the model\n\t\tif (!this.manager.getState() && !this.manager.hasStoredBuddy()) {\n\t\t\treturn { type: \"warning\", message: \"No buddy yet — hatch one first with /buddy, then set a model.\" };\n\t\t}\n\n\t\tconst status = await checkOllama();\n\t\tif (!status.available) {\n\t\t\treturn { type: \"error\", message: status.error ?? \"Ollama is not available.\" };\n\t\t}\n\n\t\t// Check if the model is installed\n\t\tconst match = status.models.find((m) => m === modelArg || m.startsWith(`${modelArg}:`));\n\t\tif (!match) {\n\t\t\tconst available = status.models.map((m) => ` • ${m}`).join(\"\\n\");\n\t\t\treturn {\n\t\t\t\ttype: \"error\",\n\t\t\t\tmessage: `Model \"${modelArg}\" not found. Available models:\\n${available}\\n\\nPull it first with: ollama pull ${modelArg}`,\n\t\t\t};\n\t\t}\n\n\t\tthis.manager.setOllamaModel(match);\n\t\treturn { type: \"model\", message: `Buddy model set to: ${match}` };\n\t}\n\n\t/** Check if an Ollama model is configured, return a nudge message if not */\n\tgetModelNudge(): string | null {\n\t\tif (this.manager.getOllamaModel()) return null;\n\t\treturn \"No Ollama model set — reactions are disabled. Run /buddy model to choose one.\";\n\t}\n\n\t// =========================================================================\n\t// Lifecycle\n\t// =========================================================================\n\n\t/** Start the controller — auto-load buddy if one exists */\n\tstart(): BuddyState | null {\n\t\tconst existing = this.manager.load();\n\t\tif (existing) {\n\t\t\t// If buddy was hidden (via /buddy off), keep it loaded but disabled\n\t\t\tthis.enabled = !existing.hidden;\n\t\t\treturn existing;\n\t\t}\n\t\treturn null;\n\t}\n\n\t/** Stop the controller — clear timers */\n\tstop(): void {\n\t\tif (this.idleTimer) {\n\t\t\tclearTimeout(this.idleTimer);\n\t\t\tthis.idleTimer = null;\n\t\t}\n\t}\n\n\t/** Full reset — clear context buffer, idle timer, reaction budget.\n\t * Respects persisted hidden state so bridge reconnects don't undo /buddy off. */\n\treset(): void {\n\t\tthis.stop();\n\t\tthis.contextBuffer = [];\n\t\tthis.lastReactionTime = 0;\n\t\tthis.reactionTimestamps = [];\n\t\t// Re-enable unless buddy was explicitly hidden via /buddy off\n\t\tconst state = this.manager.getState() ?? this.manager.load();\n\t\tthis.enabled = state ? !state.hidden : true;\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"buddy-controller.d.ts","sourceRoot":"","sources":["../../../src/core/buddy/buddy-controller.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * BuddyController — Frontend-agnostic controller for the buddy companion.\n *\n * Owns: context buffer, idle timer, reaction throttle, name-call detection.\n * Extracted from InteractiveMode so both TUI and Telegram can compose it\n * without duplicating ~150 lines of buddy wiring logic.\n *\n * The host (TUI or Telegram) provides callbacks for frontend-specific rendering:\n * - onSpeech(text) — display a speech bubble / message\n * - onThinkingStart() / onThinkingEnd() — show/hide thinking indicator\n *\n * Policies are configurable via BuddyControllerConfig so the TUI (no limits)\n * and Telegram (activity gating + reaction budget) can use different strategies.\n */\n\nimport { labelMessageEnd, labelToolEnd } from \"../context-buffer.js\";\nimport { log } from \"../logger.js\";\nimport { type BuddyManager, checkOllama } from \"./buddy-manager.js\";\nimport type { BuddyState } from \"./buddy-types.js\";\n\n/** Frontend-provided callbacks for buddy rendering */\nexport interface BuddyCallbacks {\n\t/** Display a speech/reaction message from the buddy */\n\tonSpeech: (text: string) => void;\n\t/** Show a thinking/loading indicator */\n\tonThinkingStart: () => void;\n\t/** Hide the thinking/loading indicator */\n\tonThinkingEnd: () => void;\n\t/** Hatch a new buddy — frontend resolves API key and calls manager.hatch() */\n\tonHatch: (manager: BuddyManager) => Promise<BuddyState>;\n\t/** Reroll the buddy — frontend resolves API key and calls manager.reroll() */\n\tonReroll: (manager: BuddyManager) => Promise<BuddyState>;\n\t/**\n\t * Optional: notify the host that the buddy's persisted visibility changed —\n\t * e.g. another concurrently-running dreb instance ran `/buddy off` (visible\n\t * false) or brought it back with `/buddy` (visible true). The host should\n\t * mount/unmount its visual representation accordingly. Fired only on a\n\t * transition, never on every sync.\n\t */\n\tonVisibilityChange?: (visible: boolean) => void;\n}\n\n/** Configuration for buddy behavior — differs between TUI and Telegram */\nexport interface BuddyControllerConfig {\n\t/** Max entries in the context buffer (default: 20) */\n\tcontextMaxEntries?: number;\n\t/** Idle timeout in ms before buddy reacts to silence (default: 30000) */\n\tidleTimeoutMs?: number;\n\t/** Minimum ms between reactions (default: 60000) */\n\treactionCooldownMs?: number;\n\t/** If set, pause idle timer when user has been inactive this many ms */\n\tactivityGateMs?: number;\n\t/** If set, cap reactions to this many per hour */\n\treactionsPerHour?: number;\n}\n\n/** Subcommand result for frontend to render */\nexport type BuddyCommandResult =\n\t| { type: \"hatch\"; state: BuddyState }\n\t| { type: \"show\"; state: BuddyState }\n\t| { type: \"reroll\"; state: BuddyState }\n\t| { type: \"pet\" }\n\t| { type: \"stats\"; state: BuddyState }\n\t| { type: \"off\" }\n\t| { type: \"model\"; message: string }\n\t| { type: \"warning\"; message: string }\n\t| { type: \"error\"; message: string };\n\nexport class BuddyController {\n\tprivate contextBuffer: string[] = [];\n\tprivate lastReactionTime = 0;\n\tprivate idleTimer: ReturnType<typeof setTimeout> | null = null;\n\tprivate lastActivityTime = 0;\n\tprivate reactionTimestamps: number[] = []; // for budget tracking\n\tprivate pendingUtteranceId = 0;\n\t/** When false, all active functionality is disabled: no reactions, name-calls,\n\t * idle timer, or Ollama calls. Context capture (passive) still happens. */\n\tenabled = true;\n\n\t/** Last visibility observed from the persisted `hidden` flag. Tracks whether\n\t * the buddy is currently shown/active so syncHiddenState() can detect a\n\t * cross-instance transition and fire onVisibilityChange exactly once. */\n\tprivate lastVisible = false;\n\n\treadonly manager: BuddyManager;\n\tprivate readonly callbacks: BuddyCallbacks;\n\tprivate readonly config: Required<BuddyControllerConfig>;\n\n\tconstructor(manager: BuddyManager, callbacks: BuddyCallbacks, config?: BuddyControllerConfig) {\n\t\tthis.manager = manager;\n\t\tthis.callbacks = callbacks;\n\t\tthis.config = {\n\t\t\tcontextMaxEntries: config?.contextMaxEntries ?? 20,\n\t\t\tidleTimeoutMs: config?.idleTimeoutMs ?? 30_000,\n\t\t\treactionCooldownMs: config?.reactionCooldownMs ?? 60_000,\n\t\t\tactivityGateMs: config?.activityGateMs ?? 0, // 0 = no gating (TUI default)\n\t\t\treactionsPerHour: config?.reactionsPerHour ?? 0, // 0 = unlimited (TUI default)\n\t\t};\n\t}\n\n\tprivate removeContextEntry(entry: string): void {\n\t\tconst idx = this.contextBuffer.indexOf(entry);\n\t\tif (idx !== -1) {\n\t\t\tthis.contextBuffer.splice(idx, 1);\n\t\t}\n\t}\n\n\tprivate replaceContextEntry(oldEntry: string, newEntry: string): void {\n\t\tconst idx = this.contextBuffer.indexOf(oldEntry);\n\t\tif (idx !== -1) {\n\t\t\tthis.contextBuffer[idx] = newEntry.slice(0, 2000);\n\t\t} else {\n\t\t\t// Evicted — append normally\n\t\t\tthis.appendContext(newEntry);\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Context buffer\n\t// =========================================================================\n\n\t/** Append an entry to the buddy context buffer (evicts oldest if at capacity) */\n\tappendContext(entry: string): void {\n\t\tthis.contextBuffer.push(entry.slice(0, 2000));\n\t\tif (this.contextBuffer.length > this.config.contextMaxEntries) {\n\t\t\tthis.contextBuffer.shift();\n\t\t}\n\t}\n\n\t/** Build the context buffer into a string for LLM prompts */\n\tbuildContext(): string {\n\t\tif (this.contextBuffer.length === 0) {\n\t\t\treturn \"No recent activity.\";\n\t\t}\n\t\treturn this.contextBuffer.join(\"\\n\").slice(-8000);\n\t}\n\n\t// =========================================================================\n\t// Activity & idle timer\n\t// =========================================================================\n\n\t/** Mark that user activity occurred (for activity gating) */\n\tmarkActivity(): void {\n\t\tthis.lastActivityTime = Date.now();\n\t}\n\n\t/** Reset the idle timer — called on every user message */\n\tresetIdleTimer(): void {\n\t\tif (this.idleTimer) {\n\t\t\tclearTimeout(this.idleTimer);\n\t\t}\n\n\t\tif (!this.enabled) return;\n\n\t\tif (!this.manager.getState()) return; // No buddy loaded, skip idle timer\n\n\t\t// Activity gating: skip idle timer if user has been inactive too long\n\t\tif (this.config.activityGateMs > 0 && this.lastActivityTime > 0) {\n\t\t\tconst elapsed = Date.now() - this.lastActivityTime;\n\t\t\tif (elapsed > this.config.activityGateMs) {\n\t\t\t\tthis.idleTimer = null;\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tthis.idleTimer = setTimeout(() => {\n\t\t\tconst ctx = this.buildContext();\n\t\t\tthis.triggerReaction(`It's been quiet for a moment. Recent activity:\\n${ctx}`).catch(() => {\n\t\t\t\t/* triggerReaction() logs errors internally — prevents unhandled rejection */\n\t\t\t});\n\t\t}, this.config.idleTimeoutMs);\n\t}\n\n\t// =========================================================================\n\t// Reactions\n\t// =========================================================================\n\n\t/** Check if a reaction is allowed under current throttle and budget */\n\tprivate canReact(): boolean {\n\t\tif (!this.enabled) return false;\n\n\t\tconst now = Date.now();\n\n\t\t// Cooldown throttle\n\t\tif (now - this.lastReactionTime < this.config.reactionCooldownMs) {\n\t\t\treturn false;\n\t\t}\n\n\t\t// Reaction budget (per hour)\n\t\tif (this.config.reactionsPerHour > 0) {\n\t\t\tconst oneHourAgo = now - 3_600_000;\n\t\t\tthis.reactionTimestamps = this.reactionTimestamps.filter((t) => t > oneHourAgo);\n\t\t\tif (this.reactionTimestamps.length >= this.config.reactionsPerHour) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\treturn true;\n\t}\n\n\t/**\n\t * Trigger a buddy reaction. Throttled by cooldown and budget.\n\t * Calls onThinkingStart/End and onSpeech callbacks.\n\t * No-op if disabled.\n\t */\n\tasync triggerReaction(event: string): Promise<void> {\n\t\tif (!this.canReact()) return;\n\t\tthis.lastReactionTime = Date.now();\n\n\t\tconst id = ++this.pendingUtteranceId;\n\t\tconst marker = `__BUDDY_PENDING_${id}__`;\n\t\tthis.appendContext(marker);\n\n\t\tlet thinkingEnded = false;\n\t\ttry {\n\t\t\tthis.callbacks.onThinkingStart();\n\t\t\tconst quip = await this.manager.react(event);\n\t\t\tthinkingEnded = true;\n\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\tif (quip) {\n\t\t\t\tthis.reactionTimestamps.push(Date.now());\n\t\t\t\tthis.replaceContextEntry(marker, `Buddy: ${quip}`);\n\t\t\t\tthis.callbacks.onSpeech(quip);\n\t\t\t} else {\n\t\t\t\tthis.removeContextEntry(marker);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tif (!thinkingEnded) this.callbacks.onThinkingEnd();\n\t\t\tthis.removeContextEntry(marker);\n\t\t\tlog.debug(`[buddy] triggerReaction failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\t/**\n\t * Handle a name-call from the user.\n\t * No-op if disabled — returns immediately without calling Ollama.\n\t */\n\tasync handleNameCall(userMessage: string): Promise<void> {\n\t\tif (!this.enabled) return;\n\t\tlet state = this.manager.getState();\n\t\tif (!state) {\n\t\t\tstate = this.manager.load();\n\t\t}\n\t\tif (!state) return;\n\n\t\tconst id = ++this.pendingUtteranceId;\n\t\tconst marker = `__BUDDY_PENDING_${id}__`;\n\t\tthis.appendContext(marker);\n\n\t\tlet thinkingEnded = false;\n\t\ttry {\n\t\t\tthis.callbacks.onThinkingStart();\n\t\t\tconst response = await this.manager.respondToNameCall(userMessage, this.buildContext());\n\t\t\tthinkingEnded = true;\n\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\tif (response) {\n\t\t\t\tthis.replaceContextEntry(marker, `Buddy: ${response}`);\n\t\t\t\tthis.callbacks.onSpeech(response);\n\t\t\t} else {\n\t\t\t\tthis.removeContextEntry(marker);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tif (!thinkingEnded) this.callbacks.onThinkingEnd();\n\t\t\tthis.removeContextEntry(marker);\n\t\t\tlog.debug(`[buddy] handleNameCall failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\t/** Check if a message contains the buddy's name (word-boundary matching).\n\t * Returns false if disabled. */\n\tdetectNameCall(text: string): boolean {\n\t\tif (!this.enabled) return false;\n\t\tconst name = this.manager.getName();\n\t\tif (!name) return false;\n\t\ttry {\n\t\t\tconst escaped = name.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\t\t\tconst regex = new RegExp(`\\\\b${escaped}\\\\b`, \"i\");\n\t\t\treturn regex.test(text);\n\t\t} catch {\n\t\t\t/* Invalid regex from buddy name — safe to return false */\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Event handling\n\t// =========================================================================\n\n\t/**\n\t * Process an agent event for buddy context capture and reaction triggers.\n\t * The host calls this from its event handler.\n\t *\n\t * Context capture always happens. Reactions are gated by `enabled`.\n\t */\n\thandleEvent(event: { type: string; [key: string]: any }): void {\n\t\tconst state = this.manager.getState();\n\t\tif (!state) return; // No buddy loaded\n\n\t\tswitch (event.type) {\n\t\t\tcase \"message_end\": {\n\t\t\t\tif (event.message?.role === \"assistant\") {\n\t\t\t\t\tfor (const entry of labelMessageEnd(event.message)) {\n\t\t\t\t\t\tthis.appendContext(entry);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"tool_execution_end\": {\n\t\t\t\t// Context capture (always)\n\t\t\t\tthis.appendContext(\n\t\t\t\t\tlabelToolEnd({ toolName: event.toolName, isError: event.isError, result: event.result }),\n\t\t\t\t);\n\n\t\t\t\t// Reaction on error (gated by enabled)\n\t\t\t\tif (event.isError && this.enabled) {\n\t\t\t\t\tlet errorText = \"unknown error\";\n\t\t\t\t\tconst result = event.result;\n\t\t\t\t\tif (result?.content && Array.isArray(result.content)) {\n\t\t\t\t\t\terrorText = result.content\n\t\t\t\t\t\t\t.filter((c: any) => c.type === \"text\")\n\t\t\t\t\t\t\t.map((c: any) => c.text)\n\t\t\t\t\t\t\t.join(\"\");\n\t\t\t\t\t} else if (typeof result?.error === \"string\") {\n\t\t\t\t\t\terrorText = result.error;\n\t\t\t\t\t}\n\t\t\t\t\tif (!errorText) errorText = \"unknown error\";\n\t\t\t\t\tthis.triggerReaction(`Tool \"${event.toolName}\" failed: ${errorText.slice(0, 2000)}`).catch(() => {\n\t\t\t\t\t\t/* triggerReaction() logs errors internally */\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"agent_end\": {\n\t\t\t\t// Re-sync the persisted hidden flag once per response — cheap at\n\t\t\t\t// this cadence, and the point at which a /buddy off performed by\n\t\t\t\t// another concurrent instance must take effect here.\n\t\t\t\tthis.syncHiddenState();\n\t\t\t\tif (this.enabled) {\n\t\t\t\t\tconst ctx = this.buildContext();\n\t\t\t\t\tthis.triggerReaction(`The agent finished responding. Recent activity:\\n${ctx}`).catch(() => {\n\t\t\t\t\t\t/* triggerReaction() logs errors internally */\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Process a user message — captures context, resets idle, checks name-call.\n\t * Context capture always happens. Active features gated by `enabled`.\n\t * Returns true if a name-call was detected and is being handled.\n\t */\n\tprocessUserMessage(text: string): boolean {\n\t\tthis.syncHiddenState();\n\t\tthis.appendContext(`User: ${text}`);\n\t\tthis.markActivity();\n\t\tthis.resetIdleTimer();\n\n\t\t// Name-call detection (gated by enabled via detectNameCall)\n\t\tif (this.detectNameCall(text)) {\n\t\t\tthis.handleNameCall(text).catch(() => {\n\t\t\t\t/* handleNameCall() logs errors internally — prevents unhandled rejection */\n\t\t\t});\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\t// =========================================================================\n\t// Command handling\n\t// =========================================================================\n\n\t/**\n\t * Handle a /buddy command. Returns a result object for the frontend to render.\n\t * Hatch/reroll are delegated to the frontend via onHatch/onReroll callbacks.\n\t */\n\tasync handleCommand(subcommand: string): Promise<BuddyCommandResult> {\n\t\tswitch (subcommand) {\n\t\t\tcase \"pet\": {\n\t\t\t\tif (!this.manager.getState()) {\n\t\t\t\t\treturn { type: \"warning\", message: \"No buddy to pet! Use /buddy to hatch one first.\" };\n\t\t\t\t}\n\t\t\t\treturn { type: \"pet\" };\n\t\t\t}\n\t\t\tcase \"reroll\": {\n\t\t\t\tif (!this.manager.hasStoredBuddy()) {\n\t\t\t\t\treturn { type: \"warning\", message: \"No buddy to reroll! Use /buddy to hatch one first.\" };\n\t\t\t\t}\n\t\t\t\tthis.callbacks.onThinkingStart();\n\t\t\t\ttry {\n\t\t\t\t\tconst state = await this.callbacks.onReroll(this.manager);\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.manager.setHidden(false);\n\t\t\t\t\tthis.lastVisible = true;\n\t\t\t\t\treturn { type: \"reroll\", state };\n\t\t\t\t} catch (err) {\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\treturn { type: \"error\", message: `Reroll failed: ${err instanceof Error ? err.message : String(err)}` };\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase \"stats\": {\n\t\t\t\tconst state = this.manager.getState();\n\t\t\t\tif (!state) {\n\t\t\t\t\treturn { type: \"warning\", message: \"No buddy to show stats for! Use /buddy to hatch one first.\" };\n\t\t\t\t}\n\t\t\t\treturn { type: \"stats\", state };\n\t\t\t}\n\t\t\tcase \"off\": {\n\t\t\t\tthis.enabled = false;\n\t\t\t\tthis.manager.setHidden(true);\n\t\t\t\tthis.lastVisible = false;\n\t\t\t\tthis.stop();\n\t\t\t\treturn { type: \"off\" };\n\t\t\t}\n\t\t\tdefault: {\n\t\t\t\t// Handle \"/buddy model\" and \"/buddy model <name>\"\n\t\t\t\tif (subcommand === \"model\" || subcommand.startsWith(\"model \")) {\n\t\t\t\t\treturn this.handleModelCommand(subcommand);\n\t\t\t\t}\n\n\t\t\t\t// No subcommand: hatch or show\n\t\t\t\tconst current = this.manager.getState();\n\t\t\t\tif (current) {\n\t\t\t\t\t// Already showing — just enable and return\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.manager.setHidden(false);\n\t\t\t\t\tthis.lastVisible = true;\n\t\t\t\t\treturn { type: \"show\", state: current };\n\t\t\t\t}\n\n\t\t\t\t// Try to load existing buddy\n\t\t\t\tconst existing = this.manager.load();\n\t\t\t\tif (existing) {\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.manager.setHidden(false);\n\t\t\t\t\tthis.lastVisible = true;\n\t\t\t\t\treturn { type: \"show\", state: existing };\n\t\t\t\t}\n\n\t\t\t\t// Hatch new buddy\n\t\t\t\tthis.callbacks.onThinkingStart();\n\t\t\t\ttry {\n\t\t\t\t\tconst hatchState = await this.callbacks.onHatch(this.manager);\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.lastVisible = true;\n\t\t\t\t\treturn { type: \"hatch\", state: hatchState };\n\t\t\t\t} catch (err) {\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\treturn { type: \"error\", message: `Hatch failed: ${err instanceof Error ? err.message : String(err)}` };\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Model selection\n\t// =========================================================================\n\n\t/** Handle /buddy model [name] — show current model or set a new one */\n\tprivate async handleModelCommand(subcommand: string): Promise<BuddyCommandResult> {\n\t\tconst modelArg = subcommand.slice(\"model\".length).trim();\n\n\t\tif (!modelArg) {\n\t\t\t// \"/buddy model\" with no argument — show current + available\n\t\t\tconst current = this.manager.getOllamaModel();\n\t\t\tconst status = await checkOllama();\n\t\t\tif (!status.available) {\n\t\t\t\treturn { type: \"model\", message: status.error ?? \"Ollama is not available.\" };\n\t\t\t}\n\t\t\tconst available = status.models.map((m) => ` • ${m}`).join(\"\\n\");\n\t\t\tif (current) {\n\t\t\t\treturn {\n\t\t\t\t\ttype: \"model\",\n\t\t\t\t\tmessage: `Current model: ${current}\\n\\nAvailable models:\\n${available}\\n\\nChange with: /buddy model <name>`,\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn {\n\t\t\t\ttype: \"model\",\n\t\t\t\tmessage: `No model set. Choose one with: /buddy model <name>\\n\\nAvailable models:\\n${available}`,\n\t\t\t};\n\t\t}\n\n\t\t// \"/buddy model <name>\" — set the model\n\t\tif (!this.manager.getState() && !this.manager.hasStoredBuddy()) {\n\t\t\treturn { type: \"warning\", message: \"No buddy yet — hatch one first with /buddy, then set a model.\" };\n\t\t}\n\n\t\tconst status = await checkOllama();\n\t\tif (!status.available) {\n\t\t\treturn { type: \"error\", message: status.error ?? \"Ollama is not available.\" };\n\t\t}\n\n\t\t// Check if the model is installed\n\t\tconst match = status.models.find((m) => m === modelArg || m.startsWith(`${modelArg}:`));\n\t\tif (!match) {\n\t\t\tconst available = status.models.map((m) => ` • ${m}`).join(\"\\n\");\n\t\t\treturn {\n\t\t\t\ttype: \"error\",\n\t\t\t\tmessage: `Model \"${modelArg}\" not found. Available models:\\n${available}\\n\\nPull it first with: ollama pull ${modelArg}`,\n\t\t\t};\n\t\t}\n\n\t\tthis.manager.setOllamaModel(match);\n\t\treturn { type: \"model\", message: `Buddy model set to: ${match}` };\n\t}\n\n\t/** Check if an Ollama model is configured, return a nudge message if not */\n\tgetModelNudge(): string | null {\n\t\tif (this.manager.getOllamaModel()) return null;\n\t\treturn \"No Ollama model set — reactions are disabled. Run /buddy model to choose one.\";\n\t}\n\n\t// =========================================================================\n\t// Lifecycle\n\t// =========================================================================\n\n\t/** Start the controller — auto-load buddy if one exists */\n\tstart(): BuddyState | null {\n\t\tconst existing = this.manager.load();\n\t\tif (existing) {\n\t\t\t// If buddy was hidden (via /buddy off), keep it loaded but disabled\n\t\t\tthis.enabled = !existing.hidden;\n\t\t\tthis.lastVisible = !existing.hidden;\n\t\t\treturn existing;\n\t\t}\n\t\tthis.lastVisible = false;\n\t\treturn null;\n\t}\n\n\t/**\n\t * Re-read the persisted `hidden` flag from disk and converge local state to\n\t * it. This is what makes `/buddy off` (or re-enable via `/buddy`) propagate\n\t * across concurrently-running dreb instances: the flag lives in the shared\n\t * buddy.json, and each instance re-checks it at turn-level sync points\n\t * (user message, agent_end, bridge reset) rather than caching it for the\n\t * life of the process.\n\t *\n\t * A buddy that is absent on disk (file deleted) or hidden counts as not\n\t * visible. On a visibility transition the host is notified via\n\t * onVisibilityChange so it can mount/unmount its widget.\n\t */\n\tprivate syncHiddenState(): void {\n\t\tconst exists = this.manager.hasStoredBuddy();\n\t\tconst visible = exists && !this.manager.isHidden();\n\t\tthis.enabled = visible;\n\n\t\tif (visible === this.lastVisible) return;\n\n\t\tthis.lastVisible = visible;\n\t\tif (visible) {\n\t\t\t// Refresh in-memory state from disk so reactions and the host mount\n\t\t\t// see the current buddy (it may have been hatched/changed elsewhere).\n\t\t\tthis.manager.load();\n\t\t} else {\n\t\t\tthis.stop();\n\t\t}\n\t\ttry {\n\t\t\tthis.callbacks.onVisibilityChange?.(visible);\n\t\t} catch (err) {\n\t\t\tlog.debug(`[buddy] onVisibilityChange failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\t/**\n\t * Public sync point for hosts that don't route user input through\n\t * processUserMessage(). The TUI inlines its buddy wiring (appendContext /\n\t * idle / name-call) rather than calling processUserMessage(), so it calls\n\t * this on each user message to converge the buddy's visibility to the shared\n\t * buddy.json — i.e. pick up a /buddy off (or re-enable) from another instance.\n\t */\n\trefreshVisibility(): void {\n\t\tthis.syncHiddenState();\n\t}\n\n\t/** Stop the controller — clear timers */\n\tstop(): void {\n\t\tif (this.idleTimer) {\n\t\t\tclearTimeout(this.idleTimer);\n\t\t\tthis.idleTimer = null;\n\t\t}\n\t}\n\n\t/** Full reset — clear context buffer, idle timer, reaction budget.\n\t * Re-reads the persisted hidden state from disk so bridge reconnects pick up\n\t * a /buddy off performed in another instance (and don't undo it). */\n\treset(): void {\n\t\tthis.stop();\n\t\tthis.contextBuffer = [];\n\t\tthis.lastReactionTime = 0;\n\t\tthis.reactionTimestamps = [];\n\t\t// Refresh in-memory state from disk, then converge to the persisted flag.\n\t\tthis.manager.load();\n\t\tthis.syncHiddenState();\n\t}\n}\n"]}
|
|
@@ -25,6 +25,10 @@ export class BuddyController {
|
|
|
25
25
|
/** When false, all active functionality is disabled: no reactions, name-calls,
|
|
26
26
|
* idle timer, or Ollama calls. Context capture (passive) still happens. */
|
|
27
27
|
enabled = true;
|
|
28
|
+
/** Last visibility observed from the persisted `hidden` flag. Tracks whether
|
|
29
|
+
* the buddy is currently shown/active so syncHiddenState() can detect a
|
|
30
|
+
* cross-instance transition and fire onVisibilityChange exactly once. */
|
|
31
|
+
lastVisible = false;
|
|
28
32
|
manager;
|
|
29
33
|
callbacks;
|
|
30
34
|
config;
|
|
@@ -261,6 +265,10 @@ export class BuddyController {
|
|
|
261
265
|
break;
|
|
262
266
|
}
|
|
263
267
|
case "agent_end": {
|
|
268
|
+
// Re-sync the persisted hidden flag once per response — cheap at
|
|
269
|
+
// this cadence, and the point at which a /buddy off performed by
|
|
270
|
+
// another concurrent instance must take effect here.
|
|
271
|
+
this.syncHiddenState();
|
|
264
272
|
if (this.enabled) {
|
|
265
273
|
const ctx = this.buildContext();
|
|
266
274
|
this.triggerReaction(`The agent finished responding. Recent activity:\n${ctx}`).catch(() => {
|
|
@@ -277,6 +285,7 @@ export class BuddyController {
|
|
|
277
285
|
* Returns true if a name-call was detected and is being handled.
|
|
278
286
|
*/
|
|
279
287
|
processUserMessage(text) {
|
|
288
|
+
this.syncHiddenState();
|
|
280
289
|
this.appendContext(`User: ${text}`);
|
|
281
290
|
this.markActivity();
|
|
282
291
|
this.resetIdleTimer();
|
|
@@ -314,6 +323,7 @@ export class BuddyController {
|
|
|
314
323
|
this.callbacks.onThinkingEnd();
|
|
315
324
|
this.enabled = true;
|
|
316
325
|
this.manager.setHidden(false);
|
|
326
|
+
this.lastVisible = true;
|
|
317
327
|
return { type: "reroll", state };
|
|
318
328
|
}
|
|
319
329
|
catch (err) {
|
|
@@ -331,6 +341,7 @@ export class BuddyController {
|
|
|
331
341
|
case "off": {
|
|
332
342
|
this.enabled = false;
|
|
333
343
|
this.manager.setHidden(true);
|
|
344
|
+
this.lastVisible = false;
|
|
334
345
|
this.stop();
|
|
335
346
|
return { type: "off" };
|
|
336
347
|
}
|
|
@@ -345,6 +356,7 @@ export class BuddyController {
|
|
|
345
356
|
// Already showing — just enable and return
|
|
346
357
|
this.enabled = true;
|
|
347
358
|
this.manager.setHidden(false);
|
|
359
|
+
this.lastVisible = true;
|
|
348
360
|
return { type: "show", state: current };
|
|
349
361
|
}
|
|
350
362
|
// Try to load existing buddy
|
|
@@ -352,6 +364,7 @@ export class BuddyController {
|
|
|
352
364
|
if (existing) {
|
|
353
365
|
this.enabled = true;
|
|
354
366
|
this.manager.setHidden(false);
|
|
367
|
+
this.lastVisible = true;
|
|
355
368
|
return { type: "show", state: existing };
|
|
356
369
|
}
|
|
357
370
|
// Hatch new buddy
|
|
@@ -360,6 +373,7 @@ export class BuddyController {
|
|
|
360
373
|
const hatchState = await this.callbacks.onHatch(this.manager);
|
|
361
374
|
this.callbacks.onThinkingEnd();
|
|
362
375
|
this.enabled = true;
|
|
376
|
+
this.lastVisible = true;
|
|
363
377
|
return { type: "hatch", state: hatchState };
|
|
364
378
|
}
|
|
365
379
|
catch (err) {
|
|
@@ -429,10 +443,56 @@ export class BuddyController {
|
|
|
429
443
|
if (existing) {
|
|
430
444
|
// If buddy was hidden (via /buddy off), keep it loaded but disabled
|
|
431
445
|
this.enabled = !existing.hidden;
|
|
446
|
+
this.lastVisible = !existing.hidden;
|
|
432
447
|
return existing;
|
|
433
448
|
}
|
|
449
|
+
this.lastVisible = false;
|
|
434
450
|
return null;
|
|
435
451
|
}
|
|
452
|
+
/**
|
|
453
|
+
* Re-read the persisted `hidden` flag from disk and converge local state to
|
|
454
|
+
* it. This is what makes `/buddy off` (or re-enable via `/buddy`) propagate
|
|
455
|
+
* across concurrently-running dreb instances: the flag lives in the shared
|
|
456
|
+
* buddy.json, and each instance re-checks it at turn-level sync points
|
|
457
|
+
* (user message, agent_end, bridge reset) rather than caching it for the
|
|
458
|
+
* life of the process.
|
|
459
|
+
*
|
|
460
|
+
* A buddy that is absent on disk (file deleted) or hidden counts as not
|
|
461
|
+
* visible. On a visibility transition the host is notified via
|
|
462
|
+
* onVisibilityChange so it can mount/unmount its widget.
|
|
463
|
+
*/
|
|
464
|
+
syncHiddenState() {
|
|
465
|
+
const exists = this.manager.hasStoredBuddy();
|
|
466
|
+
const visible = exists && !this.manager.isHidden();
|
|
467
|
+
this.enabled = visible;
|
|
468
|
+
if (visible === this.lastVisible)
|
|
469
|
+
return;
|
|
470
|
+
this.lastVisible = visible;
|
|
471
|
+
if (visible) {
|
|
472
|
+
// Refresh in-memory state from disk so reactions and the host mount
|
|
473
|
+
// see the current buddy (it may have been hatched/changed elsewhere).
|
|
474
|
+
this.manager.load();
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
this.stop();
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
this.callbacks.onVisibilityChange?.(visible);
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
log.debug(`[buddy] onVisibilityChange failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Public sync point for hosts that don't route user input through
|
|
488
|
+
* processUserMessage(). The TUI inlines its buddy wiring (appendContext /
|
|
489
|
+
* idle / name-call) rather than calling processUserMessage(), so it calls
|
|
490
|
+
* this on each user message to converge the buddy's visibility to the shared
|
|
491
|
+
* buddy.json — i.e. pick up a /buddy off (or re-enable) from another instance.
|
|
492
|
+
*/
|
|
493
|
+
refreshVisibility() {
|
|
494
|
+
this.syncHiddenState();
|
|
495
|
+
}
|
|
436
496
|
/** Stop the controller — clear timers */
|
|
437
497
|
stop() {
|
|
438
498
|
if (this.idleTimer) {
|
|
@@ -441,15 +501,16 @@ export class BuddyController {
|
|
|
441
501
|
}
|
|
442
502
|
}
|
|
443
503
|
/** Full reset — clear context buffer, idle timer, reaction budget.
|
|
444
|
-
*
|
|
504
|
+
* Re-reads the persisted hidden state from disk so bridge reconnects pick up
|
|
505
|
+
* a /buddy off performed in another instance (and don't undo it). */
|
|
445
506
|
reset() {
|
|
446
507
|
this.stop();
|
|
447
508
|
this.contextBuffer = [];
|
|
448
509
|
this.lastReactionTime = 0;
|
|
449
510
|
this.reactionTimestamps = [];
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
this.
|
|
511
|
+
// Refresh in-memory state from disk, then converge to the persisted flag.
|
|
512
|
+
this.manager.load();
|
|
513
|
+
this.syncHiddenState();
|
|
453
514
|
}
|
|
454
515
|
}
|
|
455
516
|
//# sourceMappingURL=buddy-controller.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"buddy-controller.js","sourceRoot":"","sources":["../../../src/core/buddy/buddy-controller.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * BuddyController — Frontend-agnostic controller for the buddy companion.\n *\n * Owns: context buffer, idle timer, reaction throttle, name-call detection.\n * Extracted from InteractiveMode so both TUI and Telegram can compose it\n * without duplicating ~150 lines of buddy wiring logic.\n *\n * The host (TUI or Telegram) provides callbacks for frontend-specific rendering:\n * - onSpeech(text) — display a speech bubble / message\n * - onThinkingStart() / onThinkingEnd() — show/hide thinking indicator\n *\n * Policies are configurable via BuddyControllerConfig so the TUI (no limits)\n * and Telegram (activity gating + reaction budget) can use different strategies.\n */\n\nimport { labelMessageEnd, labelToolEnd } from \"../context-buffer.js\";\nimport { log } from \"../logger.js\";\nimport { type BuddyManager, checkOllama } from \"./buddy-manager.js\";\nimport type { BuddyState } from \"./buddy-types.js\";\n\n/** Frontend-provided callbacks for buddy rendering */\nexport interface BuddyCallbacks {\n\t/** Display a speech/reaction message from the buddy */\n\tonSpeech: (text: string) => void;\n\t/** Show a thinking/loading indicator */\n\tonThinkingStart: () => void;\n\t/** Hide the thinking/loading indicator */\n\tonThinkingEnd: () => void;\n\t/** Hatch a new buddy — frontend resolves API key and calls manager.hatch() */\n\tonHatch: (manager: BuddyManager) => Promise<BuddyState>;\n\t/** Reroll the buddy — frontend resolves API key and calls manager.reroll() */\n\tonReroll: (manager: BuddyManager) => Promise<BuddyState>;\n}\n\n/** Configuration for buddy behavior — differs between TUI and Telegram */\nexport interface BuddyControllerConfig {\n\t/** Max entries in the context buffer (default: 20) */\n\tcontextMaxEntries?: number;\n\t/** Idle timeout in ms before buddy reacts to silence (default: 30000) */\n\tidleTimeoutMs?: number;\n\t/** Minimum ms between reactions (default: 60000) */\n\treactionCooldownMs?: number;\n\t/** If set, pause idle timer when user has been inactive this many ms */\n\tactivityGateMs?: number;\n\t/** If set, cap reactions to this many per hour */\n\treactionsPerHour?: number;\n}\n\n/** Subcommand result for frontend to render */\nexport type BuddyCommandResult =\n\t| { type: \"hatch\"; state: BuddyState }\n\t| { type: \"show\"; state: BuddyState }\n\t| { type: \"reroll\"; state: BuddyState }\n\t| { type: \"pet\" }\n\t| { type: \"stats\"; state: BuddyState }\n\t| { type: \"off\" }\n\t| { type: \"model\"; message: string }\n\t| { type: \"warning\"; message: string }\n\t| { type: \"error\"; message: string };\n\nexport class BuddyController {\n\tprivate contextBuffer: string[] = [];\n\tprivate lastReactionTime = 0;\n\tprivate idleTimer: ReturnType<typeof setTimeout> | null = null;\n\tprivate lastActivityTime = 0;\n\tprivate reactionTimestamps: number[] = []; // for budget tracking\n\tprivate pendingUtteranceId = 0;\n\t/** When false, all active functionality is disabled: no reactions, name-calls,\n\t * idle timer, or Ollama calls. Context capture (passive) still happens. */\n\tenabled = true;\n\n\treadonly manager: BuddyManager;\n\tprivate readonly callbacks: BuddyCallbacks;\n\tprivate readonly config: Required<BuddyControllerConfig>;\n\n\tconstructor(manager: BuddyManager, callbacks: BuddyCallbacks, config?: BuddyControllerConfig) {\n\t\tthis.manager = manager;\n\t\tthis.callbacks = callbacks;\n\t\tthis.config = {\n\t\t\tcontextMaxEntries: config?.contextMaxEntries ?? 20,\n\t\t\tidleTimeoutMs: config?.idleTimeoutMs ?? 30_000,\n\t\t\treactionCooldownMs: config?.reactionCooldownMs ?? 60_000,\n\t\t\tactivityGateMs: config?.activityGateMs ?? 0, // 0 = no gating (TUI default)\n\t\t\treactionsPerHour: config?.reactionsPerHour ?? 0, // 0 = unlimited (TUI default)\n\t\t};\n\t}\n\n\tprivate removeContextEntry(entry: string): void {\n\t\tconst idx = this.contextBuffer.indexOf(entry);\n\t\tif (idx !== -1) {\n\t\t\tthis.contextBuffer.splice(idx, 1);\n\t\t}\n\t}\n\n\tprivate replaceContextEntry(oldEntry: string, newEntry: string): void {\n\t\tconst idx = this.contextBuffer.indexOf(oldEntry);\n\t\tif (idx !== -1) {\n\t\t\tthis.contextBuffer[idx] = newEntry.slice(0, 2000);\n\t\t} else {\n\t\t\t// Evicted — append normally\n\t\t\tthis.appendContext(newEntry);\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Context buffer\n\t// =========================================================================\n\n\t/** Append an entry to the buddy context buffer (evicts oldest if at capacity) */\n\tappendContext(entry: string): void {\n\t\tthis.contextBuffer.push(entry.slice(0, 2000));\n\t\tif (this.contextBuffer.length > this.config.contextMaxEntries) {\n\t\t\tthis.contextBuffer.shift();\n\t\t}\n\t}\n\n\t/** Build the context buffer into a string for LLM prompts */\n\tbuildContext(): string {\n\t\tif (this.contextBuffer.length === 0) {\n\t\t\treturn \"No recent activity.\";\n\t\t}\n\t\treturn this.contextBuffer.join(\"\\n\").slice(-8000);\n\t}\n\n\t// =========================================================================\n\t// Activity & idle timer\n\t// =========================================================================\n\n\t/** Mark that user activity occurred (for activity gating) */\n\tmarkActivity(): void {\n\t\tthis.lastActivityTime = Date.now();\n\t}\n\n\t/** Reset the idle timer — called on every user message */\n\tresetIdleTimer(): void {\n\t\tif (this.idleTimer) {\n\t\t\tclearTimeout(this.idleTimer);\n\t\t}\n\n\t\tif (!this.enabled) return;\n\n\t\tif (!this.manager.getState()) return; // No buddy loaded, skip idle timer\n\n\t\t// Activity gating: skip idle timer if user has been inactive too long\n\t\tif (this.config.activityGateMs > 0 && this.lastActivityTime > 0) {\n\t\t\tconst elapsed = Date.now() - this.lastActivityTime;\n\t\t\tif (elapsed > this.config.activityGateMs) {\n\t\t\t\tthis.idleTimer = null;\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tthis.idleTimer = setTimeout(() => {\n\t\t\tconst ctx = this.buildContext();\n\t\t\tthis.triggerReaction(`It's been quiet for a moment. Recent activity:\\n${ctx}`).catch(() => {\n\t\t\t\t/* triggerReaction() logs errors internally — prevents unhandled rejection */\n\t\t\t});\n\t\t}, this.config.idleTimeoutMs);\n\t}\n\n\t// =========================================================================\n\t// Reactions\n\t// =========================================================================\n\n\t/** Check if a reaction is allowed under current throttle and budget */\n\tprivate canReact(): boolean {\n\t\tif (!this.enabled) return false;\n\n\t\tconst now = Date.now();\n\n\t\t// Cooldown throttle\n\t\tif (now - this.lastReactionTime < this.config.reactionCooldownMs) {\n\t\t\treturn false;\n\t\t}\n\n\t\t// Reaction budget (per hour)\n\t\tif (this.config.reactionsPerHour > 0) {\n\t\t\tconst oneHourAgo = now - 3_600_000;\n\t\t\tthis.reactionTimestamps = this.reactionTimestamps.filter((t) => t > oneHourAgo);\n\t\t\tif (this.reactionTimestamps.length >= this.config.reactionsPerHour) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\treturn true;\n\t}\n\n\t/**\n\t * Trigger a buddy reaction. Throttled by cooldown and budget.\n\t * Calls onThinkingStart/End and onSpeech callbacks.\n\t * No-op if disabled.\n\t */\n\tasync triggerReaction(event: string): Promise<void> {\n\t\tif (!this.canReact()) return;\n\t\tthis.lastReactionTime = Date.now();\n\n\t\tconst id = ++this.pendingUtteranceId;\n\t\tconst marker = `__BUDDY_PENDING_${id}__`;\n\t\tthis.appendContext(marker);\n\n\t\tlet thinkingEnded = false;\n\t\ttry {\n\t\t\tthis.callbacks.onThinkingStart();\n\t\t\tconst quip = await this.manager.react(event);\n\t\t\tthinkingEnded = true;\n\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\tif (quip) {\n\t\t\t\tthis.reactionTimestamps.push(Date.now());\n\t\t\t\tthis.replaceContextEntry(marker, `Buddy: ${quip}`);\n\t\t\t\tthis.callbacks.onSpeech(quip);\n\t\t\t} else {\n\t\t\t\tthis.removeContextEntry(marker);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tif (!thinkingEnded) this.callbacks.onThinkingEnd();\n\t\t\tthis.removeContextEntry(marker);\n\t\t\tlog.debug(`[buddy] triggerReaction failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\t/**\n\t * Handle a name-call from the user.\n\t * No-op if disabled — returns immediately without calling Ollama.\n\t */\n\tasync handleNameCall(userMessage: string): Promise<void> {\n\t\tif (!this.enabled) return;\n\t\tlet state = this.manager.getState();\n\t\tif (!state) {\n\t\t\tstate = this.manager.load();\n\t\t}\n\t\tif (!state) return;\n\n\t\tconst id = ++this.pendingUtteranceId;\n\t\tconst marker = `__BUDDY_PENDING_${id}__`;\n\t\tthis.appendContext(marker);\n\n\t\tlet thinkingEnded = false;\n\t\ttry {\n\t\t\tthis.callbacks.onThinkingStart();\n\t\t\tconst response = await this.manager.respondToNameCall(userMessage, this.buildContext());\n\t\t\tthinkingEnded = true;\n\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\tif (response) {\n\t\t\t\tthis.replaceContextEntry(marker, `Buddy: ${response}`);\n\t\t\t\tthis.callbacks.onSpeech(response);\n\t\t\t} else {\n\t\t\t\tthis.removeContextEntry(marker);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tif (!thinkingEnded) this.callbacks.onThinkingEnd();\n\t\t\tthis.removeContextEntry(marker);\n\t\t\tlog.debug(`[buddy] handleNameCall failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\t/** Check if a message contains the buddy's name (word-boundary matching).\n\t * Returns false if disabled. */\n\tdetectNameCall(text: string): boolean {\n\t\tif (!this.enabled) return false;\n\t\tconst name = this.manager.getName();\n\t\tif (!name) return false;\n\t\ttry {\n\t\t\tconst escaped = name.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\t\t\tconst regex = new RegExp(`\\\\b${escaped}\\\\b`, \"i\");\n\t\t\treturn regex.test(text);\n\t\t} catch {\n\t\t\t/* Invalid regex from buddy name — safe to return false */\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Event handling\n\t// =========================================================================\n\n\t/**\n\t * Process an agent event for buddy context capture and reaction triggers.\n\t * The host calls this from its event handler.\n\t *\n\t * Context capture always happens. Reactions are gated by `enabled`.\n\t */\n\thandleEvent(event: { type: string; [key: string]: any }): void {\n\t\tconst state = this.manager.getState();\n\t\tif (!state) return; // No buddy loaded\n\n\t\tswitch (event.type) {\n\t\t\tcase \"message_end\": {\n\t\t\t\tif (event.message?.role === \"assistant\") {\n\t\t\t\t\tfor (const entry of labelMessageEnd(event.message)) {\n\t\t\t\t\t\tthis.appendContext(entry);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"tool_execution_end\": {\n\t\t\t\t// Context capture (always)\n\t\t\t\tthis.appendContext(\n\t\t\t\t\tlabelToolEnd({ toolName: event.toolName, isError: event.isError, result: event.result }),\n\t\t\t\t);\n\n\t\t\t\t// Reaction on error (gated by enabled)\n\t\t\t\tif (event.isError && this.enabled) {\n\t\t\t\t\tlet errorText = \"unknown error\";\n\t\t\t\t\tconst result = event.result;\n\t\t\t\t\tif (result?.content && Array.isArray(result.content)) {\n\t\t\t\t\t\terrorText = result.content\n\t\t\t\t\t\t\t.filter((c: any) => c.type === \"text\")\n\t\t\t\t\t\t\t.map((c: any) => c.text)\n\t\t\t\t\t\t\t.join(\"\");\n\t\t\t\t\t} else if (typeof result?.error === \"string\") {\n\t\t\t\t\t\terrorText = result.error;\n\t\t\t\t\t}\n\t\t\t\t\tif (!errorText) errorText = \"unknown error\";\n\t\t\t\t\tthis.triggerReaction(`Tool \"${event.toolName}\" failed: ${errorText.slice(0, 2000)}`).catch(() => {\n\t\t\t\t\t\t/* triggerReaction() logs errors internally */\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"agent_end\": {\n\t\t\t\tif (this.enabled) {\n\t\t\t\t\tconst ctx = this.buildContext();\n\t\t\t\t\tthis.triggerReaction(`The agent finished responding. Recent activity:\\n${ctx}`).catch(() => {\n\t\t\t\t\t\t/* triggerReaction() logs errors internally */\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Process a user message — captures context, resets idle, checks name-call.\n\t * Context capture always happens. Active features gated by `enabled`.\n\t * Returns true if a name-call was detected and is being handled.\n\t */\n\tprocessUserMessage(text: string): boolean {\n\t\tthis.appendContext(`User: ${text}`);\n\t\tthis.markActivity();\n\t\tthis.resetIdleTimer();\n\n\t\t// Name-call detection (gated by enabled via detectNameCall)\n\t\tif (this.detectNameCall(text)) {\n\t\t\tthis.handleNameCall(text).catch(() => {\n\t\t\t\t/* handleNameCall() logs errors internally — prevents unhandled rejection */\n\t\t\t});\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\t// =========================================================================\n\t// Command handling\n\t// =========================================================================\n\n\t/**\n\t * Handle a /buddy command. Returns a result object for the frontend to render.\n\t * Hatch/reroll are delegated to the frontend via onHatch/onReroll callbacks.\n\t */\n\tasync handleCommand(subcommand: string): Promise<BuddyCommandResult> {\n\t\tswitch (subcommand) {\n\t\t\tcase \"pet\": {\n\t\t\t\tif (!this.manager.getState()) {\n\t\t\t\t\treturn { type: \"warning\", message: \"No buddy to pet! Use /buddy to hatch one first.\" };\n\t\t\t\t}\n\t\t\t\treturn { type: \"pet\" };\n\t\t\t}\n\t\t\tcase \"reroll\": {\n\t\t\t\tif (!this.manager.hasStoredBuddy()) {\n\t\t\t\t\treturn { type: \"warning\", message: \"No buddy to reroll! Use /buddy to hatch one first.\" };\n\t\t\t\t}\n\t\t\t\tthis.callbacks.onThinkingStart();\n\t\t\t\ttry {\n\t\t\t\t\tconst state = await this.callbacks.onReroll(this.manager);\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.manager.setHidden(false);\n\t\t\t\t\treturn { type: \"reroll\", state };\n\t\t\t\t} catch (err) {\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\treturn { type: \"error\", message: `Reroll failed: ${err instanceof Error ? err.message : String(err)}` };\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase \"stats\": {\n\t\t\t\tconst state = this.manager.getState();\n\t\t\t\tif (!state) {\n\t\t\t\t\treturn { type: \"warning\", message: \"No buddy to show stats for! Use /buddy to hatch one first.\" };\n\t\t\t\t}\n\t\t\t\treturn { type: \"stats\", state };\n\t\t\t}\n\t\t\tcase \"off\": {\n\t\t\t\tthis.enabled = false;\n\t\t\t\tthis.manager.setHidden(true);\n\t\t\t\tthis.stop();\n\t\t\t\treturn { type: \"off\" };\n\t\t\t}\n\t\t\tdefault: {\n\t\t\t\t// Handle \"/buddy model\" and \"/buddy model <name>\"\n\t\t\t\tif (subcommand === \"model\" || subcommand.startsWith(\"model \")) {\n\t\t\t\t\treturn this.handleModelCommand(subcommand);\n\t\t\t\t}\n\n\t\t\t\t// No subcommand: hatch or show\n\t\t\t\tconst current = this.manager.getState();\n\t\t\t\tif (current) {\n\t\t\t\t\t// Already showing — just enable and return\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.manager.setHidden(false);\n\t\t\t\t\treturn { type: \"show\", state: current };\n\t\t\t\t}\n\n\t\t\t\t// Try to load existing buddy\n\t\t\t\tconst existing = this.manager.load();\n\t\t\t\tif (existing) {\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.manager.setHidden(false);\n\t\t\t\t\treturn { type: \"show\", state: existing };\n\t\t\t\t}\n\n\t\t\t\t// Hatch new buddy\n\t\t\t\tthis.callbacks.onThinkingStart();\n\t\t\t\ttry {\n\t\t\t\t\tconst hatchState = await this.callbacks.onHatch(this.manager);\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\treturn { type: \"hatch\", state: hatchState };\n\t\t\t\t} catch (err) {\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\treturn { type: \"error\", message: `Hatch failed: ${err instanceof Error ? err.message : String(err)}` };\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Model selection\n\t// =========================================================================\n\n\t/** Handle /buddy model [name] — show current model or set a new one */\n\tprivate async handleModelCommand(subcommand: string): Promise<BuddyCommandResult> {\n\t\tconst modelArg = subcommand.slice(\"model\".length).trim();\n\n\t\tif (!modelArg) {\n\t\t\t// \"/buddy model\" with no argument — show current + available\n\t\t\tconst current = this.manager.getOllamaModel();\n\t\t\tconst status = await checkOllama();\n\t\t\tif (!status.available) {\n\t\t\t\treturn { type: \"model\", message: status.error ?? \"Ollama is not available.\" };\n\t\t\t}\n\t\t\tconst available = status.models.map((m) => ` • ${m}`).join(\"\\n\");\n\t\t\tif (current) {\n\t\t\t\treturn {\n\t\t\t\t\ttype: \"model\",\n\t\t\t\t\tmessage: `Current model: ${current}\\n\\nAvailable models:\\n${available}\\n\\nChange with: /buddy model <name>`,\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn {\n\t\t\t\ttype: \"model\",\n\t\t\t\tmessage: `No model set. Choose one with: /buddy model <name>\\n\\nAvailable models:\\n${available}`,\n\t\t\t};\n\t\t}\n\n\t\t// \"/buddy model <name>\" — set the model\n\t\tif (!this.manager.getState() && !this.manager.hasStoredBuddy()) {\n\t\t\treturn { type: \"warning\", message: \"No buddy yet — hatch one first with /buddy, then set a model.\" };\n\t\t}\n\n\t\tconst status = await checkOllama();\n\t\tif (!status.available) {\n\t\t\treturn { type: \"error\", message: status.error ?? \"Ollama is not available.\" };\n\t\t}\n\n\t\t// Check if the model is installed\n\t\tconst match = status.models.find((m) => m === modelArg || m.startsWith(`${modelArg}:`));\n\t\tif (!match) {\n\t\t\tconst available = status.models.map((m) => ` • ${m}`).join(\"\\n\");\n\t\t\treturn {\n\t\t\t\ttype: \"error\",\n\t\t\t\tmessage: `Model \"${modelArg}\" not found. Available models:\\n${available}\\n\\nPull it first with: ollama pull ${modelArg}`,\n\t\t\t};\n\t\t}\n\n\t\tthis.manager.setOllamaModel(match);\n\t\treturn { type: \"model\", message: `Buddy model set to: ${match}` };\n\t}\n\n\t/** Check if an Ollama model is configured, return a nudge message if not */\n\tgetModelNudge(): string | null {\n\t\tif (this.manager.getOllamaModel()) return null;\n\t\treturn \"No Ollama model set — reactions are disabled. Run /buddy model to choose one.\";\n\t}\n\n\t// =========================================================================\n\t// Lifecycle\n\t// =========================================================================\n\n\t/** Start the controller — auto-load buddy if one exists */\n\tstart(): BuddyState | null {\n\t\tconst existing = this.manager.load();\n\t\tif (existing) {\n\t\t\t// If buddy was hidden (via /buddy off), keep it loaded but disabled\n\t\t\tthis.enabled = !existing.hidden;\n\t\t\treturn existing;\n\t\t}\n\t\treturn null;\n\t}\n\n\t/** Stop the controller — clear timers */\n\tstop(): void {\n\t\tif (this.idleTimer) {\n\t\t\tclearTimeout(this.idleTimer);\n\t\t\tthis.idleTimer = null;\n\t\t}\n\t}\n\n\t/** Full reset — clear context buffer, idle timer, reaction budget.\n\t * Respects persisted hidden state so bridge reconnects don't undo /buddy off. */\n\treset(): void {\n\t\tthis.stop();\n\t\tthis.contextBuffer = [];\n\t\tthis.lastReactionTime = 0;\n\t\tthis.reactionTimestamps = [];\n\t\t// Re-enable unless buddy was explicitly hidden via /buddy off\n\t\tconst state = this.manager.getState() ?? this.manager.load();\n\t\tthis.enabled = state ? !state.hidden : true;\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"buddy-controller.js","sourceRoot":"","sources":["../../../src/core/buddy/buddy-controller.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * BuddyController — Frontend-agnostic controller for the buddy companion.\n *\n * Owns: context buffer, idle timer, reaction throttle, name-call detection.\n * Extracted from InteractiveMode so both TUI and Telegram can compose it\n * without duplicating ~150 lines of buddy wiring logic.\n *\n * The host (TUI or Telegram) provides callbacks for frontend-specific rendering:\n * - onSpeech(text) — display a speech bubble / message\n * - onThinkingStart() / onThinkingEnd() — show/hide thinking indicator\n *\n * Policies are configurable via BuddyControllerConfig so the TUI (no limits)\n * and Telegram (activity gating + reaction budget) can use different strategies.\n */\n\nimport { labelMessageEnd, labelToolEnd } from \"../context-buffer.js\";\nimport { log } from \"../logger.js\";\nimport { type BuddyManager, checkOllama } from \"./buddy-manager.js\";\nimport type { BuddyState } from \"./buddy-types.js\";\n\n/** Frontend-provided callbacks for buddy rendering */\nexport interface BuddyCallbacks {\n\t/** Display a speech/reaction message from the buddy */\n\tonSpeech: (text: string) => void;\n\t/** Show a thinking/loading indicator */\n\tonThinkingStart: () => void;\n\t/** Hide the thinking/loading indicator */\n\tonThinkingEnd: () => void;\n\t/** Hatch a new buddy — frontend resolves API key and calls manager.hatch() */\n\tonHatch: (manager: BuddyManager) => Promise<BuddyState>;\n\t/** Reroll the buddy — frontend resolves API key and calls manager.reroll() */\n\tonReroll: (manager: BuddyManager) => Promise<BuddyState>;\n\t/**\n\t * Optional: notify the host that the buddy's persisted visibility changed —\n\t * e.g. another concurrently-running dreb instance ran `/buddy off` (visible\n\t * false) or brought it back with `/buddy` (visible true). The host should\n\t * mount/unmount its visual representation accordingly. Fired only on a\n\t * transition, never on every sync.\n\t */\n\tonVisibilityChange?: (visible: boolean) => void;\n}\n\n/** Configuration for buddy behavior — differs between TUI and Telegram */\nexport interface BuddyControllerConfig {\n\t/** Max entries in the context buffer (default: 20) */\n\tcontextMaxEntries?: number;\n\t/** Idle timeout in ms before buddy reacts to silence (default: 30000) */\n\tidleTimeoutMs?: number;\n\t/** Minimum ms between reactions (default: 60000) */\n\treactionCooldownMs?: number;\n\t/** If set, pause idle timer when user has been inactive this many ms */\n\tactivityGateMs?: number;\n\t/** If set, cap reactions to this many per hour */\n\treactionsPerHour?: number;\n}\n\n/** Subcommand result for frontend to render */\nexport type BuddyCommandResult =\n\t| { type: \"hatch\"; state: BuddyState }\n\t| { type: \"show\"; state: BuddyState }\n\t| { type: \"reroll\"; state: BuddyState }\n\t| { type: \"pet\" }\n\t| { type: \"stats\"; state: BuddyState }\n\t| { type: \"off\" }\n\t| { type: \"model\"; message: string }\n\t| { type: \"warning\"; message: string }\n\t| { type: \"error\"; message: string };\n\nexport class BuddyController {\n\tprivate contextBuffer: string[] = [];\n\tprivate lastReactionTime = 0;\n\tprivate idleTimer: ReturnType<typeof setTimeout> | null = null;\n\tprivate lastActivityTime = 0;\n\tprivate reactionTimestamps: number[] = []; // for budget tracking\n\tprivate pendingUtteranceId = 0;\n\t/** When false, all active functionality is disabled: no reactions, name-calls,\n\t * idle timer, or Ollama calls. Context capture (passive) still happens. */\n\tenabled = true;\n\n\t/** Last visibility observed from the persisted `hidden` flag. Tracks whether\n\t * the buddy is currently shown/active so syncHiddenState() can detect a\n\t * cross-instance transition and fire onVisibilityChange exactly once. */\n\tprivate lastVisible = false;\n\n\treadonly manager: BuddyManager;\n\tprivate readonly callbacks: BuddyCallbacks;\n\tprivate readonly config: Required<BuddyControllerConfig>;\n\n\tconstructor(manager: BuddyManager, callbacks: BuddyCallbacks, config?: BuddyControllerConfig) {\n\t\tthis.manager = manager;\n\t\tthis.callbacks = callbacks;\n\t\tthis.config = {\n\t\t\tcontextMaxEntries: config?.contextMaxEntries ?? 20,\n\t\t\tidleTimeoutMs: config?.idleTimeoutMs ?? 30_000,\n\t\t\treactionCooldownMs: config?.reactionCooldownMs ?? 60_000,\n\t\t\tactivityGateMs: config?.activityGateMs ?? 0, // 0 = no gating (TUI default)\n\t\t\treactionsPerHour: config?.reactionsPerHour ?? 0, // 0 = unlimited (TUI default)\n\t\t};\n\t}\n\n\tprivate removeContextEntry(entry: string): void {\n\t\tconst idx = this.contextBuffer.indexOf(entry);\n\t\tif (idx !== -1) {\n\t\t\tthis.contextBuffer.splice(idx, 1);\n\t\t}\n\t}\n\n\tprivate replaceContextEntry(oldEntry: string, newEntry: string): void {\n\t\tconst idx = this.contextBuffer.indexOf(oldEntry);\n\t\tif (idx !== -1) {\n\t\t\tthis.contextBuffer[idx] = newEntry.slice(0, 2000);\n\t\t} else {\n\t\t\t// Evicted — append normally\n\t\t\tthis.appendContext(newEntry);\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Context buffer\n\t// =========================================================================\n\n\t/** Append an entry to the buddy context buffer (evicts oldest if at capacity) */\n\tappendContext(entry: string): void {\n\t\tthis.contextBuffer.push(entry.slice(0, 2000));\n\t\tif (this.contextBuffer.length > this.config.contextMaxEntries) {\n\t\t\tthis.contextBuffer.shift();\n\t\t}\n\t}\n\n\t/** Build the context buffer into a string for LLM prompts */\n\tbuildContext(): string {\n\t\tif (this.contextBuffer.length === 0) {\n\t\t\treturn \"No recent activity.\";\n\t\t}\n\t\treturn this.contextBuffer.join(\"\\n\").slice(-8000);\n\t}\n\n\t// =========================================================================\n\t// Activity & idle timer\n\t// =========================================================================\n\n\t/** Mark that user activity occurred (for activity gating) */\n\tmarkActivity(): void {\n\t\tthis.lastActivityTime = Date.now();\n\t}\n\n\t/** Reset the idle timer — called on every user message */\n\tresetIdleTimer(): void {\n\t\tif (this.idleTimer) {\n\t\t\tclearTimeout(this.idleTimer);\n\t\t}\n\n\t\tif (!this.enabled) return;\n\n\t\tif (!this.manager.getState()) return; // No buddy loaded, skip idle timer\n\n\t\t// Activity gating: skip idle timer if user has been inactive too long\n\t\tif (this.config.activityGateMs > 0 && this.lastActivityTime > 0) {\n\t\t\tconst elapsed = Date.now() - this.lastActivityTime;\n\t\t\tif (elapsed > this.config.activityGateMs) {\n\t\t\t\tthis.idleTimer = null;\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tthis.idleTimer = setTimeout(() => {\n\t\t\tconst ctx = this.buildContext();\n\t\t\tthis.triggerReaction(`It's been quiet for a moment. Recent activity:\\n${ctx}`).catch(() => {\n\t\t\t\t/* triggerReaction() logs errors internally — prevents unhandled rejection */\n\t\t\t});\n\t\t}, this.config.idleTimeoutMs);\n\t}\n\n\t// =========================================================================\n\t// Reactions\n\t// =========================================================================\n\n\t/** Check if a reaction is allowed under current throttle and budget */\n\tprivate canReact(): boolean {\n\t\tif (!this.enabled) return false;\n\n\t\tconst now = Date.now();\n\n\t\t// Cooldown throttle\n\t\tif (now - this.lastReactionTime < this.config.reactionCooldownMs) {\n\t\t\treturn false;\n\t\t}\n\n\t\t// Reaction budget (per hour)\n\t\tif (this.config.reactionsPerHour > 0) {\n\t\t\tconst oneHourAgo = now - 3_600_000;\n\t\t\tthis.reactionTimestamps = this.reactionTimestamps.filter((t) => t > oneHourAgo);\n\t\t\tif (this.reactionTimestamps.length >= this.config.reactionsPerHour) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\treturn true;\n\t}\n\n\t/**\n\t * Trigger a buddy reaction. Throttled by cooldown and budget.\n\t * Calls onThinkingStart/End and onSpeech callbacks.\n\t * No-op if disabled.\n\t */\n\tasync triggerReaction(event: string): Promise<void> {\n\t\tif (!this.canReact()) return;\n\t\tthis.lastReactionTime = Date.now();\n\n\t\tconst id = ++this.pendingUtteranceId;\n\t\tconst marker = `__BUDDY_PENDING_${id}__`;\n\t\tthis.appendContext(marker);\n\n\t\tlet thinkingEnded = false;\n\t\ttry {\n\t\t\tthis.callbacks.onThinkingStart();\n\t\t\tconst quip = await this.manager.react(event);\n\t\t\tthinkingEnded = true;\n\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\tif (quip) {\n\t\t\t\tthis.reactionTimestamps.push(Date.now());\n\t\t\t\tthis.replaceContextEntry(marker, `Buddy: ${quip}`);\n\t\t\t\tthis.callbacks.onSpeech(quip);\n\t\t\t} else {\n\t\t\t\tthis.removeContextEntry(marker);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tif (!thinkingEnded) this.callbacks.onThinkingEnd();\n\t\t\tthis.removeContextEntry(marker);\n\t\t\tlog.debug(`[buddy] triggerReaction failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\t/**\n\t * Handle a name-call from the user.\n\t * No-op if disabled — returns immediately without calling Ollama.\n\t */\n\tasync handleNameCall(userMessage: string): Promise<void> {\n\t\tif (!this.enabled) return;\n\t\tlet state = this.manager.getState();\n\t\tif (!state) {\n\t\t\tstate = this.manager.load();\n\t\t}\n\t\tif (!state) return;\n\n\t\tconst id = ++this.pendingUtteranceId;\n\t\tconst marker = `__BUDDY_PENDING_${id}__`;\n\t\tthis.appendContext(marker);\n\n\t\tlet thinkingEnded = false;\n\t\ttry {\n\t\t\tthis.callbacks.onThinkingStart();\n\t\t\tconst response = await this.manager.respondToNameCall(userMessage, this.buildContext());\n\t\t\tthinkingEnded = true;\n\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\tif (response) {\n\t\t\t\tthis.replaceContextEntry(marker, `Buddy: ${response}`);\n\t\t\t\tthis.callbacks.onSpeech(response);\n\t\t\t} else {\n\t\t\t\tthis.removeContextEntry(marker);\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tif (!thinkingEnded) this.callbacks.onThinkingEnd();\n\t\t\tthis.removeContextEntry(marker);\n\t\t\tlog.debug(`[buddy] handleNameCall failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\t/** Check if a message contains the buddy's name (word-boundary matching).\n\t * Returns false if disabled. */\n\tdetectNameCall(text: string): boolean {\n\t\tif (!this.enabled) return false;\n\t\tconst name = this.manager.getName();\n\t\tif (!name) return false;\n\t\ttry {\n\t\t\tconst escaped = name.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\t\t\tconst regex = new RegExp(`\\\\b${escaped}\\\\b`, \"i\");\n\t\t\treturn regex.test(text);\n\t\t} catch {\n\t\t\t/* Invalid regex from buddy name — safe to return false */\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Event handling\n\t// =========================================================================\n\n\t/**\n\t * Process an agent event for buddy context capture and reaction triggers.\n\t * The host calls this from its event handler.\n\t *\n\t * Context capture always happens. Reactions are gated by `enabled`.\n\t */\n\thandleEvent(event: { type: string; [key: string]: any }): void {\n\t\tconst state = this.manager.getState();\n\t\tif (!state) return; // No buddy loaded\n\n\t\tswitch (event.type) {\n\t\t\tcase \"message_end\": {\n\t\t\t\tif (event.message?.role === \"assistant\") {\n\t\t\t\t\tfor (const entry of labelMessageEnd(event.message)) {\n\t\t\t\t\t\tthis.appendContext(entry);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"tool_execution_end\": {\n\t\t\t\t// Context capture (always)\n\t\t\t\tthis.appendContext(\n\t\t\t\t\tlabelToolEnd({ toolName: event.toolName, isError: event.isError, result: event.result }),\n\t\t\t\t);\n\n\t\t\t\t// Reaction on error (gated by enabled)\n\t\t\t\tif (event.isError && this.enabled) {\n\t\t\t\t\tlet errorText = \"unknown error\";\n\t\t\t\t\tconst result = event.result;\n\t\t\t\t\tif (result?.content && Array.isArray(result.content)) {\n\t\t\t\t\t\terrorText = result.content\n\t\t\t\t\t\t\t.filter((c: any) => c.type === \"text\")\n\t\t\t\t\t\t\t.map((c: any) => c.text)\n\t\t\t\t\t\t\t.join(\"\");\n\t\t\t\t\t} else if (typeof result?.error === \"string\") {\n\t\t\t\t\t\terrorText = result.error;\n\t\t\t\t\t}\n\t\t\t\t\tif (!errorText) errorText = \"unknown error\";\n\t\t\t\t\tthis.triggerReaction(`Tool \"${event.toolName}\" failed: ${errorText.slice(0, 2000)}`).catch(() => {\n\t\t\t\t\t\t/* triggerReaction() logs errors internally */\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"agent_end\": {\n\t\t\t\t// Re-sync the persisted hidden flag once per response — cheap at\n\t\t\t\t// this cadence, and the point at which a /buddy off performed by\n\t\t\t\t// another concurrent instance must take effect here.\n\t\t\t\tthis.syncHiddenState();\n\t\t\t\tif (this.enabled) {\n\t\t\t\t\tconst ctx = this.buildContext();\n\t\t\t\t\tthis.triggerReaction(`The agent finished responding. Recent activity:\\n${ctx}`).catch(() => {\n\t\t\t\t\t\t/* triggerReaction() logs errors internally */\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Process a user message — captures context, resets idle, checks name-call.\n\t * Context capture always happens. Active features gated by `enabled`.\n\t * Returns true if a name-call was detected and is being handled.\n\t */\n\tprocessUserMessage(text: string): boolean {\n\t\tthis.syncHiddenState();\n\t\tthis.appendContext(`User: ${text}`);\n\t\tthis.markActivity();\n\t\tthis.resetIdleTimer();\n\n\t\t// Name-call detection (gated by enabled via detectNameCall)\n\t\tif (this.detectNameCall(text)) {\n\t\t\tthis.handleNameCall(text).catch(() => {\n\t\t\t\t/* handleNameCall() logs errors internally — prevents unhandled rejection */\n\t\t\t});\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\t// =========================================================================\n\t// Command handling\n\t// =========================================================================\n\n\t/**\n\t * Handle a /buddy command. Returns a result object for the frontend to render.\n\t * Hatch/reroll are delegated to the frontend via onHatch/onReroll callbacks.\n\t */\n\tasync handleCommand(subcommand: string): Promise<BuddyCommandResult> {\n\t\tswitch (subcommand) {\n\t\t\tcase \"pet\": {\n\t\t\t\tif (!this.manager.getState()) {\n\t\t\t\t\treturn { type: \"warning\", message: \"No buddy to pet! Use /buddy to hatch one first.\" };\n\t\t\t\t}\n\t\t\t\treturn { type: \"pet\" };\n\t\t\t}\n\t\t\tcase \"reroll\": {\n\t\t\t\tif (!this.manager.hasStoredBuddy()) {\n\t\t\t\t\treturn { type: \"warning\", message: \"No buddy to reroll! Use /buddy to hatch one first.\" };\n\t\t\t\t}\n\t\t\t\tthis.callbacks.onThinkingStart();\n\t\t\t\ttry {\n\t\t\t\t\tconst state = await this.callbacks.onReroll(this.manager);\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.manager.setHidden(false);\n\t\t\t\t\tthis.lastVisible = true;\n\t\t\t\t\treturn { type: \"reroll\", state };\n\t\t\t\t} catch (err) {\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\treturn { type: \"error\", message: `Reroll failed: ${err instanceof Error ? err.message : String(err)}` };\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase \"stats\": {\n\t\t\t\tconst state = this.manager.getState();\n\t\t\t\tif (!state) {\n\t\t\t\t\treturn { type: \"warning\", message: \"No buddy to show stats for! Use /buddy to hatch one first.\" };\n\t\t\t\t}\n\t\t\t\treturn { type: \"stats\", state };\n\t\t\t}\n\t\t\tcase \"off\": {\n\t\t\t\tthis.enabled = false;\n\t\t\t\tthis.manager.setHidden(true);\n\t\t\t\tthis.lastVisible = false;\n\t\t\t\tthis.stop();\n\t\t\t\treturn { type: \"off\" };\n\t\t\t}\n\t\t\tdefault: {\n\t\t\t\t// Handle \"/buddy model\" and \"/buddy model <name>\"\n\t\t\t\tif (subcommand === \"model\" || subcommand.startsWith(\"model \")) {\n\t\t\t\t\treturn this.handleModelCommand(subcommand);\n\t\t\t\t}\n\n\t\t\t\t// No subcommand: hatch or show\n\t\t\t\tconst current = this.manager.getState();\n\t\t\t\tif (current) {\n\t\t\t\t\t// Already showing — just enable and return\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.manager.setHidden(false);\n\t\t\t\t\tthis.lastVisible = true;\n\t\t\t\t\treturn { type: \"show\", state: current };\n\t\t\t\t}\n\n\t\t\t\t// Try to load existing buddy\n\t\t\t\tconst existing = this.manager.load();\n\t\t\t\tif (existing) {\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.manager.setHidden(false);\n\t\t\t\t\tthis.lastVisible = true;\n\t\t\t\t\treturn { type: \"show\", state: existing };\n\t\t\t\t}\n\n\t\t\t\t// Hatch new buddy\n\t\t\t\tthis.callbacks.onThinkingStart();\n\t\t\t\ttry {\n\t\t\t\t\tconst hatchState = await this.callbacks.onHatch(this.manager);\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\tthis.enabled = true;\n\t\t\t\t\tthis.lastVisible = true;\n\t\t\t\t\treturn { type: \"hatch\", state: hatchState };\n\t\t\t\t} catch (err) {\n\t\t\t\t\tthis.callbacks.onThinkingEnd();\n\t\t\t\t\treturn { type: \"error\", message: `Hatch failed: ${err instanceof Error ? err.message : String(err)}` };\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Model selection\n\t// =========================================================================\n\n\t/** Handle /buddy model [name] — show current model or set a new one */\n\tprivate async handleModelCommand(subcommand: string): Promise<BuddyCommandResult> {\n\t\tconst modelArg = subcommand.slice(\"model\".length).trim();\n\n\t\tif (!modelArg) {\n\t\t\t// \"/buddy model\" with no argument — show current + available\n\t\t\tconst current = this.manager.getOllamaModel();\n\t\t\tconst status = await checkOllama();\n\t\t\tif (!status.available) {\n\t\t\t\treturn { type: \"model\", message: status.error ?? \"Ollama is not available.\" };\n\t\t\t}\n\t\t\tconst available = status.models.map((m) => ` • ${m}`).join(\"\\n\");\n\t\t\tif (current) {\n\t\t\t\treturn {\n\t\t\t\t\ttype: \"model\",\n\t\t\t\t\tmessage: `Current model: ${current}\\n\\nAvailable models:\\n${available}\\n\\nChange with: /buddy model <name>`,\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn {\n\t\t\t\ttype: \"model\",\n\t\t\t\tmessage: `No model set. Choose one with: /buddy model <name>\\n\\nAvailable models:\\n${available}`,\n\t\t\t};\n\t\t}\n\n\t\t// \"/buddy model <name>\" — set the model\n\t\tif (!this.manager.getState() && !this.manager.hasStoredBuddy()) {\n\t\t\treturn { type: \"warning\", message: \"No buddy yet — hatch one first with /buddy, then set a model.\" };\n\t\t}\n\n\t\tconst status = await checkOllama();\n\t\tif (!status.available) {\n\t\t\treturn { type: \"error\", message: status.error ?? \"Ollama is not available.\" };\n\t\t}\n\n\t\t// Check if the model is installed\n\t\tconst match = status.models.find((m) => m === modelArg || m.startsWith(`${modelArg}:`));\n\t\tif (!match) {\n\t\t\tconst available = status.models.map((m) => ` • ${m}`).join(\"\\n\");\n\t\t\treturn {\n\t\t\t\ttype: \"error\",\n\t\t\t\tmessage: `Model \"${modelArg}\" not found. Available models:\\n${available}\\n\\nPull it first with: ollama pull ${modelArg}`,\n\t\t\t};\n\t\t}\n\n\t\tthis.manager.setOllamaModel(match);\n\t\treturn { type: \"model\", message: `Buddy model set to: ${match}` };\n\t}\n\n\t/** Check if an Ollama model is configured, return a nudge message if not */\n\tgetModelNudge(): string | null {\n\t\tif (this.manager.getOllamaModel()) return null;\n\t\treturn \"No Ollama model set — reactions are disabled. Run /buddy model to choose one.\";\n\t}\n\n\t// =========================================================================\n\t// Lifecycle\n\t// =========================================================================\n\n\t/** Start the controller — auto-load buddy if one exists */\n\tstart(): BuddyState | null {\n\t\tconst existing = this.manager.load();\n\t\tif (existing) {\n\t\t\t// If buddy was hidden (via /buddy off), keep it loaded but disabled\n\t\t\tthis.enabled = !existing.hidden;\n\t\t\tthis.lastVisible = !existing.hidden;\n\t\t\treturn existing;\n\t\t}\n\t\tthis.lastVisible = false;\n\t\treturn null;\n\t}\n\n\t/**\n\t * Re-read the persisted `hidden` flag from disk and converge local state to\n\t * it. This is what makes `/buddy off` (or re-enable via `/buddy`) propagate\n\t * across concurrently-running dreb instances: the flag lives in the shared\n\t * buddy.json, and each instance re-checks it at turn-level sync points\n\t * (user message, agent_end, bridge reset) rather than caching it for the\n\t * life of the process.\n\t *\n\t * A buddy that is absent on disk (file deleted) or hidden counts as not\n\t * visible. On a visibility transition the host is notified via\n\t * onVisibilityChange so it can mount/unmount its widget.\n\t */\n\tprivate syncHiddenState(): void {\n\t\tconst exists = this.manager.hasStoredBuddy();\n\t\tconst visible = exists && !this.manager.isHidden();\n\t\tthis.enabled = visible;\n\n\t\tif (visible === this.lastVisible) return;\n\n\t\tthis.lastVisible = visible;\n\t\tif (visible) {\n\t\t\t// Refresh in-memory state from disk so reactions and the host mount\n\t\t\t// see the current buddy (it may have been hatched/changed elsewhere).\n\t\t\tthis.manager.load();\n\t\t} else {\n\t\t\tthis.stop();\n\t\t}\n\t\ttry {\n\t\t\tthis.callbacks.onVisibilityChange?.(visible);\n\t\t} catch (err) {\n\t\t\tlog.debug(`[buddy] onVisibilityChange failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t}\n\t}\n\n\t/**\n\t * Public sync point for hosts that don't route user input through\n\t * processUserMessage(). The TUI inlines its buddy wiring (appendContext /\n\t * idle / name-call) rather than calling processUserMessage(), so it calls\n\t * this on each user message to converge the buddy's visibility to the shared\n\t * buddy.json — i.e. pick up a /buddy off (or re-enable) from another instance.\n\t */\n\trefreshVisibility(): void {\n\t\tthis.syncHiddenState();\n\t}\n\n\t/** Stop the controller — clear timers */\n\tstop(): void {\n\t\tif (this.idleTimer) {\n\t\t\tclearTimeout(this.idleTimer);\n\t\t\tthis.idleTimer = null;\n\t\t}\n\t}\n\n\t/** Full reset — clear context buffer, idle timer, reaction budget.\n\t * Re-reads the persisted hidden state from disk so bridge reconnects pick up\n\t * a /buddy off performed in another instance (and don't undo it). */\n\treset(): void {\n\t\tthis.stop();\n\t\tthis.contextBuffer = [];\n\t\tthis.lastReactionTime = 0;\n\t\tthis.reactionTimestamps = [];\n\t\t// Refresh in-memory state from disk, then converge to the persisted flag.\n\t\tthis.manager.load();\n\t\tthis.syncHiddenState();\n\t}\n}\n"]}
|
|
@@ -62,6 +62,12 @@ export declare class BuddyManager {
|
|
|
62
62
|
setOllamaModel(modelName: string): void;
|
|
63
63
|
/** Reset Ollama status cache (e.g. after detecting it became available) */
|
|
64
64
|
resetOllamaCache(): void;
|
|
65
|
+
/**
|
|
66
|
+
* Fresh-read the persisted `hidden` flag from disk (bypasses the in-memory
|
|
67
|
+
* cache). Returns false when no buddy is stored. Used to detect a `/buddy off`
|
|
68
|
+
* (or re-enable) performed by another concurrently-running dreb instance.
|
|
69
|
+
*/
|
|
70
|
+
isHidden(): boolean;
|
|
65
71
|
/** Update the hidden flag in persisted storage */
|
|
66
72
|
setHidden(hidden: boolean): void;
|
|
67
73
|
}
|
|
@@ -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;AAkID,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,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, 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\twriteFileSync(path, JSON.stringify(stored, null, 2));\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/** 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;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"]}
|