bloby-bot 0.53.10 → 0.54.11
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/src/components/Chat/EnvForm.tsx +2 -1
- package/supervisor/harnesses/claude.ts +12 -2
- package/supervisor/harnesses/codex.ts +289 -43
- package/supervisor/harnesses/pi/index.ts +8 -1
- package/supervisor/index.ts +126 -7
- 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/alexa/SKILL.md +5 -0
- package/workspace/skills/mac/SKILL.md +5 -0
- package/workspace/skills/plaud/SKILL.md +5 -0
- package/workspace/skills/whatsapp/SKILL.md +30 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bloby-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.11",
|
|
4
4
|
"releaseNotes": [
|
|
5
5
|
"1. New Morphy animation system: config-driven sprites loaded from /morphy/*.json",
|
|
6
6
|
"2. Swapped teleporting (splash) and headphones (bubble + chat) to the new format",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"@streamdown/code": "^1.1.1",
|
|
62
62
|
"@tailwindcss/vite": "^4.2.0",
|
|
63
63
|
"@vitejs/plugin-react": "^6.0.1",
|
|
64
|
-
"@whiskeysockets/baileys": "
|
|
64
|
+
"@whiskeysockets/baileys": "7.0.0-rc13",
|
|
65
65
|
"better-sqlite3": "^12.6.2",
|
|
66
66
|
"class-variance-authority": "^0.7.1",
|
|
67
67
|
"clsx": "^2.1.1",
|
package/shared/config.ts
CHANGED
|
@@ -11,6 +11,11 @@ export interface ChannelConfig {
|
|
|
11
11
|
skill?: string;
|
|
12
12
|
/** Opt-in: process messages in group chats (default false). Channel mode ignores this. */
|
|
13
13
|
allowGroups?: boolean;
|
|
14
|
+
/** Assistant mode only. When false (default) ONLY the account owner can trigger the agent
|
|
15
|
+
* with `@botname`. When true, ANYONE who tags the bot (in a DM or group) can drive it.
|
|
16
|
+
* DANGER: the triggerer gains control of an agent that can run Bash, edit files, etc.
|
|
17
|
+
* Only enable for fully trusted shared use (e.g. a partner). See the WhatsApp SKILL.md. */
|
|
18
|
+
allowOthersToTrigger?: boolean;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
export interface AlexaChannelConfig {
|
package/supervisor/backend.ts
CHANGED
|
@@ -14,6 +14,15 @@ let intentionallyStopped = false;
|
|
|
14
14
|
let gaveUp = false;
|
|
15
15
|
const MAX_RESTARTS = 3;
|
|
16
16
|
const STABLE_THRESHOLD = 30_000; // 30s — if backend ran this long, it wasn't a crash loop
|
|
17
|
+
// Rolling-window backstop: the 30s "stable" rule resets the consecutive counter, so a backend
|
|
18
|
+
// that crashes every ~35s would otherwise restart forever. Give up if it crashes too often within
|
|
19
|
+
// the window (kept generous so a legitimately long-running-then-crashing backend isn't penalized).
|
|
20
|
+
let crashTimes: number[] = [];
|
|
21
|
+
const CRASH_WINDOW_MS = 5 * 60_000; // 5 min
|
|
22
|
+
const CRASH_WINDOW_MAX = 6; // > this many crashes in the window → give up regardless of the 30s reset
|
|
23
|
+
// Called once when the backend gives up, so the supervisor can tell the live chat (which exists
|
|
24
|
+
// precisely so the user can be told to fix it). Set via setBackendGiveUpHandler; logging-only default.
|
|
25
|
+
let onGiveUp: (() => void) | null = null;
|
|
17
26
|
|
|
18
27
|
/** Extra env vars injected into every backend spawn (e.g. BLOBY_AGENT_SECRET) */
|
|
19
28
|
let extraEnv: Record<string, string> = {};
|
|
@@ -23,6 +32,12 @@ export function setBackendEnv(env: Record<string, string>): void {
|
|
|
23
32
|
extraEnv = { ...extraEnv, ...env };
|
|
24
33
|
}
|
|
25
34
|
|
|
35
|
+
/** Register a callback fired once when the backend gives up (crash-looped past the limits).
|
|
36
|
+
* The supervisor wires this to broadcast a chat event so the user is told to fix it. */
|
|
37
|
+
export function setBackendGiveUpHandler(fn: () => void): void {
|
|
38
|
+
onGiveUp = fn;
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
const LOG_FILE = path.join(WORKSPACE_DIR, '.backend.log');
|
|
27
42
|
|
|
28
43
|
export function getBackendPort(basePort: number): number {
|
|
@@ -101,18 +116,24 @@ export function spawnBackend(port: number): ChildProcess {
|
|
|
101
116
|
|
|
102
117
|
// Any unexpected exit (crash, SIGTERM, OOM, null code) — restart
|
|
103
118
|
log.warn(`Backend exited unexpectedly (code ${code})`);
|
|
104
|
-
//
|
|
105
|
-
|
|
119
|
+
// Track crashes in a rolling window (backstop for the 30s-reset crash-loop hole).
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
crashTimes = crashTimes.filter((t) => now - t < CRASH_WINDOW_MS);
|
|
122
|
+
crashTimes.push(now);
|
|
123
|
+
const windowExceeded = crashTimes.length > CRASH_WINDOW_MAX;
|
|
124
|
+
// If backend was alive for >30s, it's not a crash loop — reset the consecutive counter.
|
|
125
|
+
if (now - lastSpawnTime > STABLE_THRESHOLD) {
|
|
106
126
|
restarts = 0;
|
|
107
127
|
}
|
|
108
|
-
if (restarts < MAX_RESTARTS) {
|
|
128
|
+
if (!windowExceeded && restarts < MAX_RESTARTS) {
|
|
109
129
|
restarts++;
|
|
110
130
|
const delay = Math.min(1000 * restarts, 5000);
|
|
111
131
|
log.info(`Restarting backend (${restarts}/${MAX_RESTARTS}, delay ${delay}ms)...`);
|
|
112
132
|
setTimeout(() => spawnBackend(port), delay);
|
|
113
133
|
} else {
|
|
114
134
|
gaveUp = true;
|
|
115
|
-
log.error(
|
|
135
|
+
log.error(`Backend failed too many times${windowExceeded ? ` (${crashTimes.length} crashes in ${CRASH_WINDOW_MS / 60000}min)` : ''}. Use Bloby chat to debug.`);
|
|
136
|
+
try { onGiveUp?.(); } catch {}
|
|
116
137
|
}
|
|
117
138
|
});
|
|
118
139
|
|
|
@@ -212,4 +233,8 @@ export function isBackendStopping(): boolean {
|
|
|
212
233
|
|
|
213
234
|
export function resetBackendRestarts(): void {
|
|
214
235
|
restarts = 0;
|
|
236
|
+
// A deliberate restart (file edit, user fix, scheduler) is a fresh attempt — clear the rolling
|
|
237
|
+
// crash window too so a just-fixed backend gets a clean slate (deliberate stops never record a
|
|
238
|
+
// crash, so this only matters right after a give-up + fix).
|
|
239
|
+
crashTimes = [];
|
|
215
240
|
}
|
|
@@ -83,6 +83,11 @@ interface DebounceEntry {
|
|
|
83
83
|
/** Per-conversation accumulator for streaming bot text → WhatsApp. */
|
|
84
84
|
export interface WaStreamState {
|
|
85
85
|
chunkBuf: string;
|
|
86
|
+
/** True once the CURRENT turn has consumed its routing target (via `bot:response` or
|
|
87
|
+
* `bot:error`). Reset on `bot:turn-complete`. Guards the turn-complete safety-net drain so a
|
|
88
|
+
* normal turn — which already consumed on `bot:response` — never double-drains and eats the
|
|
89
|
+
* NEXT queued message's target (the root cause of chat↔WhatsApp bleed and DM-answered-in-group). */
|
|
90
|
+
consumedThisTurn?: boolean;
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
/** Agent-turn events that carry per-turn content. Broadcast only for dashboard surfaces
|
|
@@ -489,6 +494,7 @@ export class ChannelManager {
|
|
|
489
494
|
|
|
490
495
|
if (type === 'bot:response' && eventData?.content && convId) {
|
|
491
496
|
const target = this.consumeRoute(convId);
|
|
497
|
+
state.consumedThisTurn = true;
|
|
492
498
|
const remaining = state.chunkBuf.trim();
|
|
493
499
|
state.chunkBuf = '';
|
|
494
500
|
|
|
@@ -514,20 +520,46 @@ export class ChannelManager {
|
|
|
514
520
|
return;
|
|
515
521
|
}
|
|
516
522
|
|
|
517
|
-
// Turn
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
if (
|
|
521
|
-
|
|
522
|
-
if (head) {
|
|
523
|
+
// Turn errored without a usable reply — drain THIS turn's route so it can't bleed into the
|
|
524
|
+
// next turn. Guarded by `consumedThisTurn`: if `bot:response` already fired this turn it
|
|
525
|
+
// consumed the target, so we must NOT drain again.
|
|
526
|
+
if (type === 'bot:error' && convId) {
|
|
527
|
+
if (!state.consumedThisTurn) {
|
|
523
528
|
const dropped = this.consumeRoute(convId);
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
529
|
+
if (dropped) {
|
|
530
|
+
log.warn(`[channels] bot:error without bot:response — dropping pending route (surface=${dropped.surface}, to=${dropped.waSendTo || 'none'})`);
|
|
531
|
+
if (dropped.surface === 'alexa') {
|
|
532
|
+
const alexa = this.providers.get('alexa') as AlexaChannel | undefined;
|
|
533
|
+
alexa?.rejectHead(convId, type);
|
|
534
|
+
}
|
|
528
535
|
}
|
|
536
|
+
state.consumedThisTurn = true;
|
|
529
537
|
}
|
|
530
538
|
state.chunkBuf = '';
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Turn finished. Drain the head ONLY if this turn never consumed its route — i.e. a true
|
|
543
|
+
// empty turn (no `bot:response`, no `bot:error`; see harness claude.ts which always emits
|
|
544
|
+
// `bot:turn-complete` after every result). A normal turn already consumed on `bot:response`,
|
|
545
|
+
// so draining here would eat the NEXT queued message's target and every later reply would
|
|
546
|
+
// land on the wrong surface (chat↔WhatsApp bleed, DM answered in a group). Reset the per-turn
|
|
547
|
+
// flag afterwards so the next turn starts clean. Turns are strictly sequential per conversation
|
|
548
|
+
// (single input queue, one `result` at a time), so this per-conversation flag is race-free.
|
|
549
|
+
if (type === 'bot:turn-complete' && convId) {
|
|
550
|
+
if (!state.consumedThisTurn) {
|
|
551
|
+
const head = this.peekRoute(convId);
|
|
552
|
+
if (head) {
|
|
553
|
+
const dropped = this.consumeRoute(convId);
|
|
554
|
+
log.warn(`[channels] turn-complete without bot:response — dropping pending route (surface=${dropped?.surface}, to=${dropped?.waSendTo || 'none'})`);
|
|
555
|
+
if (dropped?.surface === 'alexa') {
|
|
556
|
+
const alexa = this.providers.get('alexa') as AlexaChannel | undefined;
|
|
557
|
+
alexa?.rejectHead(convId, type);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
state.consumedThisTurn = false;
|
|
562
|
+
state.chunkBuf = '';
|
|
531
563
|
}
|
|
532
564
|
}
|
|
533
565
|
|
|
@@ -556,6 +588,22 @@ export class ChannelManager {
|
|
|
556
588
|
return config.channels?.[channel];
|
|
557
589
|
}
|
|
558
590
|
|
|
591
|
+
/** Robust "is this the account owner's own self-chat?" check.
|
|
592
|
+
*
|
|
593
|
+
* Keys purely on JID equality (`isSelfChat` — the chat resolves to the owner's OWN number,
|
|
594
|
+
* computed in whatsapp.ts from `ownPhoneJid` + LID translation). This is authoritative and is
|
|
595
|
+
* UNAFFECTED by Baileys' `fromMe` decode regressions (e.g. the rc11→rc13 protocolMessage
|
|
596
|
+
* `fromMe=false` bug): the chat JID of a self-message is still the owner's own number even when
|
|
597
|
+
* `fromMe` decodes wrong. So we deliberately do NOT also require `fromMe` (which the old gate
|
|
598
|
+
* did — that's what silently dropped self-messages under rc11).
|
|
599
|
+
*
|
|
600
|
+
* We also deliberately do NOT treat `fromMe` alone as self-chat: `fromMe` is true for the owner
|
|
601
|
+
* messaging a CONTACT from a linked device too, so a `fromMe`-based OR would misroute those into
|
|
602
|
+
* the admin brain. Only own-number JID equality is safe and false-positive-free. */
|
|
603
|
+
private isOwnerSelfChat(isSelfChat: boolean, isGroup: boolean): boolean {
|
|
604
|
+
return !isGroup && isSelfChat;
|
|
605
|
+
}
|
|
606
|
+
|
|
559
607
|
/** Handle an incoming message from any channel — debounces rapid messages from the same sender.
|
|
560
608
|
*
|
|
561
609
|
* Per-mode behavior is decided here. To add a new mode: extend the gating block below
|
|
@@ -586,26 +634,36 @@ export class ChannelManager {
|
|
|
586
634
|
if (!channelConfig.allowGroups) return;
|
|
587
635
|
}
|
|
588
636
|
|
|
637
|
+
// Owner self-chat — JID-based, immune to Baileys `fromMe` decode regressions.
|
|
638
|
+
const selfChat = this.isOwnerSelfChat(isSelfChat, isGroup);
|
|
639
|
+
|
|
589
640
|
// ── Channel mode: ONLY respond to self-chat ──
|
|
590
641
|
if (mode === 'channel') {
|
|
591
|
-
if (!
|
|
642
|
+
if (!selfChat) return;
|
|
592
643
|
}
|
|
593
644
|
|
|
594
|
-
// ── Business mode: filter outgoing (
|
|
595
|
-
if (mode === 'business' && fromMe && !
|
|
645
|
+
// ── Business mode: filter outgoing to others (your messages to customers, not self-chat) ──
|
|
646
|
+
if (mode === 'business' && fromMe && !selfChat) return;
|
|
596
647
|
|
|
597
648
|
// ── Assistant mode ──
|
|
598
649
|
// Self-chat: falls through to debounce (processed as admin)
|
|
599
|
-
// Others' messages or
|
|
600
|
-
//
|
|
601
|
-
if (mode === 'assistant' && !
|
|
650
|
+
// Others' messages or untriggered messages: store for context, don't invoke
|
|
651
|
+
// Triggered messages: fall through to debounce → agent (owner always; others only if opted-in)
|
|
652
|
+
if (mode === 'assistant' && !selfChat) {
|
|
602
653
|
// Store every message for context (both mine and theirs) — keyed by the chat (group or 1:1)
|
|
603
654
|
this.storeAssistantContext(channel, chatJid, senderName, text, fromMe);
|
|
604
655
|
|
|
605
|
-
//
|
|
656
|
+
// Trigger must be present.
|
|
606
657
|
const botName = loadConfig().username || 'bloby';
|
|
607
658
|
const triggerPattern = new RegExp(`(?:^|\\n)\\s*@${botName}[:\\s]`, 'i');
|
|
608
|
-
if (!
|
|
659
|
+
if (!triggerPattern.test(text)) return;
|
|
660
|
+
|
|
661
|
+
// Who may drive the agent? By default ONLY the account owner (fromMe). When the channel is
|
|
662
|
+
// explicitly configured with `allowOthersToTrigger`, anyone who tags the bot can — a
|
|
663
|
+
// deliberately dangerous shared-control mode (the triggerer gets an agent with Bash, file
|
|
664
|
+
// access, etc.; see the WhatsApp SKILL.md disclaimer).
|
|
665
|
+
const allowOthers = channelConfig.allowOthersToTrigger === true;
|
|
666
|
+
if (!fromMe && !allowOthers) return;
|
|
609
667
|
// Falls through to debounce → flushDebounce → handleAssistantMessage
|
|
610
668
|
}
|
|
611
669
|
|
|
@@ -660,8 +718,12 @@ export class ChannelManager {
|
|
|
660
718
|
// Reply identifier — strip JID suffix to get a stable id (phone for 1:1, group hash for groups)
|
|
661
719
|
const chatId = chatJid.replace(/@.*/, '');
|
|
662
720
|
|
|
721
|
+
// Owner self-chat — JID-based, immune to Baileys `fromMe` decode regressions (matches the
|
|
722
|
+
// gate in handleInboundMessage so a self-message can't pass one check and fail the other).
|
|
723
|
+
const selfChat = this.isOwnerSelfChat(isSelfChat, isGroup);
|
|
724
|
+
|
|
663
725
|
// Route based on mode and role
|
|
664
|
-
if (mode === 'channel' || (mode === 'business' &&
|
|
726
|
+
if (mode === 'channel' || (mode === 'business' && selfChat) || (mode === 'assistant' && selfChat)) {
|
|
665
727
|
// Admin (self-chat in any mode)
|
|
666
728
|
const message: InboundMessage = {
|
|
667
729
|
channel,
|
|
@@ -15,6 +15,11 @@ export interface ChannelConfig {
|
|
|
15
15
|
skill?: string;
|
|
16
16
|
/** Opt-in: process messages in group chats (default false). Channel mode ignores this. */
|
|
17
17
|
allowGroups?: boolean;
|
|
18
|
+
/** Assistant mode only. When false (default) ONLY the account owner (fromMe) can trigger
|
|
19
|
+
* the agent with `@botname`. When true, ANYONE who tags the bot (DM or group) can drive it.
|
|
20
|
+
* DANGER: the triggerer gains full control of an agent that can run Bash, edit files, etc.
|
|
21
|
+
* Enable only for fully trusted shared use. See the WhatsApp SKILL.md disclaimer. */
|
|
22
|
+
allowOthersToTrigger?: boolean;
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
export interface InboundMessageAttachment {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { Check, KeyRound, Loader2, AlertCircle } from 'lucide-react';
|
|
3
|
+
import { authFetch } from '../../lib/auth';
|
|
3
4
|
|
|
4
5
|
export interface EnvField {
|
|
5
6
|
name: string;
|
|
@@ -34,7 +35,7 @@ export default function EnvForm({ group }: Props) {
|
|
|
34
35
|
setErrorMsg('');
|
|
35
36
|
|
|
36
37
|
try {
|
|
37
|
-
const res = await
|
|
38
|
+
const res = await authFetch('/api/env', {
|
|
38
39
|
method: 'POST',
|
|
39
40
|
headers: { 'Content-Type': 'application/json' },
|
|
40
41
|
body: JSON.stringify({ vars: Object.fromEntries(filled) }),
|
|
@@ -191,7 +191,7 @@ async function buildConversationOptions(
|
|
|
191
191
|
recentMessages?: RecentMessage[],
|
|
192
192
|
): Promise<Omit<Options, 'abortController' | 'stderr'>> {
|
|
193
193
|
const memoryFiles = readMemoryFiles();
|
|
194
|
-
const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
|
|
194
|
+
const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName, 'claude');
|
|
195
195
|
let systemPrompt = basePrompt;
|
|
196
196
|
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}`;
|
|
197
197
|
|
|
@@ -594,7 +594,7 @@ export async function startBlobyAgentQuery(
|
|
|
594
594
|
if (supportPrompt) {
|
|
595
595
|
enrichedPrompt = supportPrompt;
|
|
596
596
|
} else {
|
|
597
|
-
const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
|
|
597
|
+
const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName, 'claude');
|
|
598
598
|
enrichedPrompt = basePrompt;
|
|
599
599
|
enrichedPrompt += `\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}`;
|
|
600
600
|
|
|
@@ -614,6 +614,15 @@ export async function startBlobyAgentQuery(
|
|
|
614
614
|
|
|
615
615
|
activeQueries.set(conversationId, { abortController });
|
|
616
616
|
|
|
617
|
+
// Hard watchdog: a hung CLI subprocess (network stall, stuck MCP) would otherwise leave the
|
|
618
|
+
// `for await` loop pending forever — the finally never runs, bot:done never fires, and the
|
|
619
|
+
// caller's per-conversation slot (WhatsApp activeAgents / scheduler) is pinned for good. Abort
|
|
620
|
+
// after 5 min so the finally always emits bot:done. Cleared on normal completion.
|
|
621
|
+
const watchdog = setTimeout(() => {
|
|
622
|
+
log.warn(`[bloby-agent] One-shot query timed out (5m) — aborting conv=${conversationId}`);
|
|
623
|
+
abortController.abort();
|
|
624
|
+
}, 300_000);
|
|
625
|
+
|
|
617
626
|
let fullText = '';
|
|
618
627
|
const usedTools = new Set<string>();
|
|
619
628
|
let stderrBuf = '';
|
|
@@ -705,6 +714,7 @@ export async function startBlobyAgentQuery(
|
|
|
705
714
|
onMessage('bot:error', { conversationId, error: errMsg });
|
|
706
715
|
}
|
|
707
716
|
} finally {
|
|
717
|
+
clearTimeout(watchdog);
|
|
708
718
|
activeQueries.delete(conversationId);
|
|
709
719
|
const FILE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'];
|
|
710
720
|
const usedFileTools = FILE_TOOLS.some((t) => usedTools.has(t));
|