bloby-bot 0.53.9 → 0.54.10
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/package.json +2 -2
- package/shared/config.ts +5 -0
- package/supervisor/backend.ts +29 -4
- package/supervisor/channels/manager.ts +81 -19
- package/supervisor/channels/types.ts +5 -0
- package/supervisor/chat/bloby-main.tsx +1 -1
- package/supervisor/chat/src/components/Chat/EnvForm.tsx +2 -1
- package/supervisor/chat/src/hooks/useChat.ts +6 -5
- package/supervisor/harnesses/claude.ts +12 -2
- package/supervisor/harnesses/codex.ts +117 -22
- package/supervisor/harnesses/pi/index.ts +8 -1
- package/supervisor/index.ts +218 -53
- package/worker/prompts/bloby-system-prompt-codex.txt +778 -0
- package/worker/prompts/bloby-system-prompt-pi.txt +778 -0
- package/worker/prompts/prompt-assembler.ts +49 -14
- package/workspace/skills/whatsapp/SKILL.md +25 -2
|
@@ -42,7 +42,15 @@ export type { RecentMessage, AgentAttachment };
|
|
|
42
42
|
|
|
43
43
|
const CLIENT_INFO = { name: 'bloby', title: 'Bloby', version: '1' };
|
|
44
44
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
45
|
-
const VALID_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh']);
|
|
45
|
+
const VALID_EFFORTS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']);
|
|
46
|
+
/**
|
|
47
|
+
* Per-turn watchdog. `turn/completed` is a NON-guaranteed notification — if the
|
|
48
|
+
* app-server stalls mid-turn without exiting, the RPC `exit` handler never fires
|
|
49
|
+
* and `busy` stays true forever (live: wedges the dashboard + defers backend
|
|
50
|
+
* restarts; one-shot: pins the WhatsApp/scheduler slot since bot:done never
|
|
51
|
+
* arrives). Claude's one-shot path has the same 5-min guard. Mirrors it here.
|
|
52
|
+
*/
|
|
53
|
+
const TURN_WATCHDOG_MS = 5 * 60_000;
|
|
46
54
|
|
|
47
55
|
/**
|
|
48
56
|
* Resolve the `codex` binary. We don't trust $PATH because Bloby may be
|
|
@@ -115,7 +123,7 @@ async function assembleBaseInstructions(
|
|
|
115
123
|
recentMessages?: RecentMessage[],
|
|
116
124
|
): Promise<string> {
|
|
117
125
|
const memoryFiles = readMemoryFiles();
|
|
118
|
-
const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
|
|
126
|
+
const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName, 'codex');
|
|
119
127
|
let prompt = basePrompt;
|
|
120
128
|
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}`;
|
|
121
129
|
|
|
@@ -326,6 +334,15 @@ interface CodexConversation {
|
|
|
326
334
|
busy: boolean;
|
|
327
335
|
/** True for one-shot queries — the conversation ends after the first turn completes. */
|
|
328
336
|
oneShot: boolean;
|
|
337
|
+
/**
|
|
338
|
+
* Latest context occupancy from `thread/tokenUsage/updated` (codex does NOT
|
|
339
|
+
* report usage on `turn/completed` — Turn has no usage field). Emitted on
|
|
340
|
+
* `bot:turn-complete` so the orchestrator's proactive recycling can fire.
|
|
341
|
+
*/
|
|
342
|
+
lastContextTokens: number;
|
|
343
|
+
lastContextWindow: number;
|
|
344
|
+
/** Active per-turn watchdog timer (see TURN_WATCHDOG_MS). */
|
|
345
|
+
turnWatchdog: NodeJS.Timeout | null;
|
|
329
346
|
}
|
|
330
347
|
|
|
331
348
|
const conversations = new Map<string, CodexConversation>();
|
|
@@ -353,17 +370,59 @@ function buildUserInput(text: string, savedFiles?: SavedFile[]): Array<Record<st
|
|
|
353
370
|
return input;
|
|
354
371
|
}
|
|
355
372
|
|
|
373
|
+
function clearTurnWatchdog(conv: CodexConversation): void {
|
|
374
|
+
if (conv.turnWatchdog) {
|
|
375
|
+
clearTimeout(conv.turnWatchdog);
|
|
376
|
+
conv.turnWatchdog = null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Arm the per-turn watchdog. On fire, unstick the conversation the same way a
|
|
382
|
+
* real `turn/completed` would (so the dashboard, `anyConversationBusy`, and the
|
|
383
|
+
* channel slot all release), then tear the conversation down — the next message
|
|
384
|
+
* cold-starts a fresh thread.
|
|
385
|
+
*/
|
|
386
|
+
function armTurnWatchdog(conv: CodexConversation): void {
|
|
387
|
+
clearTurnWatchdog(conv);
|
|
388
|
+
conv.turnWatchdog = setTimeout(() => {
|
|
389
|
+
conv.turnWatchdog = null;
|
|
390
|
+
log.warn(`[codex] turn watchdog fired (${TURN_WATCHDOG_MS}ms) — conv=${conv.id}; unsticking + tearing down`);
|
|
391
|
+
conv.busy = false;
|
|
392
|
+
conv.currentTurnId = null;
|
|
393
|
+
conv.onMessage('bot:error', { conversationId: conv.id, error: 'Codex turn timed out — no response from app-server.' });
|
|
394
|
+
if (conv.oneShot) {
|
|
395
|
+
conv.onMessage('bot:done', { conversationId: conv.id, usedFileTools: conv.usedFileTools });
|
|
396
|
+
} else {
|
|
397
|
+
conv.onMessage('bot:turn-complete', {
|
|
398
|
+
conversationId: conv.id,
|
|
399
|
+
usedFileTools: conv.usedFileTools,
|
|
400
|
+
contextTokens: conv.lastContextTokens || 0,
|
|
401
|
+
contextWindow: conv.lastContextWindow || 0,
|
|
402
|
+
idle: true,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
teardownConversation(conv.id);
|
|
406
|
+
}, TURN_WATCHDOG_MS);
|
|
407
|
+
}
|
|
408
|
+
|
|
356
409
|
async function startTurn(conv: CodexConversation, content: string, savedFiles?: SavedFile[]): Promise<void> {
|
|
357
410
|
const input = buildUserInput(content, savedFiles);
|
|
358
411
|
conv.busy = true;
|
|
359
412
|
conv.fullText = '';
|
|
360
413
|
conv.usedFileTools = false;
|
|
361
414
|
conv.onMessage('bot:typing', { conversationId: conv.id });
|
|
415
|
+
armTurnWatchdog(conv);
|
|
362
416
|
try {
|
|
363
417
|
const params: Record<string, any> = { threadId: conv.threadId, input };
|
|
364
418
|
if (conv.effort) params.effort = conv.effort;
|
|
365
|
-
|
|
419
|
+
// turn/start resolves immediately with { turn }; seize the id now so a
|
|
420
|
+
// pushMessage arriving before the turn/started notification can steer
|
|
421
|
+
// instead of starting a second turn.
|
|
422
|
+
const res = await conv.rpc.request<{ turn?: { id?: string } }>('turn/start', params);
|
|
423
|
+
if (res?.turn?.id) conv.currentTurnId = res.turn.id;
|
|
366
424
|
} catch (err: any) {
|
|
425
|
+
clearTurnWatchdog(conv);
|
|
367
426
|
conv.busy = false;
|
|
368
427
|
conv.currentTurnId = null;
|
|
369
428
|
conv.onMessage('bot:error', { conversationId: conv.id, error: `turn/start failed: ${err.message}` });
|
|
@@ -385,11 +444,12 @@ async function steerOrQueue(conv: CodexConversation, content: string, savedFiles
|
|
|
385
444
|
// Active turn — inject mid-flight.
|
|
386
445
|
const input = buildUserInput(content, savedFiles);
|
|
387
446
|
try {
|
|
388
|
-
await conv.rpc.request('turn/steer', {
|
|
447
|
+
const res = await conv.rpc.request<{ turnId?: string }>('turn/steer', {
|
|
389
448
|
threadId: conv.threadId,
|
|
390
449
|
expectedTurnId: conv.currentTurnId,
|
|
391
450
|
input,
|
|
392
451
|
});
|
|
452
|
+
if (res?.turnId) conv.currentTurnId = res.turnId;
|
|
393
453
|
conv.onMessage('bot:typing', { conversationId: conv.id });
|
|
394
454
|
} catch (err: any) {
|
|
395
455
|
// expectedTurnId mismatch most likely means the turn just finished —
|
|
@@ -430,10 +490,11 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
|
|
|
430
490
|
});
|
|
431
491
|
break;
|
|
432
492
|
case 'mcpToolCall':
|
|
493
|
+
// ThreadItem.mcpToolCall fields are `server` + `tool` (no toolName/name/input).
|
|
433
494
|
conv.onMessage('bot:tool', {
|
|
434
495
|
conversationId: conv.id,
|
|
435
|
-
name: item.
|
|
436
|
-
input: item.arguments ||
|
|
496
|
+
name: item.tool ? (item.server ? `${item.server}/${item.tool}` : item.tool) : 'mcp_tool',
|
|
497
|
+
input: item.arguments || {},
|
|
437
498
|
});
|
|
438
499
|
break;
|
|
439
500
|
case 'fileChange':
|
|
@@ -470,18 +531,34 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
|
|
|
470
531
|
break;
|
|
471
532
|
}
|
|
472
533
|
|
|
534
|
+
case 'thread/tokenUsage/updated': {
|
|
535
|
+
// Codex's only token-usage signal. ThreadTokenUsage = { total, last, modelContextWindow };
|
|
536
|
+
// `last` is the current prompt occupancy (mirrors Claude's input+cacheRead+cacheCreation),
|
|
537
|
+
// the right basis for the recycle compare in supervisor/index.ts (fraction*window, not lifetime).
|
|
538
|
+
const tu = p.tokenUsage || {};
|
|
539
|
+
const last = tu.last || {};
|
|
540
|
+
conv.lastContextTokens = (last.inputTokens || 0) + (last.cachedInputTokens || 0);
|
|
541
|
+
if (typeof tu.modelContextWindow === 'number' && tu.modelContextWindow > 0) {
|
|
542
|
+
conv.lastContextWindow = tu.modelContextWindow;
|
|
543
|
+
}
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
546
|
+
|
|
473
547
|
case 'turn/completed': {
|
|
474
548
|
const status: string = p.turn?.status || 'completed';
|
|
475
549
|
const turnError = p.turn?.error;
|
|
476
550
|
|
|
551
|
+
clearTurnWatchdog(conv);
|
|
477
552
|
conv.currentTurnId = null;
|
|
478
553
|
conv.busy = false;
|
|
479
554
|
|
|
480
|
-
if (status === 'failed'
|
|
555
|
+
if (status === 'failed') {
|
|
481
556
|
conv.onMessage('bot:error', {
|
|
482
557
|
conversationId: conv.id,
|
|
483
558
|
error: turnError?.message || 'Codex turn failed.',
|
|
484
559
|
});
|
|
560
|
+
} else if (status === 'interrupted') {
|
|
561
|
+
// Interrupted turns carry no final answer — stay silent.
|
|
485
562
|
} else if (conv.fullText) {
|
|
486
563
|
conv.onMessage('bot:response', { conversationId: conv.id, content: conv.fullText });
|
|
487
564
|
}
|
|
@@ -490,16 +567,17 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
|
|
|
490
567
|
conv.onMessage('bot:done', { conversationId: conv.id, usedFileTools: conv.usedFileTools });
|
|
491
568
|
teardownConversation(conv.id);
|
|
492
569
|
} else {
|
|
493
|
-
// Context-size signal for the orchestrator's proactive session recycling
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
// own built-in auto-compaction).
|
|
497
|
-
const tu: any = p.turn?.usage || p.usage || {};
|
|
498
|
-
const contextTokens = tu.input_tokens ?? tu.inputTokens ?? tu.total_tokens ?? tu.totalTokens ?? tu.tokens ?? 0;
|
|
499
|
-
const contextWindow = tu.context_window ?? tu.contextWindow ?? 0;
|
|
500
|
-
// idle = no message queued behind this turn (the drain happens just below).
|
|
570
|
+
// Context-size signal for the orchestrator's proactive session recycling,
|
|
571
|
+
// sourced from the cached `thread/tokenUsage/updated` values above. 0 if codex
|
|
572
|
+
// never sent one this turn → falls back to codex's own in-thread auto-compaction.
|
|
501
573
|
const idle = conv.pendingInputs.length === 0;
|
|
502
|
-
conv.onMessage('bot:turn-complete', {
|
|
574
|
+
conv.onMessage('bot:turn-complete', {
|
|
575
|
+
conversationId: conv.id,
|
|
576
|
+
usedFileTools: conv.usedFileTools,
|
|
577
|
+
contextTokens: conv.lastContextTokens || 0,
|
|
578
|
+
contextWindow: conv.lastContextWindow || 0,
|
|
579
|
+
idle,
|
|
580
|
+
});
|
|
503
581
|
|
|
504
582
|
// Drain any messages that were submitted while we were busy.
|
|
505
583
|
const next = conv.pendingInputs.shift();
|
|
@@ -509,6 +587,12 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
|
|
|
509
587
|
}
|
|
510
588
|
|
|
511
589
|
case 'error': {
|
|
590
|
+
// ErrorNotification carries willRetry — codex will retry transient errors
|
|
591
|
+
// itself; don't surface those as a hard bot:error before the retry lands.
|
|
592
|
+
if (p.willRetry) {
|
|
593
|
+
log.info(`[codex] transient error (will retry): ${p.error?.message || 'unknown'}`);
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
512
596
|
const errMsg = p.error?.message || 'Codex error notification';
|
|
513
597
|
conv.onMessage('bot:error', { conversationId: conv.id, error: errMsg });
|
|
514
598
|
break;
|
|
@@ -522,6 +606,7 @@ function handleNotification(conv: CodexConversation, n: { method: string; params
|
|
|
522
606
|
function teardownConversation(conversationId: string): void {
|
|
523
607
|
const conv = conversations.get(conversationId);
|
|
524
608
|
if (!conv) return;
|
|
609
|
+
clearTurnWatchdog(conv);
|
|
525
610
|
conversations.delete(conversationId);
|
|
526
611
|
try { conv.rpc.close(); } catch {}
|
|
527
612
|
conv.onMessage('bot:conversation-ended', { conversationId });
|
|
@@ -531,7 +616,7 @@ async function spawnAndInitialize(
|
|
|
531
616
|
conversationId: string,
|
|
532
617
|
model: string,
|
|
533
618
|
onMessage: OnAgentMessage,
|
|
534
|
-
|
|
619
|
+
instructions: string,
|
|
535
620
|
oneShot: boolean,
|
|
536
621
|
): Promise<CodexConversation | null> {
|
|
537
622
|
// Pre-flight: confirm we have valid OAuth tokens before spending time spawning.
|
|
@@ -560,6 +645,9 @@ async function spawnAndInitialize(
|
|
|
560
645
|
pendingInputs: [],
|
|
561
646
|
busy: false,
|
|
562
647
|
oneShot,
|
|
648
|
+
lastContextTokens: 0,
|
|
649
|
+
lastContextWindow: 0,
|
|
650
|
+
turnWatchdog: null,
|
|
563
651
|
};
|
|
564
652
|
|
|
565
653
|
rpc.onNotification((n) => handleNotification(conv, n));
|
|
@@ -582,7 +670,13 @@ async function spawnAndInitialize(
|
|
|
582
670
|
const startResult = await rpc.request<{ thread: { id: string } }>('thread/start', {
|
|
583
671
|
cwd: WORKSPACE_DIR,
|
|
584
672
|
model: modelId,
|
|
585
|
-
|
|
673
|
+
// Bloby's persona/workflow prompt rides developerInstructions (ADDITIVE),
|
|
674
|
+
// NOT baseInstructions. baseInstructions fully OVERRIDES codex's native base
|
|
675
|
+
// prompt — which carries the apply_patch FREEFORM spec + shell protocol the
|
|
676
|
+
// model needs to edit files. Leaving baseInstructions unset keeps that native
|
|
677
|
+
// scaffolding; developerInstructions layers Bloby's persona on top of it.
|
|
678
|
+
developerInstructions: instructions,
|
|
679
|
+
personality: 'pragmatic',
|
|
586
680
|
// Bloby's posture matches Claude's bypassPermissions — the bot is
|
|
587
681
|
// running on the user's own machine with their full consent. Skip the
|
|
588
682
|
// approval prompts and give it write access to the workspace + beyond.
|
|
@@ -782,7 +876,7 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
|
|
|
782
876
|
case 'item/started': {
|
|
783
877
|
const item = p.item || {};
|
|
784
878
|
if (item.type === 'commandExecution') usedTools.add('shell');
|
|
785
|
-
else if (item.type === 'mcpToolCall') usedTools.add(item.
|
|
879
|
+
else if (item.type === 'mcpToolCall') usedTools.add(item.tool || 'mcp_tool');
|
|
786
880
|
else if (item.type === 'fileChange') { usedTools.add('file_change'); usedFileTools = true; }
|
|
787
881
|
else if (item.type === 'webSearch') usedTools.add('web_search');
|
|
788
882
|
break;
|
|
@@ -798,13 +892,14 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
|
|
|
798
892
|
}
|
|
799
893
|
case 'turn/completed': {
|
|
800
894
|
const status = p.turn?.status || 'completed';
|
|
801
|
-
if (status === 'failed'
|
|
895
|
+
if (status === 'failed') {
|
|
802
896
|
turnError = p.turn?.error?.message || 'Codex turn failed.';
|
|
803
897
|
}
|
|
804
898
|
resolveTurn?.();
|
|
805
899
|
break;
|
|
806
900
|
}
|
|
807
901
|
case 'error': {
|
|
902
|
+
if (p.willRetry) break; // transient — codex retries itself
|
|
808
903
|
turnError = p.error?.message || 'Codex error';
|
|
809
904
|
resolveTurn?.();
|
|
810
905
|
break;
|
|
@@ -833,7 +928,7 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
|
|
|
833
928
|
const r = await rpc.request<{ thread: { id: string } }>('thread/start', {
|
|
834
929
|
cwd: WORKSPACE_DIR,
|
|
835
930
|
model,
|
|
836
|
-
...(req.systemPrompt ? {
|
|
931
|
+
...(req.systemPrompt ? { developerInstructions: req.systemPrompt } : {}),
|
|
837
932
|
approvalPolicy: 'never',
|
|
838
933
|
sandbox: 'danger-full-access',
|
|
839
934
|
});
|
|
@@ -843,7 +938,7 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
|
|
|
843
938
|
const r = await rpc.request<{ thread: { id: string } }>('thread/start', {
|
|
844
939
|
cwd: WORKSPACE_DIR,
|
|
845
940
|
model,
|
|
846
|
-
...(req.systemPrompt ? {
|
|
941
|
+
...(req.systemPrompt ? { developerInstructions: req.systemPrompt } : {}),
|
|
847
942
|
approvalPolicy: 'never',
|
|
848
943
|
sandbox: 'danger-full-access',
|
|
849
944
|
});
|
|
@@ -107,7 +107,7 @@ async function buildSystemPrompt(
|
|
|
107
107
|
recentMessages?: RecentMessage[],
|
|
108
108
|
): Promise<string> {
|
|
109
109
|
const memoryFiles = readMemoryFiles();
|
|
110
|
-
const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
|
|
110
|
+
const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName, 'pi');
|
|
111
111
|
let systemPrompt = basePrompt;
|
|
112
112
|
systemPrompt += LIVE_CONVERSATION_HINT;
|
|
113
113
|
systemPrompt += `\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}`;
|
|
@@ -369,6 +369,12 @@ export async function startBlobyAgentQuery(
|
|
|
369
369
|
|
|
370
370
|
const abortController = new AbortController();
|
|
371
371
|
activeQueries.set(conversationId, abortController);
|
|
372
|
+
// Hard watchdog — a hung provider stream would otherwise pin this query forever (finally never
|
|
373
|
+
// runs, bot:done never fires). Abort after 5 min; cleared in the finally on normal completion.
|
|
374
|
+
const watchdog = setTimeout(() => {
|
|
375
|
+
log.warn(`[pi/bloby-agent] one-shot timed out (5m) — aborting conv=${conversationId}`);
|
|
376
|
+
abortController.abort();
|
|
377
|
+
}, 300_000);
|
|
372
378
|
|
|
373
379
|
let systemPrompt: string;
|
|
374
380
|
if (supportPrompt) {
|
|
@@ -425,6 +431,7 @@ export async function startBlobyAgentQuery(
|
|
|
425
431
|
onMessage('bot:error', { conversationId, error: err?.message || String(err) });
|
|
426
432
|
}
|
|
427
433
|
} finally {
|
|
434
|
+
clearTimeout(watchdog);
|
|
428
435
|
activeQueries.delete(conversationId);
|
|
429
436
|
const FILE_TOOL_NAMES = ['Write', 'Edit', 'write', 'edit'];
|
|
430
437
|
const usedFileTools = FILE_TOOL_NAMES.some((t) => usedTools.has(t));
|