bloby-bot 0.32.0 → 0.33.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.
@@ -0,0 +1,591 @@
1
+ /**
2
+ * Codex (OpenAI app-server) harness — Phase 2.
3
+ *
4
+ * One long-lived `codex app-server` subprocess per conversation. We talk to
5
+ * it via JSON-RPC 2.0 over stdio — bidirectional: client → server requests,
6
+ * server → client notifications. Tokens come from `~/.codex/auth.json`
7
+ * (codex reads them itself; we don't pass anything).
8
+ *
9
+ * Lifecycle per live conversation:
10
+ * spawn → initialize → initialized → thread/start → turn/start (per
11
+ * user message; turn/steer to inject mid-turn) → turn/completed → idle
12
+ * → endConversation → turn/interrupt (if needed) → kill subprocess
13
+ *
14
+ * Lifecycle per one-shot query: same as above, but the subprocess is killed
15
+ * as soon as `turn/completed` arrives.
16
+ *
17
+ * Model strings accept either bare ids (`gpt-5.5`) or `<id>:<effort>` where
18
+ * effort is one of low|medium|high|xhigh — the suffix is split off and
19
+ * passed as `effort` on `turn/start`.
20
+ *
21
+ * Notes on parity with Claude harness:
22
+ * - System prompt → `baseInstructions` on `thread/start`
23
+ * - Sub-agents → not implemented (Codex has Skills, different model)
24
+ * - MCP servers → not wired yet (Codex has its own MCP layer)
25
+ * - Mid-turn input uses `turn/steer` (better than Claude's queue)
26
+ */
27
+
28
+ import { spawn, type ChildProcessWithoutNullStreams } from 'child_process';
29
+ import readline from 'readline';
30
+ import fs from 'fs';
31
+ import path from 'path';
32
+ import { log } from '../../shared/logger.js';
33
+ import { WORKSPACE_DIR } from '../../shared/paths.js';
34
+ import type { SavedFile } from '../file-saver.js';
35
+ import { getCodexAccessToken } from '../../worker/codex-auth.js';
36
+ import { assembleSystemPrompt } from '../../worker/prompts/prompt-assembler.js';
37
+ import type { OnAgentMessage, RecentMessage, AgentAttachment } from './types.js';
38
+ export type { RecentMessage, AgentAttachment };
39
+
40
+ /* ── Constants ─────────────────────────────────────────────────────────── */
41
+
42
+ const CLIENT_INFO = { name: 'bloby', title: 'Bloby', version: '1' };
43
+ const REQUEST_TIMEOUT_MS = 60_000;
44
+ const VALID_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh']);
45
+
46
+ /* ── Prompt-assembly helpers (duplicated from claude.ts to keep that file
47
+ * untouched per the project rule) ───────────────────────────────────── */
48
+
49
+ function readMemoryFile(filename: string): string {
50
+ try {
51
+ const content = fs.readFileSync(path.join(WORKSPACE_DIR, filename), 'utf-8').trim();
52
+ return content || '(empty)';
53
+ } catch {
54
+ return '(empty)';
55
+ }
56
+ }
57
+
58
+ function readMemoryFiles() {
59
+ return {
60
+ myself: readMemoryFile('MYSELF.md'),
61
+ myhuman: readMemoryFile('MYHUMAN.md'),
62
+ memory: readMemoryFile('MEMORY.md'),
63
+ pulse: readMemoryFile('PULSE.json'),
64
+ crons: readMemoryFile('CRONS.json'),
65
+ };
66
+ }
67
+
68
+ function formatConversationHistory(messages: RecentMessage[]): string {
69
+ if (!messages.length) return '';
70
+ return messages.map((m) => `${m.role}: ${m.content}`).join('\n\n');
71
+ }
72
+
73
+ async function assembleBaseInstructions(
74
+ names?: { botName: string; humanName: string },
75
+ recentMessages?: RecentMessage[],
76
+ ): Promise<string> {
77
+ const memoryFiles = readMemoryFiles();
78
+ const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
79
+ let prompt = basePrompt;
80
+ prompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;
81
+
82
+ try {
83
+ const { loadConfig: loadCfg } = await import('../../shared/config.js');
84
+ const cfg = loadCfg();
85
+ const channels = (cfg as any).channels;
86
+ if (channels) {
87
+ prompt += `\n\n---\n# Channel Config\n\`\`\`json\n${JSON.stringify(channels, null, 2)}\n\`\`\``;
88
+ }
89
+ } catch {}
90
+
91
+ if (recentMessages?.length) {
92
+ prompt += `\n\n---\n# Recent Conversation\n${formatConversationHistory(recentMessages)}`;
93
+ }
94
+
95
+ return prompt;
96
+ }
97
+
98
+ /** Split `gpt-5.5:high` into `{ id: 'gpt-5.5', effort: 'high' }`. */
99
+ function parseModelString(model: string): { id: string; effort?: string } {
100
+ const idx = model.lastIndexOf(':');
101
+ if (idx <= 0) return { id: model };
102
+ const candidate = model.slice(idx + 1);
103
+ if (!VALID_EFFORTS.has(candidate)) return { id: model };
104
+ return { id: model.slice(0, idx), effort: candidate };
105
+ }
106
+
107
+ /* ── JSON-RPC client over stdio ────────────────────────────────────────── */
108
+
109
+ type RpcResult<T = any> = { id: number; result?: T; error?: { code?: number; message: string } };
110
+ type RpcNotification = { method: string; params?: any };
111
+
112
+ interface PendingRequest {
113
+ resolve: (value: any) => void;
114
+ reject: (err: Error) => void;
115
+ timer: NodeJS.Timeout;
116
+ }
117
+
118
+ class CodexRpc {
119
+ private proc: ChildProcessWithoutNullStreams | null = null;
120
+ private pending = new Map<number, PendingRequest>();
121
+ private nextId = 1;
122
+ private notificationHandler: (n: RpcNotification) => void = () => {};
123
+ private closeHandler: (code: number | null) => void = () => {};
124
+ private closed = false;
125
+ private stderrBuf = '';
126
+
127
+ start(): void {
128
+ this.proc = spawn('codex', ['app-server'], { stdio: ['pipe', 'pipe', 'pipe'] });
129
+ const rl = readline.createInterface({ input: this.proc.stdout });
130
+ rl.on('line', (line) => this.onLine(line));
131
+
132
+ this.proc.stderr.on('data', (chunk) => {
133
+ this.stderrBuf += chunk.toString();
134
+ // Trim if growing unbounded.
135
+ if (this.stderrBuf.length > 16_000) this.stderrBuf = this.stderrBuf.slice(-8_000);
136
+ });
137
+
138
+ this.proc.on('exit', (code) => {
139
+ if (this.closed) return;
140
+ this.closed = true;
141
+ const err = new Error(`codex app-server exited (code=${code}). Stderr tail:\n${this.stderrBuf.trim().slice(-1000)}`);
142
+ for (const p of this.pending.values()) {
143
+ clearTimeout(p.timer);
144
+ p.reject(err);
145
+ }
146
+ this.pending.clear();
147
+ this.closeHandler(code);
148
+ });
149
+
150
+ this.proc.on('error', (err) => {
151
+ if (this.closed) return;
152
+ this.closed = true;
153
+ log.warn(`[codex-rpc] spawn error: ${err.message}`);
154
+ for (const p of this.pending.values()) {
155
+ clearTimeout(p.timer);
156
+ p.reject(err);
157
+ }
158
+ this.pending.clear();
159
+ this.closeHandler(null);
160
+ });
161
+ }
162
+
163
+ onNotification(handler: (n: RpcNotification) => void): void { this.notificationHandler = handler; }
164
+ onClose(handler: (code: number | null) => void): void { this.closeHandler = handler; }
165
+
166
+ private onLine(line: string): void {
167
+ if (!line.trim()) return;
168
+ let msg: any;
169
+ try { msg = JSON.parse(line); } catch {
170
+ log.warn(`[codex-rpc] malformed JSON from server: ${line.slice(0, 200)}`);
171
+ return;
172
+ }
173
+ if (typeof msg.id === 'number') {
174
+ const pending = this.pending.get(msg.id);
175
+ if (!pending) return;
176
+ this.pending.delete(msg.id);
177
+ clearTimeout(pending.timer);
178
+ if (msg.error) pending.reject(new Error(msg.error.message || 'RPC error'));
179
+ else pending.resolve(msg.result);
180
+ return;
181
+ }
182
+ if (typeof msg.method === 'string') {
183
+ this.notificationHandler({ method: msg.method, params: msg.params });
184
+ }
185
+ }
186
+
187
+ request<T = any>(method: string, params?: any, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
188
+ if (this.closed || !this.proc) return Promise.reject(new Error('RPC connection closed'));
189
+ const id = this.nextId++;
190
+ return new Promise<T>((resolve, reject) => {
191
+ const timer = setTimeout(() => {
192
+ this.pending.delete(id);
193
+ reject(new Error(`codex app-server: ${method} timed out after ${timeoutMs}ms`));
194
+ }, timeoutMs);
195
+ this.pending.set(id, { resolve, reject, timer });
196
+ try {
197
+ this.proc!.stdin.write(JSON.stringify({ method, id, params }) + '\n');
198
+ } catch (err: any) {
199
+ this.pending.delete(id);
200
+ clearTimeout(timer);
201
+ reject(err);
202
+ }
203
+ });
204
+ }
205
+
206
+ notify(method: string, params?: any): void {
207
+ if (this.closed || !this.proc) return;
208
+ try {
209
+ this.proc.stdin.write(JSON.stringify({ method, params }) + '\n');
210
+ } catch (err: any) {
211
+ log.warn(`[codex-rpc] notify ${method} failed: ${err.message}`);
212
+ }
213
+ }
214
+
215
+ close(): void {
216
+ if (this.closed) return;
217
+ this.closed = true;
218
+ for (const p of this.pending.values()) {
219
+ clearTimeout(p.timer);
220
+ p.reject(new Error('RPC connection closed'));
221
+ }
222
+ this.pending.clear();
223
+ try { this.proc?.stdin.end(); } catch {}
224
+ try { this.proc?.kill('SIGTERM'); } catch {}
225
+ this.proc = null;
226
+ }
227
+ }
228
+
229
+ /* ── Per-conversation state ────────────────────────────────────────────── */
230
+
231
+ interface CodexConversation {
232
+ id: string;
233
+ rpc: CodexRpc;
234
+ threadId: string;
235
+ effort?: string;
236
+ onMessage: OnAgentMessage;
237
+ /** Currently in-flight turn id (set on `turn/started`, cleared on `turn/completed`). */
238
+ currentTurnId: string | null;
239
+ /** Streaming text accumulator for the current turn's agentMessage items. */
240
+ fullText: string;
241
+ /** Tools/items used during the current turn, for the bot:turn-complete payload. */
242
+ usedFileTools: boolean;
243
+ /**
244
+ * Queue of messages submitted via `pushMessage` that arrived while no turn
245
+ * was active *and* we hadn't yet returned from the previous turn — almost
246
+ * always empty, but covers a tight push-during-completed race.
247
+ */
248
+ pendingInputs: string[];
249
+ /** True once the harness has emitted the per-turn completion event. */
250
+ busy: boolean;
251
+ /** True for one-shot queries — the conversation ends after the first turn completes. */
252
+ oneShot: boolean;
253
+ }
254
+
255
+ const conversations = new Map<string, CodexConversation>();
256
+
257
+ /* ── Helpers ───────────────────────────────────────────────────────────── */
258
+
259
+ function buildUserInput(text: string, savedFiles?: SavedFile[]): Array<Record<string, any>> {
260
+ const input: Array<Record<string, any>> = [];
261
+
262
+ let promptText = text || '(attached files)';
263
+ if (savedFiles?.length) {
264
+ const lines = savedFiles.map((f) => `- ${f.name} -> ${f.relPath}`);
265
+ promptText += `\n\n[Attached files saved to disk]\n${lines.join('\n')}\nYou can read or reference these files using the paths above (relative to your cwd).`;
266
+ }
267
+ input.push({ type: 'text', text: promptText });
268
+
269
+ // Codex understands `localImage` (path on disk) — Bloby's file-saver already
270
+ // wrote attachments to disk, so we just point at the absolute path.
271
+ if (savedFiles?.length) {
272
+ for (const f of savedFiles) {
273
+ if (f.type === 'image') input.push({ type: 'localImage', path: f.absPath });
274
+ }
275
+ }
276
+
277
+ return input;
278
+ }
279
+
280
+ async function startTurn(conv: CodexConversation, content: string, savedFiles?: SavedFile[]): Promise<void> {
281
+ const input = buildUserInput(content, savedFiles);
282
+ conv.busy = true;
283
+ conv.fullText = '';
284
+ conv.usedFileTools = false;
285
+ conv.onMessage('bot:typing', { conversationId: conv.id });
286
+ try {
287
+ const params: Record<string, any> = { threadId: conv.threadId, input };
288
+ if (conv.effort) params.effort = conv.effort;
289
+ await conv.rpc.request('turn/start', params);
290
+ } catch (err: any) {
291
+ conv.busy = false;
292
+ conv.onMessage('bot:error', { conversationId: conv.id, error: `turn/start failed: ${err.message}` });
293
+ }
294
+ }
295
+
296
+ async function steerOrQueue(conv: CodexConversation, content: string, savedFiles?: SavedFile[]): Promise<void> {
297
+ if (!conv.currentTurnId) {
298
+ // No active turn — start a fresh one.
299
+ await startTurn(conv, content, savedFiles);
300
+ return;
301
+ }
302
+ // Active turn — inject mid-flight.
303
+ const input = buildUserInput(content, savedFiles);
304
+ try {
305
+ await conv.rpc.request('turn/steer', {
306
+ threadId: conv.threadId,
307
+ expectedTurnId: conv.currentTurnId,
308
+ input,
309
+ });
310
+ conv.onMessage('bot:typing', { conversationId: conv.id });
311
+ } catch (err: any) {
312
+ // expectedTurnId mismatch most likely means the turn just finished —
313
+ // retry as a fresh turn.
314
+ log.warn(`[codex] turn/steer failed (${err.message}); falling back to turn/start`);
315
+ if (!conv.currentTurnId) await startTurn(conv, content, savedFiles);
316
+ else conv.pendingInputs.push(content);
317
+ }
318
+ }
319
+
320
+ function handleNotification(conv: CodexConversation, n: { method: string; params?: any }): void {
321
+ const p = n.params || {};
322
+ switch (n.method) {
323
+ case 'turn/started': {
324
+ conv.currentTurnId = p.turn?.id || null;
325
+ conv.fullText = '';
326
+ conv.usedFileTools = false;
327
+ break;
328
+ }
329
+
330
+ case 'item/agentMessage/delta': {
331
+ const delta: string = p.delta || '';
332
+ if (!delta) break;
333
+ conv.fullText += delta;
334
+ conv.onMessage('bot:token', { conversationId: conv.id, token: delta });
335
+ break;
336
+ }
337
+
338
+ case 'item/started': {
339
+ const item = p.item || {};
340
+ // Surface tool-like items so the dashboard can show activity.
341
+ switch (item.type) {
342
+ case 'commandExecution':
343
+ conv.onMessage('bot:tool', {
344
+ conversationId: conv.id,
345
+ name: 'shell',
346
+ input: { command: item.command || item.commandLine || '' },
347
+ });
348
+ break;
349
+ case 'mcpToolCall':
350
+ conv.onMessage('bot:tool', {
351
+ conversationId: conv.id,
352
+ name: item.toolName || item.name || 'mcp_tool',
353
+ input: item.arguments || item.input || {},
354
+ });
355
+ break;
356
+ case 'fileChange':
357
+ conv.usedFileTools = true;
358
+ conv.onMessage('bot:tool', {
359
+ conversationId: conv.id,
360
+ name: 'file_change',
361
+ input: { changes: (item.changes || []).map((c: any) => c.path).filter(Boolean) },
362
+ });
363
+ break;
364
+ case 'webSearch':
365
+ conv.onMessage('bot:tool', {
366
+ conversationId: conv.id,
367
+ name: 'web_search',
368
+ input: { query: item.query || '' },
369
+ });
370
+ break;
371
+ // userMessage / agentMessage / reasoning — no tool-style event.
372
+ }
373
+ break;
374
+ }
375
+
376
+ case 'item/completed': {
377
+ const item = p.item || {};
378
+ if (item.type === 'fileChange') conv.usedFileTools = true;
379
+ // If a final agentMessage arrives without preceding deltas (rare), grab it now.
380
+ if (item.type === 'agentMessage' && !conv.fullText) {
381
+ const text = (item.content || []).map((c: any) => c.text || '').join('') || item.text || '';
382
+ if (text) {
383
+ conv.fullText = text;
384
+ conv.onMessage('bot:token', { conversationId: conv.id, token: text });
385
+ }
386
+ }
387
+ break;
388
+ }
389
+
390
+ case 'turn/completed': {
391
+ const status: string = p.turn?.status || 'completed';
392
+ const turnError = p.turn?.error;
393
+
394
+ conv.currentTurnId = null;
395
+ conv.busy = false;
396
+
397
+ if (status === 'failed' || status === 'systemError') {
398
+ conv.onMessage('bot:error', {
399
+ conversationId: conv.id,
400
+ error: turnError?.message || 'Codex turn failed.',
401
+ });
402
+ } else if (conv.fullText) {
403
+ conv.onMessage('bot:response', { conversationId: conv.id, content: conv.fullText });
404
+ }
405
+
406
+ if (conv.oneShot) {
407
+ conv.onMessage('bot:done', { conversationId: conv.id, usedFileTools: conv.usedFileTools });
408
+ teardownConversation(conv.id);
409
+ } else {
410
+ conv.onMessage('bot:turn-complete', { conversationId: conv.id, usedFileTools: conv.usedFileTools });
411
+
412
+ // Drain any messages that were submitted while we were busy.
413
+ const next = conv.pendingInputs.shift();
414
+ if (next !== undefined) void startTurn(conv, next);
415
+ }
416
+ break;
417
+ }
418
+
419
+ case 'error': {
420
+ const errMsg = p.error?.message || 'Codex error notification';
421
+ conv.onMessage('bot:error', { conversationId: conv.id, error: errMsg });
422
+ break;
423
+ }
424
+
425
+ // thread/started, thread/status/changed, mcpServer/startupStatus/updated,
426
+ // remoteControl/status/changed — informational, no-op for the dashboard.
427
+ }
428
+ }
429
+
430
+ function teardownConversation(conversationId: string): void {
431
+ const conv = conversations.get(conversationId);
432
+ if (!conv) return;
433
+ conversations.delete(conversationId);
434
+ try { conv.rpc.close(); } catch {}
435
+ conv.onMessage('bot:conversation-ended', { conversationId });
436
+ }
437
+
438
+ async function spawnAndInitialize(
439
+ conversationId: string,
440
+ model: string,
441
+ onMessage: OnAgentMessage,
442
+ baseInstructions: string,
443
+ oneShot: boolean,
444
+ ): Promise<CodexConversation | null> {
445
+ // Pre-flight: confirm we have valid OAuth tokens before spending time spawning.
446
+ const token = await getCodexAccessToken();
447
+ if (!token) {
448
+ onMessage('bot:error', {
449
+ conversationId,
450
+ error: 'Codex credentials not found or expired. Re-authenticate from the dashboard.',
451
+ });
452
+ return null;
453
+ }
454
+
455
+ const { id: modelId, effort } = parseModelString(model);
456
+ const rpc = new CodexRpc();
457
+ rpc.start();
458
+
459
+ const conv: CodexConversation = {
460
+ id: conversationId,
461
+ rpc,
462
+ threadId: '',
463
+ effort,
464
+ onMessage,
465
+ currentTurnId: null,
466
+ fullText: '',
467
+ usedFileTools: false,
468
+ pendingInputs: [],
469
+ busy: false,
470
+ oneShot,
471
+ };
472
+
473
+ rpc.onNotification((n) => handleNotification(conv, n));
474
+ rpc.onClose(() => {
475
+ if (conversations.get(conversationId) === conv) {
476
+ conversations.delete(conversationId);
477
+ onMessage('bot:conversation-ended', { conversationId });
478
+ }
479
+ });
480
+
481
+ try {
482
+ log.info(`[codex] init conversation ${conversationId} (model=${modelId}${effort ? `, effort=${effort}` : ''})`);
483
+ await rpc.request('initialize', { clientInfo: CLIENT_INFO });
484
+ rpc.notify('initialized', {});
485
+ const startResult = await rpc.request<{ thread: { id: string } }>('thread/start', {
486
+ cwd: WORKSPACE_DIR,
487
+ model: modelId,
488
+ baseInstructions,
489
+ });
490
+ conv.threadId = startResult.thread.id;
491
+ conversations.set(conversationId, conv);
492
+ log.ok(`[codex] thread started ${conv.threadId}`);
493
+ return conv;
494
+ } catch (err: any) {
495
+ rpc.close();
496
+ onMessage('bot:error', { conversationId, error: `Failed to initialize Codex: ${err.message}` });
497
+ return null;
498
+ }
499
+ }
500
+
501
+ /* ── Harness implementation ────────────────────────────────────────────── */
502
+
503
+ export function hasConversation(conversationId: string): boolean {
504
+ return conversations.has(conversationId);
505
+ }
506
+
507
+ export function isConversationBusy(conversationId: string): boolean {
508
+ return conversations.get(conversationId)?.busy ?? false;
509
+ }
510
+
511
+ export async function startConversation(
512
+ conversationId: string,
513
+ model: string,
514
+ onMessage: OnAgentMessage,
515
+ names?: { botName: string; humanName: string },
516
+ recentMessages?: RecentMessage[],
517
+ ): Promise<boolean> {
518
+ if (conversations.has(conversationId)) endConversation(conversationId);
519
+ const baseInstructions = await assembleBaseInstructions(names, recentMessages);
520
+ const conv = await spawnAndInitialize(conversationId, model, onMessage, baseInstructions, false);
521
+ return !!conv;
522
+ }
523
+
524
+ export function pushMessage(
525
+ conversationId: string,
526
+ content: string,
527
+ _attachments?: AgentAttachment[],
528
+ savedFiles?: SavedFile[],
529
+ ): boolean {
530
+ const conv = conversations.get(conversationId);
531
+ if (!conv) {
532
+ log.warn(`[codex] pushMessage: no live conversation ${conversationId}`);
533
+ return false;
534
+ }
535
+ void steerOrQueue(conv, content, savedFiles);
536
+ return true;
537
+ }
538
+
539
+ export function endConversation(conversationId: string): void {
540
+ const conv = conversations.get(conversationId);
541
+ if (!conv) return;
542
+ log.info(`[codex] ending conversation ${conversationId}`);
543
+ if (conv.currentTurnId) {
544
+ void conv.rpc.request('turn/interrupt', {
545
+ threadId: conv.threadId,
546
+ turnId: conv.currentTurnId,
547
+ }).catch(() => {});
548
+ }
549
+ teardownConversation(conversationId);
550
+ }
551
+
552
+ export function endAllConversations(): void {
553
+ for (const id of Array.from(conversations.keys())) endConversation(id);
554
+ }
555
+
556
+ export async function stopSubAgentTask(_conversationId: string, _taskId: string): Promise<void> {
557
+ // Codex doesn't expose Claude-style sub-agent tasks. No-op for now.
558
+ }
559
+
560
+ export async function warmUpForLiveConversation(
561
+ _model: string,
562
+ _names?: { botName: string; humanName: string },
563
+ ): Promise<void> {
564
+ // No subprocess pre-warming yet — `codex app-server` startup is fast enough
565
+ // (~hundreds of ms). Re-evaluate if it becomes noticeable on the Pi.
566
+ }
567
+
568
+ export async function startBlobyAgentQuery(
569
+ conversationId: string,
570
+ prompt: string,
571
+ model: string,
572
+ onMessage: OnAgentMessage,
573
+ _attachments?: AgentAttachment[],
574
+ savedFiles?: SavedFile[],
575
+ names?: { botName: string; humanName: string },
576
+ recentMessages?: RecentMessage[],
577
+ supportPrompt?: string,
578
+ _maxTurns?: number,
579
+ ): Promise<void> {
580
+ if (conversations.has(conversationId)) endConversation(conversationId);
581
+ const baseInstructions = supportPrompt
582
+ ? supportPrompt
583
+ : await assembleBaseInstructions(names, recentMessages);
584
+ const conv = await spawnAndInitialize(conversationId, model, onMessage, baseInstructions, true);
585
+ if (!conv) return;
586
+ await startTurn(conv, prompt, savedFiles);
587
+ }
588
+
589
+ export function stopBlobyAgentQuery(conversationId: string): void {
590
+ endConversation(conversationId);
591
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Harness — provider-agnostic agent runtime contract.
3
+ *
4
+ * Each AI provider that ships an "agent" experience (Claude Agent SDK,
5
+ * Codex app-server, …) implements this interface. The dispatcher in
6
+ * `supervisor/bloby-agent.ts` selects which one to use based on
7
+ * `cfg.ai.provider` so the rest of the supervisor never needs to care.
8
+ *
9
+ * The two execution shapes mirror what the supervisor already needs:
10
+ * • startConversation / pushMessage / endConversation — long-lived chat
11
+ * with streaming events. Used by the dashboard and admin WhatsApp.
12
+ * • startBlobyAgentQuery / stopBlobyAgentQuery — one-shot request/response
13
+ * used by customer WhatsApp and the scheduler.
14
+ *
15
+ * Event names emitted via the `onMessage` callback are the same vocabulary
16
+ * `supervisor/index.ts` and `channels/manager.ts` already consume — every
17
+ * harness translates its native events into this set:
18
+ * bot:typing, bot:token, bot:tool, bot:response, bot:turn-complete,
19
+ * bot:done, bot:error, bot:task-created, bot:task-progress, bot:task-done,
20
+ * bot:conversation-ended.
21
+ */
22
+
23
+ import type { SavedFile } from '../file-saver.js';
24
+
25
+ export interface RecentMessage {
26
+ role: 'user' | 'assistant';
27
+ content: string;
28
+ }
29
+
30
+ export interface AgentAttachment {
31
+ type: 'image' | 'file';
32
+ name: string;
33
+ mediaType: string;
34
+ data: string; // base64
35
+ }
36
+
37
+ export type OnAgentMessage = (type: string, data: any) => void;
38
+
39
+ export interface Harness {
40
+ /* ── Live conversation API ── */
41
+ startConversation(
42
+ conversationId: string,
43
+ model: string,
44
+ onMessage: OnAgentMessage,
45
+ names?: { botName: string; humanName: string },
46
+ recentMessages?: RecentMessage[],
47
+ ): Promise<boolean>;
48
+
49
+ pushMessage(
50
+ conversationId: string,
51
+ content: string,
52
+ attachments?: AgentAttachment[],
53
+ savedFiles?: SavedFile[],
54
+ ): boolean;
55
+
56
+ hasConversation(conversationId: string): boolean;
57
+ endConversation(conversationId: string): void;
58
+ endAllConversations(): void;
59
+ isConversationBusy(conversationId: string): boolean;
60
+ stopSubAgentTask(conversationId: string, taskId: string): Promise<void>;
61
+ warmUpForLiveConversation(
62
+ model: string,
63
+ names?: { botName: string; humanName: string },
64
+ ): Promise<void>;
65
+
66
+ /* ── One-shot API (request/response) ── */
67
+ startBlobyAgentQuery(
68
+ conversationId: string,
69
+ prompt: string,
70
+ model: string,
71
+ onMessage: OnAgentMessage,
72
+ attachments?: AgentAttachment[],
73
+ savedFiles?: SavedFile[],
74
+ names?: { botName: string; humanName: string },
75
+ recentMessages?: RecentMessage[],
76
+ supportPrompt?: string,
77
+ maxTurns?: number,
78
+ ): Promise<void>;
79
+
80
+ stopBlobyAgentQuery(conversationId: string): void;
81
+ }