bloby-bot 0.20.8 → 0.21.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/package.json +1 -1
- package/supervisor/bloby-agent.ts +430 -149
- package/supervisor/channels/manager.ts +22 -22
- package/supervisor/index.ts +103 -67
- package/worker/prompts/bloby-system-prompt.txt +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Claude Agent SDK wrapper — v2 Long-lived Query Model
|
|
3
|
+
*
|
|
4
|
+
* Two modes:
|
|
5
|
+
* 1. Live Conversation (main chat + admin WhatsApp):
|
|
6
|
+
* Single long-lived query() per conversation. User messages pushed into async queue.
|
|
7
|
+
* Agent stays alive, processes messages as they arrive, reports sub-agent completions.
|
|
8
|
+
*
|
|
9
|
+
* 2. One-shot Query (customer WhatsApp, scheduler):
|
|
10
|
+
* Classic request-response: one query() per message. Backward compat.
|
|
4
11
|
*/
|
|
5
12
|
|
|
6
13
|
import { query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
@@ -13,18 +20,13 @@ import { getClaudeAccessToken } from '../worker/claude-auth.js';
|
|
|
13
20
|
import { assembleSystemPrompt } from '../worker/prompts/prompt-assembler.js';
|
|
14
21
|
import { buildAgents } from './agents/index.js';
|
|
15
22
|
|
|
23
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
16
25
|
export interface RecentMessage {
|
|
17
26
|
role: 'user' | 'assistant';
|
|
18
27
|
content: string;
|
|
19
28
|
}
|
|
20
29
|
|
|
21
|
-
interface ActiveQuery {
|
|
22
|
-
abortController: AbortController;
|
|
23
|
-
queryHandle?: any; // SDK query handle for stopTask()
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const activeQueries = new Map<string, ActiveQuery>();
|
|
27
|
-
|
|
28
30
|
export interface AgentAttachment {
|
|
29
31
|
type: 'image' | 'file';
|
|
30
32
|
name: string;
|
|
@@ -32,6 +34,68 @@ export interface AgentAttachment {
|
|
|
32
34
|
data: string; // base64
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
// ── Async Queue ────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
interface AsyncQueue<T> extends AsyncIterable<T> {
|
|
40
|
+
push(item: T): void;
|
|
41
|
+
end(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Create an async queue that can be used as an AsyncIterable prompt for the SDK */
|
|
45
|
+
function createAsyncQueue<T>(): AsyncQueue<T> {
|
|
46
|
+
const pending: T[] = [];
|
|
47
|
+
let resolve: ((value: IteratorResult<T>) => void) | null = null;
|
|
48
|
+
let done = false;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
push(item: T) {
|
|
52
|
+
if (done) return;
|
|
53
|
+
if (resolve) {
|
|
54
|
+
resolve({ value: item, done: false });
|
|
55
|
+
resolve = null;
|
|
56
|
+
} else {
|
|
57
|
+
pending.push(item);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
end() {
|
|
61
|
+
done = true;
|
|
62
|
+
if (resolve) resolve({ value: undefined as any, done: true });
|
|
63
|
+
},
|
|
64
|
+
[Symbol.asyncIterator]() {
|
|
65
|
+
return {
|
|
66
|
+
next(): Promise<IteratorResult<T>> {
|
|
67
|
+
if (pending.length > 0) {
|
|
68
|
+
return Promise.resolve({ value: pending.shift()!, done: false });
|
|
69
|
+
}
|
|
70
|
+
if (done) return Promise.resolve({ value: undefined as any, done: true });
|
|
71
|
+
return new Promise((r) => { resolve = r; });
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Live Conversation Manager ──────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
interface LiveConversation {
|
|
81
|
+
id: string;
|
|
82
|
+
inputQueue: AsyncQueue<SDKUserMessage>;
|
|
83
|
+
abortController: AbortController;
|
|
84
|
+
queryHandle: any;
|
|
85
|
+
onMessage: (type: string, data: any) => void;
|
|
86
|
+
/** True while the model is actively processing (between message push and result) */
|
|
87
|
+
busy: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const liveConversations = new Map<string, LiveConversation>();
|
|
91
|
+
|
|
92
|
+
/** Check if a live conversation exists */
|
|
93
|
+
export function hasConversation(conversationId: string): boolean {
|
|
94
|
+
return liveConversations.has(conversationId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
35
99
|
/** Read a memory file from workspace, returning '(empty)' if missing or empty */
|
|
36
100
|
function readMemoryFile(filename: string): string {
|
|
37
101
|
try {
|
|
@@ -42,8 +106,8 @@ function readMemoryFile(filename: string): string {
|
|
|
42
106
|
}
|
|
43
107
|
}
|
|
44
108
|
|
|
45
|
-
/** Read all memory + config files
|
|
46
|
-
function readMemoryFiles()
|
|
109
|
+
/** Read all memory + config files */
|
|
110
|
+
function readMemoryFiles() {
|
|
47
111
|
return {
|
|
48
112
|
myself: readMemoryFile('MYSELF.md'),
|
|
49
113
|
myhuman: readMemoryFile('MYHUMAN.md'),
|
|
@@ -59,11 +123,25 @@ function formatConversationHistory(messages: RecentMessage[]): string {
|
|
|
59
123
|
return messages.map((m) => `${m.role}: ${m.content}`).join('\n\n');
|
|
60
124
|
}
|
|
61
125
|
|
|
62
|
-
/**
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
const
|
|
126
|
+
/** Load MCP server config from workspace/MCP.json */
|
|
127
|
+
function loadMcpServers(): Record<string, any> | undefined {
|
|
128
|
+
try {
|
|
129
|
+
const mcpConfigPath = path.join(WORKSPACE_DIR, 'MCP.json');
|
|
130
|
+
const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8'));
|
|
131
|
+
if (mcpConfig && typeof mcpConfig === 'object' && !Array.isArray(mcpConfig) && Object.keys(mcpConfig).length) {
|
|
132
|
+
return mcpConfig;
|
|
133
|
+
} else if (Array.isArray(mcpConfig) && mcpConfig.length) {
|
|
134
|
+
return Object.assign({}, ...mcpConfig);
|
|
135
|
+
}
|
|
136
|
+
} catch {}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Build an SDKUserMessage from text + optional attachments */
|
|
141
|
+
function buildUserMessage(text: string, attachments?: AgentAttachment[], savedFiles?: SavedFile[]): SDKUserMessage {
|
|
142
|
+
const content: any[] = [];
|
|
66
143
|
|
|
144
|
+
if (attachments?.length) {
|
|
67
145
|
for (const att of attachments) {
|
|
68
146
|
if (att.type === 'image') {
|
|
69
147
|
content.push({
|
|
@@ -77,28 +155,338 @@ function buildMultiPartPrompt(text: string, attachments: AgentAttachment[], save
|
|
|
77
155
|
});
|
|
78
156
|
}
|
|
79
157
|
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let promptText = text || '(attached files)';
|
|
161
|
+
if (savedFiles?.length) {
|
|
162
|
+
const lines = savedFiles.map((f) => `- ${f.name} -> ${f.relPath}`);
|
|
163
|
+
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).`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
content.push({ type: 'text', text: promptText });
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
type: 'user' as const,
|
|
170
|
+
message: { role: 'user' as const, content },
|
|
171
|
+
parent_tool_use_id: null,
|
|
172
|
+
} as SDKUserMessage;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Live Conversation API ──────────────────────────────────────────────────
|
|
80
176
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Start a long-lived conversation.
|
|
179
|
+
* Creates a single query() with an async input queue.
|
|
180
|
+
* Messages are pushed via pushMessage(). The query stays alive until endConversation().
|
|
181
|
+
*/
|
|
182
|
+
export async function startConversation(
|
|
183
|
+
conversationId: string,
|
|
184
|
+
model: string,
|
|
185
|
+
onMessage: (type: string, data: any) => void,
|
|
186
|
+
names?: { botName: string; humanName: string },
|
|
187
|
+
recentMessages?: RecentMessage[],
|
|
188
|
+
): Promise<boolean> {
|
|
189
|
+
log.info(`[conversation] ──── STARTING CONVERSATION ────`);
|
|
190
|
+
log.info(`[conversation] Conv ID: ${conversationId}`);
|
|
191
|
+
log.info(`[conversation] Model: ${model}`);
|
|
192
|
+
|
|
193
|
+
// End any existing conversation with this ID
|
|
194
|
+
if (liveConversations.has(conversationId)) {
|
|
195
|
+
log.info(`[conversation] Ending existing conversation ${conversationId} before starting new one`);
|
|
196
|
+
endConversation(conversationId);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const oauthToken = await getClaudeAccessToken();
|
|
200
|
+
if (!oauthToken) {
|
|
201
|
+
log.warn('[conversation] No OAuth token — cannot start');
|
|
202
|
+
onMessage('bot:error', { conversationId, error: 'Claude OAuth token not found. Please authenticate via the dashboard.' });
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Assemble system prompt (once for the conversation lifetime)
|
|
207
|
+
const memoryFiles = readMemoryFiles();
|
|
208
|
+
const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
|
|
209
|
+
let systemPrompt = basePrompt;
|
|
210
|
+
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}`;
|
|
211
|
+
|
|
212
|
+
// Inject channel config
|
|
213
|
+
try {
|
|
214
|
+
const { loadConfig: loadCfg } = await import('../shared/config.js');
|
|
215
|
+
const cfg = loadCfg();
|
|
216
|
+
const channels = (cfg as any).channels;
|
|
217
|
+
if (channels) {
|
|
218
|
+
systemPrompt += `\n\n---\n# Channel Config\n\`\`\`json\n${JSON.stringify(channels, null, 2)}\n\`\`\``;
|
|
86
219
|
}
|
|
220
|
+
} catch {}
|
|
221
|
+
|
|
222
|
+
// Inject recent conversation history for context continuity
|
|
223
|
+
if (recentMessages?.length) {
|
|
224
|
+
systemPrompt += `\n\n---\n# Recent Conversation\n${formatConversationHistory(recentMessages)}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Build sub-agent definitions
|
|
228
|
+
const agents = buildAgents();
|
|
229
|
+
log.info(`[conversation] Loaded ${Object.keys(agents).length} sub-agent(s): ${Object.keys(agents).join(', ')}`);
|
|
230
|
+
|
|
231
|
+
// Load MCP servers
|
|
232
|
+
const mcpServers = loadMcpServers();
|
|
233
|
+
if (mcpServers) {
|
|
234
|
+
log.info(`[conversation] MCP servers: ${Object.keys(mcpServers).join(', ')}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Create the async input queue
|
|
238
|
+
const inputQueue = createAsyncQueue<SDKUserMessage>();
|
|
239
|
+
const abortController = new AbortController();
|
|
240
|
+
|
|
241
|
+
// Store the conversation
|
|
242
|
+
const conv: LiveConversation = {
|
|
243
|
+
id: conversationId,
|
|
244
|
+
inputQueue,
|
|
245
|
+
abortController,
|
|
246
|
+
queryHandle: null,
|
|
247
|
+
onMessage,
|
|
248
|
+
busy: false,
|
|
249
|
+
};
|
|
250
|
+
liveConversations.set(conversationId, conv);
|
|
251
|
+
|
|
252
|
+
log.info(`[conversation] System prompt: ${systemPrompt.length} chars`);
|
|
253
|
+
log.info(`[conversation] Starting long-lived query...`);
|
|
254
|
+
|
|
255
|
+
// Run the for-await loop in the background (fire and forget)
|
|
256
|
+
(async () => {
|
|
257
|
+
let fullText = '';
|
|
258
|
+
const usedTools = new Set<string>();
|
|
259
|
+
let stderrBuf = '';
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const claudeQuery = query({
|
|
263
|
+
prompt: inputQueue,
|
|
264
|
+
options: {
|
|
265
|
+
model,
|
|
266
|
+
cwd: WORKSPACE_DIR,
|
|
267
|
+
permissionMode: 'bypassPermissions',
|
|
268
|
+
allowDangerouslySkipPermissions: true,
|
|
269
|
+
abortController,
|
|
270
|
+
systemPrompt,
|
|
271
|
+
mcpServers,
|
|
272
|
+
agents,
|
|
273
|
+
agentProgressSummaries: true,
|
|
274
|
+
stderr: (chunk: string) => { stderrBuf += chunk; },
|
|
275
|
+
env: {
|
|
276
|
+
...process.env as Record<string, string>,
|
|
277
|
+
CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
|
|
278
|
+
CLAUDE_CODE_BUBBLEWRAP: '1',
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
conv.queryHandle = claudeQuery;
|
|
284
|
+
log.info(`[conversation] ──── QUERY LOOP STARTED ────`);
|
|
285
|
+
|
|
286
|
+
for await (const msg of claudeQuery) {
|
|
287
|
+
if (abortController.signal.aborted) {
|
|
288
|
+
log.info(`[conversation] Query aborted — exiting loop`);
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
switch (msg.type) {
|
|
293
|
+
case 'assistant': {
|
|
294
|
+
const assistantMsg = msg.message;
|
|
295
|
+
if (!assistantMsg?.content) break;
|
|
296
|
+
|
|
297
|
+
for (const block of assistantMsg.content) {
|
|
298
|
+
if (block.type === 'text' && block.text) {
|
|
299
|
+
if (fullText && !fullText.endsWith('\n')) {
|
|
300
|
+
fullText += '\n\n';
|
|
301
|
+
onMessage('bot:token', { conversationId, token: '\n\n' });
|
|
302
|
+
}
|
|
303
|
+
fullText += block.text;
|
|
304
|
+
onMessage('bot:token', { conversationId, token: block.text });
|
|
305
|
+
} else if (block.type === 'tool_use') {
|
|
306
|
+
usedTools.add(block.name);
|
|
307
|
+
onMessage('bot:tool', { conversationId, name: block.name, input: block.input });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
87
312
|
|
|
88
|
-
|
|
313
|
+
case 'result': {
|
|
314
|
+
// Agent finished processing the current message
|
|
315
|
+
log.info(`[conversation] ──── TURN COMPLETE ────`);
|
|
316
|
+
log.info(`[conversation] Response length: ${fullText.length} chars`);
|
|
317
|
+
log.info(`[conversation] Tools used this turn: ${Array.from(usedTools).join(', ') || 'none'}`);
|
|
318
|
+
|
|
319
|
+
if (fullText) {
|
|
320
|
+
onMessage('bot:response', { conversationId, content: fullText });
|
|
321
|
+
fullText = '';
|
|
322
|
+
} else if (msg.subtype?.startsWith('error')) {
|
|
323
|
+
const errorText = (msg as any).errors?.join('; ') || 'Agent turn failed';
|
|
324
|
+
log.warn(`[conversation] Turn error: ${errorText}`);
|
|
325
|
+
onMessage('bot:error', { conversationId, error: errorText });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Signal turn complete — backend restart + UI update
|
|
329
|
+
const FILE_TOOLS = ['Write', 'Edit'];
|
|
330
|
+
const usedFileTools = FILE_TOOLS.some((t) => usedTools.has(t));
|
|
331
|
+
onMessage('bot:turn-complete', { conversationId, usedFileTools });
|
|
332
|
+
|
|
333
|
+
// Reset per-turn state
|
|
334
|
+
usedTools.clear();
|
|
335
|
+
conv.busy = false;
|
|
336
|
+
log.info(`[conversation] Agent idle — waiting for next message`);
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
case 'tool_progress':
|
|
341
|
+
onMessage('bot:tool', {
|
|
342
|
+
conversationId,
|
|
343
|
+
name: (msg as any).tool_name || 'working',
|
|
344
|
+
status: 'running',
|
|
345
|
+
});
|
|
346
|
+
break;
|
|
347
|
+
|
|
348
|
+
// ── Background sub-agent events ──
|
|
349
|
+
case 'system': {
|
|
350
|
+
const sysMsg = msg as any;
|
|
351
|
+
if (sysMsg.subtype === 'task_started') {
|
|
352
|
+
log.info(`[conversation] ──── SUB-AGENT STARTED ────`);
|
|
353
|
+
log.info(`[conversation] Task ID: ${sysMsg.task_id}`);
|
|
354
|
+
log.info(`[conversation] Description: ${sysMsg.description}`);
|
|
355
|
+
onMessage('bot:task-created', {
|
|
356
|
+
conversationId,
|
|
357
|
+
taskId: sysMsg.task_id,
|
|
358
|
+
description: sysMsg.description,
|
|
359
|
+
type: sysMsg.task_type,
|
|
360
|
+
});
|
|
361
|
+
} else if (sysMsg.subtype === 'task_progress') {
|
|
362
|
+
const summary = sysMsg.summary || sysMsg.last_tool_name || 'working';
|
|
363
|
+
log.info(`[conversation] Sub-agent ${sysMsg.task_id} | ${summary} | Tools: ${sysMsg.usage?.tool_uses || 0} | ${Math.round((sysMsg.usage?.duration_ms || 0) / 1000)}s`);
|
|
364
|
+
onMessage('bot:task-progress', {
|
|
365
|
+
conversationId,
|
|
366
|
+
taskId: sysMsg.task_id,
|
|
367
|
+
summary,
|
|
368
|
+
lastTool: sysMsg.last_tool_name,
|
|
369
|
+
usage: sysMsg.usage,
|
|
370
|
+
});
|
|
371
|
+
} else if (sysMsg.subtype === 'task_notification') {
|
|
372
|
+
log.info(`[conversation] ──── SUB-AGENT ${sysMsg.status?.toUpperCase()} ────`);
|
|
373
|
+
log.info(`[conversation] Task ID: ${sysMsg.task_id}`);
|
|
374
|
+
log.info(`[conversation] Status: ${sysMsg.status}`);
|
|
375
|
+
log.info(`[conversation] Summary: ${sysMsg.summary?.slice(0, 200)}`);
|
|
376
|
+
log.info(`[conversation] Tokens: ${sysMsg.usage?.total_tokens || 0} | Tools: ${sysMsg.usage?.tool_uses || 0} | Duration: ${Math.round((sysMsg.usage?.duration_ms || 0) / 1000)}s`);
|
|
377
|
+
onMessage('bot:task-done', {
|
|
378
|
+
conversationId,
|
|
379
|
+
taskId: sysMsg.task_id,
|
|
380
|
+
status: sysMsg.status,
|
|
381
|
+
summary: sysMsg.summary,
|
|
382
|
+
usage: sysMsg.usage,
|
|
383
|
+
});
|
|
384
|
+
// Sub-agent completion may have written files
|
|
385
|
+
if (sysMsg.status === 'completed') {
|
|
386
|
+
onMessage('bot:turn-complete', { conversationId, usedFileTools: true });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
log.info(`[conversation] ──── QUERY LOOP ENDED ────`);
|
|
89
395
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
396
|
+
// Send any remaining text
|
|
397
|
+
if (fullText && !abortController.signal.aborted) {
|
|
398
|
+
onMessage('bot:response', { conversationId, content: fullText });
|
|
399
|
+
}
|
|
400
|
+
} catch (err: any) {
|
|
401
|
+
if (!abortController.signal.aborted) {
|
|
402
|
+
const detail = stderrBuf.trim();
|
|
403
|
+
const errMsg = detail ? `${err.message}\n\nCLI stderr:\n${detail}` : err.message;
|
|
404
|
+
log.warn(`[conversation] Query error: ${errMsg}`);
|
|
405
|
+
onMessage('bot:error', { conversationId, error: errMsg });
|
|
406
|
+
}
|
|
407
|
+
} finally {
|
|
408
|
+
log.info(`[conversation] Cleaning up conversation ${conversationId}`);
|
|
409
|
+
liveConversations.delete(conversationId);
|
|
410
|
+
onMessage('bot:conversation-ended', { conversationId });
|
|
411
|
+
}
|
|
95
412
|
})();
|
|
413
|
+
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Push a user message into an existing live conversation.
|
|
419
|
+
* The agent will process it as part of the ongoing conversation.
|
|
420
|
+
*/
|
|
421
|
+
export function pushMessage(
|
|
422
|
+
conversationId: string,
|
|
423
|
+
content: string,
|
|
424
|
+
attachments?: AgentAttachment[],
|
|
425
|
+
savedFiles?: SavedFile[],
|
|
426
|
+
): boolean {
|
|
427
|
+
const conv = liveConversations.get(conversationId);
|
|
428
|
+
if (!conv) {
|
|
429
|
+
log.warn(`[conversation] pushMessage — no live conversation ${conversationId}`);
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
log.info(`[conversation] ──── PUSH MESSAGE ────`);
|
|
434
|
+
log.info(`[conversation] Conv: ${conversationId}`);
|
|
435
|
+
log.info(`[conversation] Content: "${content.slice(0, 100)}..."`);
|
|
436
|
+
log.info(`[conversation] Attachments: ${attachments?.length || 0}`);
|
|
437
|
+
log.info(`[conversation] Agent busy: ${conv.busy}`);
|
|
438
|
+
|
|
439
|
+
const userMessage = buildUserMessage(content, attachments, savedFiles);
|
|
440
|
+
conv.busy = true;
|
|
441
|
+
conv.inputQueue.push(userMessage);
|
|
442
|
+
|
|
443
|
+
// Emit typing indicator
|
|
444
|
+
conv.onMessage('bot:typing', { conversationId });
|
|
445
|
+
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** End a live conversation */
|
|
450
|
+
export function endConversation(conversationId: string): void {
|
|
451
|
+
const conv = liveConversations.get(conversationId);
|
|
452
|
+
if (!conv) return;
|
|
453
|
+
|
|
454
|
+
log.info(`[conversation] ──── ENDING CONVERSATION ────`);
|
|
455
|
+
log.info(`[conversation] Conv: ${conversationId}`);
|
|
456
|
+
|
|
457
|
+
conv.inputQueue.end();
|
|
458
|
+
conv.abortController.abort();
|
|
459
|
+
liveConversations.delete(conversationId);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Check if the agent is currently busy processing a message */
|
|
463
|
+
export function isConversationBusy(conversationId: string): boolean {
|
|
464
|
+
return liveConversations.get(conversationId)?.busy || false;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Stop a specific background sub-agent task */
|
|
468
|
+
export async function stopSubAgentTask(conversationId: string, taskId: string): Promise<void> {
|
|
469
|
+
const conv = liveConversations.get(conversationId);
|
|
470
|
+
if (conv?.queryHandle?.stopTask) {
|
|
471
|
+
log.info(`[conversation] Stopping sub-agent task: ${taskId}`);
|
|
472
|
+
await conv.queryHandle.stopTask(taskId);
|
|
473
|
+
} else {
|
|
474
|
+
log.warn(`[conversation] Cannot stop task ${taskId} — no live conversation ${conversationId}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ── One-shot Query API (backward compat) ────────────────────────────────────
|
|
479
|
+
// Used by: customer WhatsApp (handleCustomerMessage), scheduler (triggerAgent)
|
|
480
|
+
|
|
481
|
+
interface ActiveQuery {
|
|
482
|
+
abortController: AbortController;
|
|
96
483
|
}
|
|
97
484
|
|
|
485
|
+
const activeQueries = new Map<string, ActiveQuery>();
|
|
98
486
|
|
|
99
487
|
/**
|
|
100
|
-
* Run
|
|
101
|
-
*
|
|
488
|
+
* Run a one-shot Agent SDK query (classic request-response).
|
|
489
|
+
* Used for customer-facing messages and scheduler triggers.
|
|
102
490
|
*/
|
|
103
491
|
export async function startBlobyAgentQuery(
|
|
104
492
|
conversationId: string,
|
|
@@ -109,9 +497,7 @@ export async function startBlobyAgentQuery(
|
|
|
109
497
|
savedFiles?: SavedFile[],
|
|
110
498
|
names?: { botName: string; humanName: string },
|
|
111
499
|
recentMessages?: RecentMessage[],
|
|
112
|
-
/** Override system prompt (used for customer-facing channel messages via SUPPORT.md) */
|
|
113
500
|
supportPrompt?: string,
|
|
114
|
-
/** Max agentic turns. Default 50. Orchestrator uses 5. */
|
|
115
501
|
maxTurns?: number,
|
|
116
502
|
): Promise<void> {
|
|
117
503
|
const oauthToken = await getClaudeAccessToken();
|
|
@@ -123,19 +509,14 @@ export async function startBlobyAgentQuery(
|
|
|
123
509
|
const abortController = new AbortController();
|
|
124
510
|
const memoryFiles = readMemoryFiles();
|
|
125
511
|
|
|
126
|
-
// Build enriched system prompt
|
|
127
512
|
let enrichedPrompt: string;
|
|
128
513
|
if (supportPrompt) {
|
|
129
|
-
// Customer-facing: SCRIPT.md is the ENTIRE prompt. Nothing else.
|
|
130
|
-
// No memory files, no skill instructions, no config — just the script + conversation history.
|
|
131
514
|
enrichedPrompt = supportPrompt;
|
|
132
515
|
} else {
|
|
133
|
-
// Admin/chat: full main system prompt with all context (dynamic fragments applied)
|
|
134
516
|
const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
|
|
135
517
|
enrichedPrompt = basePrompt;
|
|
136
518
|
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}`;
|
|
137
519
|
|
|
138
|
-
// Inject channel config (admin only)
|
|
139
520
|
try {
|
|
140
521
|
const { loadConfig: loadCfg } = await import('../shared/config.js');
|
|
141
522
|
const cfg = loadCfg();
|
|
@@ -144,19 +525,18 @@ export async function startBlobyAgentQuery(
|
|
|
144
525
|
enrichedPrompt += `\n\n---\n# Channel Config\n\`\`\`json\n${JSON.stringify(channels, null, 2)}\n\`\`\``;
|
|
145
526
|
}
|
|
146
527
|
} catch {}
|
|
147
|
-
|
|
148
|
-
// Task board is now managed natively by the SDK via background agents
|
|
149
528
|
}
|
|
150
529
|
|
|
151
530
|
if (recentMessages?.length) {
|
|
152
531
|
enrichedPrompt += `\n\n---\n# Recent Conversation\n${formatConversationHistory(recentMessages)}`;
|
|
153
532
|
}
|
|
154
533
|
|
|
534
|
+
activeQueries.set(conversationId, { abortController });
|
|
535
|
+
|
|
155
536
|
let fullText = '';
|
|
156
537
|
const usedTools = new Set<string>();
|
|
157
538
|
let stderrBuf = '';
|
|
158
539
|
|
|
159
|
-
// If there are saved files but no inline attachments, append path info to plain text prompt
|
|
160
540
|
let plainPrompt = prompt;
|
|
161
541
|
if (savedFiles?.length && !attachments?.length) {
|
|
162
542
|
const lines = savedFiles.map((f) => `- ${f.name} -> ${f.relPath}`);
|
|
@@ -164,43 +544,15 @@ export async function startBlobyAgentQuery(
|
|
|
164
544
|
}
|
|
165
545
|
|
|
166
546
|
const sdkPrompt: string | AsyncIterable<SDKUserMessage> =
|
|
167
|
-
attachments?.length ?
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
// in each skill folder. No manual injection needed — the SDK handles lazy loading.
|
|
171
|
-
// Customer mode uses SCRIPT.md exclusively (passed via supportPrompt parameter).
|
|
547
|
+
attachments?.length ? (async function* () {
|
|
548
|
+
yield buildUserMessage(prompt, attachments, savedFiles);
|
|
549
|
+
})() : plainPrompt;
|
|
172
550
|
|
|
173
551
|
try {
|
|
174
|
-
|
|
175
|
-
// Load MCP server config from workspace/MCP.json if it exists
|
|
176
|
-
// Format: { "server-name": { command, args, env }, ... } (object, not array)
|
|
177
|
-
let mcpServers: Record<string, any> | undefined;
|
|
178
|
-
try {
|
|
179
|
-
const mcpConfigPath = path.join(WORKSPACE_DIR, 'MCP.json');
|
|
180
|
-
const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8'));
|
|
181
|
-
if (mcpConfig && typeof mcpConfig === 'object' && !Array.isArray(mcpConfig) && Object.keys(mcpConfig).length) {
|
|
182
|
-
// Already in correct format: { "name": { command, args, env } }
|
|
183
|
-
mcpServers = mcpConfig;
|
|
184
|
-
} else if (Array.isArray(mcpConfig) && mcpConfig.length) {
|
|
185
|
-
// Legacy array format: merge all entries into a single object
|
|
186
|
-
mcpServers = Object.assign({}, ...mcpConfig);
|
|
187
|
-
}
|
|
188
|
-
if (mcpServers) {
|
|
189
|
-
const names = Object.keys(mcpServers).join(', ');
|
|
190
|
-
log.info(`Loaded MCP server(s): [${names}]`);
|
|
191
|
-
}
|
|
192
|
-
} catch {}
|
|
193
|
-
|
|
552
|
+
const mcpServers = loadMcpServers();
|
|
194
553
|
const effectiveMaxTurns = maxTurns ?? 50;
|
|
195
554
|
|
|
196
|
-
|
|
197
|
-
let agents: Record<string, any> | undefined;
|
|
198
|
-
if (!supportPrompt && effectiveMaxTurns <= 10) {
|
|
199
|
-
agents = buildAgents();
|
|
200
|
-
log.info(`[bloby-agent] Orchestrator mode — loaded ${Object.keys(agents).length} sub-agent(s): ${Object.keys(agents).join(', ')}`);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
log.info(`[bloby-agent] Starting query: conv=${conversationId}, model=${model}, maxTurns=${effectiveMaxTurns}, agents=${agents ? Object.keys(agents).join(',') : 'none'}, promptLen=${enrichedPrompt.length}`);
|
|
555
|
+
log.info(`[bloby-agent] One-shot query: conv=${conversationId}, maxTurns=${effectiveMaxTurns}`);
|
|
204
556
|
|
|
205
557
|
const claudeQuery = query({
|
|
206
558
|
prompt: sdkPrompt,
|
|
@@ -213,8 +565,6 @@ export async function startBlobyAgentQuery(
|
|
|
213
565
|
abortController,
|
|
214
566
|
systemPrompt: enrichedPrompt,
|
|
215
567
|
mcpServers,
|
|
216
|
-
...(agents && { agents }),
|
|
217
|
-
...(agents && { agentProgressSummaries: true }),
|
|
218
568
|
stderr: (chunk: string) => { stderrBuf += chunk; },
|
|
219
569
|
env: {
|
|
220
570
|
...process.env as Record<string, string>,
|
|
@@ -224,9 +574,6 @@ export async function startBlobyAgentQuery(
|
|
|
224
574
|
},
|
|
225
575
|
});
|
|
226
576
|
|
|
227
|
-
// Store query handle for stopTask() support
|
|
228
|
-
activeQueries.set(conversationId, { abortController, queryHandle: claudeQuery });
|
|
229
|
-
|
|
230
577
|
onMessage('bot:typing', { conversationId });
|
|
231
578
|
|
|
232
579
|
for await (const msg of claudeQuery) {
|
|
@@ -236,10 +583,8 @@ export async function startBlobyAgentQuery(
|
|
|
236
583
|
case 'assistant': {
|
|
237
584
|
const assistantMsg = msg.message;
|
|
238
585
|
if (!assistantMsg?.content) break;
|
|
239
|
-
|
|
240
586
|
for (const block of assistantMsg.content) {
|
|
241
587
|
if (block.type === 'text' && block.text) {
|
|
242
|
-
// Add separator between text from different assistant turns
|
|
243
588
|
if (fullText && !fullText.endsWith('\n')) {
|
|
244
589
|
fullText += '\n\n';
|
|
245
590
|
onMessage('bot:token', { conversationId, token: '\n\n' });
|
|
@@ -253,83 +598,30 @@ export async function startBlobyAgentQuery(
|
|
|
253
598
|
}
|
|
254
599
|
break;
|
|
255
600
|
}
|
|
256
|
-
|
|
257
601
|
case 'result': {
|
|
258
602
|
if (fullText) {
|
|
259
603
|
onMessage('bot:response', { conversationId, content: fullText });
|
|
260
|
-
fullText = '';
|
|
604
|
+
fullText = '';
|
|
261
605
|
} else if (msg.subtype?.startsWith('error')) {
|
|
262
|
-
|
|
263
|
-
onMessage('bot:error', { conversationId, error: errorText });
|
|
606
|
+
onMessage('bot:error', { conversationId, error: (msg as any).errors?.join('; ') || 'Agent query failed' });
|
|
264
607
|
}
|
|
265
608
|
break;
|
|
266
609
|
}
|
|
267
|
-
|
|
268
610
|
case 'tool_progress':
|
|
269
|
-
onMessage('bot:tool', {
|
|
270
|
-
conversationId,
|
|
271
|
-
name: (msg as any).tool_name || 'working',
|
|
272
|
-
status: 'running',
|
|
273
|
-
});
|
|
274
|
-
break;
|
|
275
|
-
|
|
276
|
-
// ── Background sub-agent events (SDK-managed) ──
|
|
277
|
-
case 'system': {
|
|
278
|
-
const sysMsg = msg as any;
|
|
279
|
-
if (sysMsg.subtype === 'task_started') {
|
|
280
|
-
log.info(`[bloby-agent] ──── SUB-AGENT STARTED ────`);
|
|
281
|
-
log.info(`[bloby-agent] Task ID: ${sysMsg.task_id}`);
|
|
282
|
-
log.info(`[bloby-agent] Description: ${sysMsg.description}`);
|
|
283
|
-
log.info(`[bloby-agent] Type: ${sysMsg.task_type || 'agent'}`);
|
|
284
|
-
onMessage('bot:task-created', {
|
|
285
|
-
conversationId,
|
|
286
|
-
taskId: sysMsg.task_id,
|
|
287
|
-
description: sysMsg.description,
|
|
288
|
-
type: sysMsg.task_type,
|
|
289
|
-
});
|
|
290
|
-
} else if (sysMsg.subtype === 'task_progress') {
|
|
291
|
-
const summary = sysMsg.summary || sysMsg.last_tool_name || 'working';
|
|
292
|
-
log.info(`[bloby-agent] Sub-agent ${sysMsg.task_id} | Progress: ${summary} | Tools: ${sysMsg.usage?.tool_uses || 0} | ${Math.round((sysMsg.usage?.duration_ms || 0) / 1000)}s`);
|
|
293
|
-
onMessage('bot:task-progress', {
|
|
294
|
-
conversationId,
|
|
295
|
-
taskId: sysMsg.task_id,
|
|
296
|
-
summary,
|
|
297
|
-
lastTool: sysMsg.last_tool_name,
|
|
298
|
-
usage: sysMsg.usage,
|
|
299
|
-
});
|
|
300
|
-
} else if (sysMsg.subtype === 'task_notification') {
|
|
301
|
-
log.info(`[bloby-agent] ──── SUB-AGENT ${sysMsg.status?.toUpperCase()} ────`);
|
|
302
|
-
log.info(`[bloby-agent] Task ID: ${sysMsg.task_id}`);
|
|
303
|
-
log.info(`[bloby-agent] Status: ${sysMsg.status}`);
|
|
304
|
-
log.info(`[bloby-agent] Summary: ${sysMsg.summary?.slice(0, 200)}`);
|
|
305
|
-
log.info(`[bloby-agent] Tokens: ${sysMsg.usage?.total_tokens || 0} | Tools: ${sysMsg.usage?.tool_uses || 0} | Duration: ${Math.round((sysMsg.usage?.duration_ms || 0) / 1000)}s`);
|
|
306
|
-
onMessage('bot:task-done', {
|
|
307
|
-
conversationId,
|
|
308
|
-
taskId: sysMsg.task_id,
|
|
309
|
-
status: sysMsg.status,
|
|
310
|
-
summary: sysMsg.summary,
|
|
311
|
-
usage: sysMsg.usage,
|
|
312
|
-
});
|
|
313
|
-
// If the sub-agent wrote files, flag it
|
|
314
|
-
if (sysMsg.status === 'completed') {
|
|
315
|
-
usedTools.add('Write'); // ensure backend restart
|
|
316
|
-
}
|
|
317
|
-
}
|
|
611
|
+
onMessage('bot:tool', { conversationId, name: (msg as any).tool_name || 'working', status: 'running' });
|
|
318
612
|
break;
|
|
319
|
-
}
|
|
320
613
|
}
|
|
321
614
|
}
|
|
322
615
|
|
|
323
|
-
// If we accumulated text but didn't hit a result message, send what we have
|
|
324
616
|
if (fullText && !abortController.signal.aborted) {
|
|
325
617
|
onMessage('bot:response', { conversationId, content: fullText });
|
|
326
618
|
}
|
|
327
619
|
} catch (err: any) {
|
|
328
620
|
if (!abortController.signal.aborted) {
|
|
329
621
|
const detail = stderrBuf.trim();
|
|
330
|
-
const
|
|
331
|
-
log.warn(`Bloby agent error (${conversationId}): ${
|
|
332
|
-
onMessage('bot:error', { conversationId, error:
|
|
622
|
+
const errMsg = detail ? `${err.message}\n\nCLI stderr:\n${detail}` : err.message;
|
|
623
|
+
log.warn(`Bloby agent error (${conversationId}): ${errMsg}`);
|
|
624
|
+
onMessage('bot:error', { conversationId, error: errMsg });
|
|
333
625
|
}
|
|
334
626
|
} finally {
|
|
335
627
|
activeQueries.delete(conversationId);
|
|
@@ -339,7 +631,7 @@ export async function startBlobyAgentQuery(
|
|
|
339
631
|
}
|
|
340
632
|
}
|
|
341
633
|
|
|
342
|
-
/** Stop
|
|
634
|
+
/** Stop a one-shot query */
|
|
343
635
|
export function stopBlobyAgentQuery(conversationId: string): void {
|
|
344
636
|
const q = activeQueries.get(conversationId);
|
|
345
637
|
if (q) {
|
|
@@ -347,14 +639,3 @@ export function stopBlobyAgentQuery(conversationId: string): void {
|
|
|
347
639
|
activeQueries.delete(conversationId);
|
|
348
640
|
}
|
|
349
641
|
}
|
|
350
|
-
|
|
351
|
-
/** Stop a specific background sub-agent task */
|
|
352
|
-
export async function stopSubAgentTask(conversationId: string, taskId: string): Promise<void> {
|
|
353
|
-
const q = activeQueries.get(conversationId);
|
|
354
|
-
if (q?.queryHandle?.stopTask) {
|
|
355
|
-
log.info(`[bloby-agent] Stopping sub-agent task: ${taskId}`);
|
|
356
|
-
await q.queryHandle.stopTask(taskId);
|
|
357
|
-
} else {
|
|
358
|
-
log.warn(`[bloby-agent] Cannot stop task ${taskId} — no active query for ${conversationId}`);
|
|
359
|
-
}
|
|
360
|
-
}
|
|
@@ -20,7 +20,7 @@ import path from 'path';
|
|
|
20
20
|
import { loadConfig } from '../../shared/config.js';
|
|
21
21
|
import { WORKSPACE_DIR } from '../../shared/paths.js';
|
|
22
22
|
import { log } from '../../shared/logger.js';
|
|
23
|
-
import { startBlobyAgentQuery, type RecentMessage } from '../bloby-agent.js';
|
|
23
|
+
import { startBlobyAgentQuery, startConversation, pushMessage, hasConversation, type RecentMessage } from '../bloby-agent.js';
|
|
24
24
|
import { WhatsAppChannel } from './whatsapp.js';
|
|
25
25
|
import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, InboundMessageAttachment, SenderRole } from './types.js';
|
|
26
26
|
import type { AgentAttachment } from '../bloby-agent.js';
|
|
@@ -392,14 +392,14 @@ export class ChannelManager {
|
|
|
392
392
|
// Show "typing..." while the agent processes
|
|
393
393
|
this.startTyping(msg.channel, msg.rawSender);
|
|
394
394
|
|
|
395
|
-
// Track text chunks for WhatsApp —
|
|
395
|
+
// Track text chunks for WhatsApp — lives for the conversation lifetime
|
|
396
396
|
let waChunkBuf = '';
|
|
397
397
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
(type, eventData) => {
|
|
398
|
+
// Start a live conversation if one doesn't exist (shared with chat UI)
|
|
399
|
+
if (!hasConversation(convId)) {
|
|
400
|
+
log.info(`[channels] Starting live conversation for admin: ${convId}`);
|
|
401
|
+
|
|
402
|
+
await startConversation(convId, model, (type, eventData) => {
|
|
403
403
|
// Accumulate text tokens
|
|
404
404
|
if (type === 'bot:token' && eventData.token) {
|
|
405
405
|
waChunkBuf += eventData.token;
|
|
@@ -414,7 +414,7 @@ export class ChannelManager {
|
|
|
414
414
|
}
|
|
415
415
|
|
|
416
416
|
if (type === 'bot:response' && eventData.content) {
|
|
417
|
-
// Send remaining text
|
|
417
|
+
// Send remaining text
|
|
418
418
|
const remaining = waChunkBuf.trim();
|
|
419
419
|
if (remaining) {
|
|
420
420
|
this.sendMessage(msg.channel, msg.rawSender, remaining).catch((err) => {
|
|
@@ -431,22 +431,22 @@ export class ChannelManager {
|
|
|
431
431
|
}).catch(() => {});
|
|
432
432
|
}
|
|
433
433
|
|
|
434
|
-
//
|
|
435
|
-
if (type === 'bot:
|
|
436
|
-
broadcastBloby(type, eventData);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
if (type === 'bot:done' && eventData.usedFileTools) {
|
|
434
|
+
// Handle turn completion — restart backend if needed
|
|
435
|
+
if (type === 'bot:turn-complete' && eventData.usedFileTools) {
|
|
440
436
|
this.opts.restartBackend();
|
|
441
437
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
438
|
+
|
|
439
|
+
// Don't forward internal events to chat clients
|
|
440
|
+
if (type === 'bot:turn-complete' || type === 'bot:conversation-ended') return;
|
|
441
|
+
|
|
442
|
+
// Mirror streaming + task events to chat clients
|
|
443
|
+
broadcastBloby(type, eventData);
|
|
444
|
+
}, { botName, humanName }, recentMessages);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Push the message into the live conversation
|
|
448
|
+
const channelContent = channelContext + msg.text;
|
|
449
|
+
pushMessage(convId, channelContent, agentAttachments);
|
|
450
450
|
}
|
|
451
451
|
|
|
452
452
|
/** Handle message from a customer — runs support agent in parallel with conversation context */
|
package/supervisor/index.ts
CHANGED
|
@@ -13,7 +13,12 @@ import { createWorkerApp } from '../worker/index.js';
|
|
|
13
13
|
import { closeDb, getSession, getSetting } from '../worker/db.js';
|
|
14
14
|
import { spawnBackend, stopBackend, getBackendPort, isBackendAlive, isBackendStopping, resetBackendRestarts } from './backend.js';
|
|
15
15
|
import { updateTunnelUrl, startHeartbeat, stopHeartbeat, disconnect } from '../shared/relay.js';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
startConversation, pushMessage, hasConversation, endConversation,
|
|
18
|
+
isConversationBusy, stopSubAgentTask,
|
|
19
|
+
startBlobyAgentQuery, stopBlobyAgentQuery,
|
|
20
|
+
type RecentMessage,
|
|
21
|
+
} from './bloby-agent.js';
|
|
17
22
|
import { ensureFileDirs, saveAttachment, type SavedFile } from './file-saver.js';
|
|
18
23
|
import { startViteDevServers, stopViteDevServers } from './vite-dev.js';
|
|
19
24
|
import { startScheduler, stopScheduler } from './scheduler.js';
|
|
@@ -1127,84 +1132,101 @@ ${!connected ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
|
|
|
1127
1132
|
}
|
|
1128
1133
|
} catch {}
|
|
1129
1134
|
|
|
1130
|
-
// Mirror chat responses to WhatsApp self-chat (if connected)
|
|
1131
|
-
const waStatus = channelManager.getStatus('whatsapp');
|
|
1132
|
-
const waMirrorJid = waStatus?.connected ? waStatus.info?.phoneNumber : null;
|
|
1133
|
-
let waChunkBuf = '';
|
|
1134
|
-
|
|
1135
|
-
// Start orchestrator query (maxTurns: 8 — quick tasks direct, coding delegated)
|
|
1136
1135
|
log.info(`[orchestrator] ──── USER MESSAGE ────`);
|
|
1137
1136
|
log.info(`[orchestrator] Content: "${content.slice(0, 100)}..."`);
|
|
1138
|
-
log.info(`[orchestrator] Model: ${freshConfig.ai.model}`);
|
|
1139
1137
|
log.info(`[orchestrator] Conv: ${convId}`);
|
|
1140
|
-
log.info(`[orchestrator]
|
|
1141
|
-
agentQueryActive = true;
|
|
1142
|
-
currentStreamConvId = convId;
|
|
1143
|
-
currentStreamBuffer = '';
|
|
1144
|
-
startBlobyAgentQuery(convId, content, freshConfig.ai.model, (type, eventData) => {
|
|
1145
|
-
// Track stream buffer for reconnecting clients
|
|
1146
|
-
if (type === 'bot:token' && eventData.token) {
|
|
1147
|
-
currentStreamBuffer += eventData.token;
|
|
1148
|
-
if (waMirrorJid) waChunkBuf += eventData.token;
|
|
1149
|
-
}
|
|
1138
|
+
log.info(`[orchestrator] Live conversation exists: ${hasConversation(convId)}`);
|
|
1150
1139
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
waChunkBuf = '';
|
|
1155
|
-
}
|
|
1140
|
+
// Start a live conversation if one doesn't exist
|
|
1141
|
+
if (!hasConversation(convId)) {
|
|
1142
|
+
log.info(`[orchestrator] Starting new live conversation...`);
|
|
1156
1143
|
|
|
1157
|
-
//
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
stopBackend().then(() => spawnBackend(backendPort));
|
|
1171
|
-
}
|
|
1172
|
-
// Run deferred update if agent requested one
|
|
1173
|
-
if (pendingUpdate) {
|
|
1174
|
-
pendingUpdate = false;
|
|
1175
|
-
runDeferredUpdate();
|
|
1144
|
+
// WhatsApp mirror state — lives for the conversation lifetime
|
|
1145
|
+
let waChunkBuf = '';
|
|
1146
|
+
|
|
1147
|
+
await startConversation(convId, freshConfig.ai.model, (type, eventData) => {
|
|
1148
|
+
// Check WA mirror on each event (connection state may change)
|
|
1149
|
+
const waStatus = channelManager.getStatus('whatsapp');
|
|
1150
|
+
const waMirrorJid = waStatus?.connected ? waStatus.info?.phoneNumber : null;
|
|
1151
|
+
|
|
1152
|
+
// Track stream buffer for reconnecting clients
|
|
1153
|
+
if (type === 'bot:typing') {
|
|
1154
|
+
currentStreamConvId = convId;
|
|
1155
|
+
currentStreamBuffer = '';
|
|
1156
|
+
agentQueryActive = true;
|
|
1176
1157
|
}
|
|
1177
|
-
return; // don't forward bot:done to client
|
|
1178
|
-
}
|
|
1179
1158
|
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1159
|
+
if (type === 'bot:token' && eventData.token) {
|
|
1160
|
+
currentStreamBuffer += eventData.token;
|
|
1161
|
+
if (waMirrorJid) waChunkBuf += eventData.token;
|
|
1162
|
+
}
|
|
1183
1163
|
|
|
1184
|
-
// WhatsApp mirror: send
|
|
1185
|
-
if (waMirrorJid && waChunkBuf.trim()) {
|
|
1164
|
+
// WhatsApp mirror: send intermediate chunk when agent pauses for tool use
|
|
1165
|
+
if (type === 'bot:tool' && waMirrorJid && waChunkBuf.trim()) {
|
|
1186
1166
|
channelManager.sendMessage('whatsapp', `${waMirrorJid}@s.whatsapp.net`, waChunkBuf.trim()).catch(() => {});
|
|
1187
1167
|
waChunkBuf = '';
|
|
1188
1168
|
}
|
|
1189
1169
|
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1170
|
+
// Agent finished a turn — handle backend restart + state cleanup
|
|
1171
|
+
if (type === 'bot:turn-complete') {
|
|
1172
|
+
log.info(`[orchestrator] ──── TURN COMPLETE ────`);
|
|
1173
|
+
log.info(`[orchestrator] File tools used: ${eventData.usedFileTools}`);
|
|
1174
|
+
agentQueryActive = false;
|
|
1175
|
+
currentStreamConvId = null;
|
|
1176
|
+
currentStreamBuffer = '';
|
|
1177
|
+
|
|
1178
|
+
if (eventData.usedFileTools || pendingBackendRestart) {
|
|
1179
|
+
log.info('[orchestrator] Restarting backend (file tools used)');
|
|
1180
|
+
pendingBackendRestart = false;
|
|
1181
|
+
if (backendRestartTimer) { clearTimeout(backendRestartTimer); backendRestartTimer = null; }
|
|
1182
|
+
resetBackendRestarts();
|
|
1183
|
+
stopBackend().then(() => spawnBackend(backendPort));
|
|
1197
1184
|
}
|
|
1198
|
-
|
|
1199
|
-
|
|
1185
|
+
if (pendingUpdate) {
|
|
1186
|
+
pendingUpdate = false;
|
|
1187
|
+
runDeferredUpdate();
|
|
1188
|
+
}
|
|
1189
|
+
return; // don't forward to client
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Conversation ended (query loop exited)
|
|
1193
|
+
if (type === 'bot:conversation-ended') {
|
|
1194
|
+
log.info(`[orchestrator] Conversation ended: ${convId}`);
|
|
1195
|
+
agentQueryActive = false;
|
|
1196
|
+
currentStreamConvId = null;
|
|
1197
|
+
currentStreamBuffer = '';
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
1200
|
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1201
|
+
// Save assistant response to DB
|
|
1202
|
+
if (type === 'bot:response') {
|
|
1203
|
+
currentStreamBuffer = '';
|
|
1204
|
+
|
|
1205
|
+
// WhatsApp mirror: send remaining chunk
|
|
1206
|
+
if (waMirrorJid && waChunkBuf.trim()) {
|
|
1207
|
+
channelManager.sendMessage('whatsapp', `${waMirrorJid}@s.whatsapp.net`, waChunkBuf.trim()).catch(() => {});
|
|
1208
|
+
waChunkBuf = '';
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
(async () => {
|
|
1212
|
+
try {
|
|
1213
|
+
await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
1214
|
+
role: 'assistant', content: eventData.content, meta: { model: freshConfig.ai.model },
|
|
1215
|
+
});
|
|
1216
|
+
} catch (err: any) {
|
|
1217
|
+
log.warn(`[bloby] DB persist bot response error: ${err.message}`);
|
|
1218
|
+
}
|
|
1219
|
+
})();
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// Stream all events to every connected client
|
|
1223
|
+
broadcastBloby(type, eventData);
|
|
1224
|
+
}, { botName, humanName }, recentMessages);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// Push the user message into the live conversation
|
|
1228
|
+
log.info(`[orchestrator] Pushing message into live conversation`);
|
|
1229
|
+
pushMessage(convId, content, data.attachments, savedFiles);
|
|
1208
1230
|
})();
|
|
1209
1231
|
return;
|
|
1210
1232
|
}
|
|
@@ -1238,7 +1260,13 @@ ${!connected ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
|
|
|
1238
1260
|
}
|
|
1239
1261
|
|
|
1240
1262
|
if (msg.type === 'user:stop') {
|
|
1241
|
-
|
|
1263
|
+
// End the live conversation (if any) or stop a one-shot query
|
|
1264
|
+
if (hasConversation(convId)) {
|
|
1265
|
+
log.info(`[orchestrator] user:stop — ending live conversation ${convId}`);
|
|
1266
|
+
endConversation(convId);
|
|
1267
|
+
} else {
|
|
1268
|
+
stopBlobyAgentQuery(convId);
|
|
1269
|
+
}
|
|
1242
1270
|
return;
|
|
1243
1271
|
}
|
|
1244
1272
|
|
|
@@ -1256,6 +1284,11 @@ ${!connected ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
|
|
|
1256
1284
|
if (msg.type === 'user:clear-context') {
|
|
1257
1285
|
(async () => {
|
|
1258
1286
|
try {
|
|
1287
|
+
// End the live conversation
|
|
1288
|
+
if (hasConversation(convId)) {
|
|
1289
|
+
log.info(`[orchestrator] clear-context — ending live conversation ${convId}`);
|
|
1290
|
+
endConversation(convId);
|
|
1291
|
+
}
|
|
1259
1292
|
clientConvs.delete(ws);
|
|
1260
1293
|
await workerApi('/api/context/clear', 'POST');
|
|
1261
1294
|
} catch (err: any) {
|
|
@@ -1325,11 +1358,14 @@ ${!connected ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
|
|
|
1325
1358
|
}
|
|
1326
1359
|
});
|
|
1327
1360
|
|
|
1328
|
-
// Track whether an agent
|
|
1361
|
+
// Track whether an agent is actively processing — file watcher defers restarts during active turns
|
|
1329
1362
|
let agentQueryActive = false;
|
|
1330
1363
|
let pendingBackendRestart = false; // Set when file watcher fires during agent turn
|
|
1331
1364
|
let pendingUpdate = false; // Set when .update file is created during agent turn
|
|
1332
1365
|
|
|
1366
|
+
// Note: with live conversations, agentQueryActive is true while the agent processes a message
|
|
1367
|
+
// and false when it's idle (waiting for next message). The live conversation stays alive between messages.
|
|
1368
|
+
|
|
1333
1369
|
// Run bloby update as a child process.
|
|
1334
1370
|
// BLOBY_SELF_UPDATE=1 tells bin/cli.js to skip daemon stop/restart —
|
|
1335
1371
|
// the supervisor exits after the update finishes, and systemd (Restart=on-failure)
|
|
@@ -221,7 +221,7 @@ You handle two kinds of work differently:
|
|
|
221
221
|
- Complex research or data gathering
|
|
222
222
|
- Any coding task that touches workspace source files (client/, backend/)
|
|
223
223
|
|
|
224
|
-
For quick tasks, use your tools directly — Read, Write, Edit, Bash.
|
|
224
|
+
For quick tasks, use your tools directly — Read, Write, Edit, Bash.
|
|
225
225
|
|
|
226
226
|
For coding tasks, use the Agent tool. It runs in the background — you respond immediately while the work happens behind the scenes.
|
|
227
227
|
|