bloby-bot 0.20.8 → 0.21.1
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/dist-bloby/assets/{bloby-C2KDOC_1.js → bloby-DQ36AQA0.js} +4 -4
- package/dist-bloby/assets/{highlighted-body-OFNGDK62-CdUBnqzY.js → highlighted-body-OFNGDK62-CPyk9gZ2.js} +1 -1
- package/dist-bloby/assets/mermaid-GHXKKRXX-BbN_HY2S.js +1 -0
- package/dist-bloby/bloby.html +1 -1
- package/package.json +1 -1
- package/supervisor/bloby-agent.ts +430 -149
- package/supervisor/channels/manager.ts +22 -22
- package/supervisor/chat/src/components/Chat/InputBar.tsx +1 -1
- package/supervisor/index.ts +103 -67
- package/worker/prompts/bloby-system-prompt.txt +1 -1
- package/dist-bloby/assets/mermaid-GHXKKRXX-mjSiQkZC.js +0 -1
|
@@ -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
|
-
}
|