bloby-bot 0.25.1 → 0.25.4
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
CHANGED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent API — exposes the Claude Agent SDK to workspace code.
|
|
3
|
+
*
|
|
4
|
+
* Single endpoint: POST /api/agent/query
|
|
5
|
+
*
|
|
6
|
+
* If `systemPromptPath` is provided → read it from workspace, use as systemPrompt.
|
|
7
|
+
* If omitted → use the `claude_code` preset (built-in Claude Code tools + prompt).
|
|
8
|
+
*
|
|
9
|
+
* Supports session persistence via `sessionId` (pass the one returned from a previous call).
|
|
10
|
+
*
|
|
11
|
+
* Auth: per-session secret injected into the workspace backend as BLOBY_AGENT_SECRET.
|
|
12
|
+
* Safety: localhost-only, path traversal prevention, concurrency + rate limits, timeouts.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import { query, type SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
18
|
+
import { WORKSPACE_DIR } from '../shared/paths.js';
|
|
19
|
+
import { log } from '../shared/logger.js';
|
|
20
|
+
import { getClaudeAccessToken } from '../worker/claude-auth.js';
|
|
21
|
+
|
|
22
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface AgentQueryRequest {
|
|
25
|
+
message: string;
|
|
26
|
+
systemPromptPath?: string; // relative to workspace, e.g. "skills/my-skill/prompt.txt"
|
|
27
|
+
sessionId?: string; // resume a previous session
|
|
28
|
+
maxTurns?: number; // default 25, max 50
|
|
29
|
+
timeout?: number; // ms, default 120_000, max 300_000
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AgentQueryResponse {
|
|
33
|
+
ok: boolean;
|
|
34
|
+
response?: string;
|
|
35
|
+
sessionId?: string;
|
|
36
|
+
toolsUsed?: string[];
|
|
37
|
+
usedFileTools?: boolean;
|
|
38
|
+
error?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Concurrency & Rate Limiting ────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const MAX_CONCURRENT = 3;
|
|
44
|
+
const RATE_LIMIT_WINDOW = 60_000; // 1 minute
|
|
45
|
+
const RATE_LIMIT_MAX = 10;
|
|
46
|
+
|
|
47
|
+
let activeQueries = 0;
|
|
48
|
+
const requestTimestamps: number[] = [];
|
|
49
|
+
|
|
50
|
+
function checkRateLimit(): string | null {
|
|
51
|
+
// Concurrency check
|
|
52
|
+
if (activeQueries >= MAX_CONCURRENT) {
|
|
53
|
+
return `Too many concurrent queries (max ${MAX_CONCURRENT}). Try again shortly.`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Rate limit check
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
// Prune old timestamps
|
|
59
|
+
while (requestTimestamps.length && requestTimestamps[0]! < now - RATE_LIMIT_WINDOW) {
|
|
60
|
+
requestTimestamps.shift();
|
|
61
|
+
}
|
|
62
|
+
if (requestTimestamps.length >= RATE_LIMIT_MAX) {
|
|
63
|
+
return `Rate limit exceeded (max ${RATE_LIMIT_MAX} requests per minute).`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Path Validation ────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
function resolveSystemPromptPath(relPath: string): { path: string } | { error: string } {
|
|
72
|
+
const resolved = path.resolve(WORKSPACE_DIR, relPath);
|
|
73
|
+
const workspaceBoundary = WORKSPACE_DIR + path.sep;
|
|
74
|
+
|
|
75
|
+
// Must be inside workspace (not parent traversal)
|
|
76
|
+
if (!resolved.startsWith(workspaceBoundary) && resolved !== WORKSPACE_DIR) {
|
|
77
|
+
return { error: 'System prompt path must be within the workspace directory.' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!fs.existsSync(resolved)) {
|
|
81
|
+
return { error: `System prompt file not found: ${relPath}` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { path: resolved };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Main Query Handler ─────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
export async function handleAgentQuery(req: AgentQueryRequest): Promise<AgentQueryResponse> {
|
|
90
|
+
// ── Validate inputs ──
|
|
91
|
+
if (!req.message || typeof req.message !== 'string') {
|
|
92
|
+
return { ok: false, error: 'Missing or invalid "message" field.' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const maxTurns = Math.min(Math.max(req.maxTurns || 25, 1), 50);
|
|
96
|
+
const timeout = Math.min(Math.max(req.timeout || 120_000, 5_000), 300_000);
|
|
97
|
+
|
|
98
|
+
// ── Rate limit ──
|
|
99
|
+
const rateLimitError = checkRateLimit();
|
|
100
|
+
if (rateLimitError) {
|
|
101
|
+
return { ok: false, error: rateLimitError };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Resolve system prompt ──
|
|
105
|
+
let systemPrompt: string | { type: 'preset'; preset: 'claude_code' };
|
|
106
|
+
|
|
107
|
+
if (req.systemPromptPath) {
|
|
108
|
+
const result = resolveSystemPromptPath(req.systemPromptPath);
|
|
109
|
+
if ('error' in result) {
|
|
110
|
+
return { ok: false, error: result.error };
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const content = fs.readFileSync(result.path, 'utf-8').trim();
|
|
114
|
+
if (!content) {
|
|
115
|
+
return { ok: false, error: 'System prompt file is empty.' };
|
|
116
|
+
}
|
|
117
|
+
systemPrompt = content;
|
|
118
|
+
} catch (err: any) {
|
|
119
|
+
return { ok: false, error: `Failed to read system prompt: ${err.message}` };
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
systemPrompt = { type: 'preset', preset: 'claude_code' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── OAuth token ──
|
|
126
|
+
const oauthToken = await getClaudeAccessToken();
|
|
127
|
+
if (!oauthToken) {
|
|
128
|
+
return { ok: false, error: 'Claude OAuth token not found. Please authenticate via the dashboard.' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Execute query ──
|
|
132
|
+
activeQueries++;
|
|
133
|
+
requestTimestamps.push(Date.now());
|
|
134
|
+
|
|
135
|
+
const abortController = new AbortController();
|
|
136
|
+
const timeoutHandle = setTimeout(() => abortController.abort(), timeout);
|
|
137
|
+
|
|
138
|
+
let fullText = '';
|
|
139
|
+
const usedTools = new Set<string>();
|
|
140
|
+
let sessionId: string | undefined;
|
|
141
|
+
let stderrBuf = '';
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
log.info(`[agent-api] Query: msg="${req.message.slice(0, 80)}..." maxTurns=${maxTurns} timeout=${timeout}ms resume=${req.sessionId || 'none'}`);
|
|
145
|
+
|
|
146
|
+
const claudeQuery = query({
|
|
147
|
+
prompt: req.message,
|
|
148
|
+
options: {
|
|
149
|
+
cwd: WORKSPACE_DIR,
|
|
150
|
+
permissionMode: 'bypassPermissions',
|
|
151
|
+
allowDangerouslySkipPermissions: true,
|
|
152
|
+
maxTurns,
|
|
153
|
+
abortController,
|
|
154
|
+
systemPrompt: systemPrompt as any,
|
|
155
|
+
...(req.sessionId ? { resume: req.sessionId } : {}),
|
|
156
|
+
stderr: (chunk: string) => { stderrBuf += chunk; },
|
|
157
|
+
env: {
|
|
158
|
+
...process.env as Record<string, string>,
|
|
159
|
+
CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
|
|
160
|
+
CLAUDE_CODE_BUBBLEWRAP: '1',
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
for await (const msg of claudeQuery) {
|
|
166
|
+
if (abortController.signal.aborted) break;
|
|
167
|
+
|
|
168
|
+
switch (msg.type) {
|
|
169
|
+
case 'assistant': {
|
|
170
|
+
const assistantMsg = (msg as any).message;
|
|
171
|
+
if (!assistantMsg?.content) break;
|
|
172
|
+
for (const block of assistantMsg.content) {
|
|
173
|
+
if (block.type === 'text' && block.text) {
|
|
174
|
+
if (fullText && !fullText.endsWith('\n')) fullText += '\n\n';
|
|
175
|
+
fullText += block.text;
|
|
176
|
+
} else if (block.type === 'tool_use') {
|
|
177
|
+
usedTools.add(block.name);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case 'result': {
|
|
183
|
+
// Extract session_id from result for persistence
|
|
184
|
+
sessionId = (msg as any).session_id;
|
|
185
|
+
|
|
186
|
+
if (!fullText && (msg as any).subtype?.startsWith('error')) {
|
|
187
|
+
const errors = (msg as any).errors?.join('; ') || 'Agent query failed';
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
error: errors,
|
|
191
|
+
sessionId,
|
|
192
|
+
toolsUsed: Array.from(usedTools),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const FILE_TOOLS = ['Write', 'Edit'];
|
|
201
|
+
const usedFileTools = FILE_TOOLS.some((t) => usedTools.has(t));
|
|
202
|
+
|
|
203
|
+
log.info(`[agent-api] Done: ${fullText.length} chars, tools=[${Array.from(usedTools).join(',')}], session=${sessionId || 'unknown'}`);
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
response: fullText,
|
|
208
|
+
sessionId,
|
|
209
|
+
toolsUsed: Array.from(usedTools),
|
|
210
|
+
usedFileTools,
|
|
211
|
+
};
|
|
212
|
+
} catch (err: any) {
|
|
213
|
+
if (abortController.signal.aborted) {
|
|
214
|
+
return { ok: false, error: 'Query timed out.', sessionId };
|
|
215
|
+
}
|
|
216
|
+
const detail = stderrBuf.trim();
|
|
217
|
+
const errMsg = detail ? `${err.message}\n\n${detail}` : err.message;
|
|
218
|
+
log.warn(`[agent-api] Error: ${errMsg}`);
|
|
219
|
+
return { ok: false, error: errMsg, sessionId };
|
|
220
|
+
} finally {
|
|
221
|
+
clearTimeout(timeoutHandle);
|
|
222
|
+
activeQueries--;
|
|
223
|
+
}
|
|
224
|
+
}
|
package/supervisor/backend.ts
CHANGED
|
@@ -11,6 +11,14 @@ let intentionallyStopped = false;
|
|
|
11
11
|
const MAX_RESTARTS = 3;
|
|
12
12
|
const STABLE_THRESHOLD = 30_000; // 30s — if backend ran this long, it wasn't a crash loop
|
|
13
13
|
|
|
14
|
+
/** Extra env vars injected into every backend spawn (e.g. BLOBY_AGENT_SECRET) */
|
|
15
|
+
let extraEnv: Record<string, string> = {};
|
|
16
|
+
|
|
17
|
+
/** Set extra environment variables for the backend process. Applies to all future spawns including auto-restarts. */
|
|
18
|
+
export function setBackendEnv(env: Record<string, string>): void {
|
|
19
|
+
extraEnv = { ...extraEnv, ...env };
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
const LOG_FILE = path.join(WORKSPACE_DIR, '.backend.log');
|
|
15
23
|
|
|
16
24
|
export function getBackendPort(basePort: number): number {
|
|
@@ -59,7 +67,7 @@ export function spawnBackend(port: number): ChildProcess {
|
|
|
59
67
|
child = spawn(process.execPath, ['--import', 'tsx/esm', '--input-type=module', '-e', wrapper], {
|
|
60
68
|
cwd: WORKSPACE_DIR,
|
|
61
69
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
62
|
-
env: { ...process.env, BACKEND_PORT: String(port) },
|
|
70
|
+
env: { ...process.env, ...extraEnv, BACKEND_PORT: String(port) },
|
|
63
71
|
});
|
|
64
72
|
|
|
65
73
|
child.stdout?.on('data', (d) => {
|
|
@@ -70,6 +70,8 @@ export class ChannelManager {
|
|
|
70
70
|
private customerBuffers = new Map<string, BufferedMessage[]>();
|
|
71
71
|
/** Debounce buffers per sender (keyed by "channel:sender") */
|
|
72
72
|
private debounceBuffers = new Map<string, DebounceEntry>();
|
|
73
|
+
/** Dynamic reply target for the admin live conversation (updated before each pushMessage) */
|
|
74
|
+
private waReplyTarget: { channel: ChannelType; rawSender: string; assistantBufferKey?: string } | null = null;
|
|
73
75
|
|
|
74
76
|
constructor(opts: ChannelManagerOpts) {
|
|
75
77
|
this.opts = opts;
|
|
@@ -362,19 +364,58 @@ export class ChannelManager {
|
|
|
362
364
|
return;
|
|
363
365
|
}
|
|
364
366
|
|
|
365
|
-
// Assistant mode — triggered message in someone else's chat
|
|
367
|
+
// Assistant mode — triggered message in someone else's chat → route through admin (shared brain)
|
|
366
368
|
if (mode === 'assistant') {
|
|
369
|
+
const phone = sender.replace(/@.*/, '');
|
|
370
|
+
const bufferKey = `${channel}:${phone}`;
|
|
371
|
+
const buffer = this.customerBuffers.get(bufferKey) || [];
|
|
372
|
+
|
|
373
|
+
// Strip trigger prefix
|
|
374
|
+
const cfgBotName = loadConfig().username || 'bloby';
|
|
375
|
+
const triggerRegex = new RegExp(`@${cfgBotName}[:\\s]+`, 'i');
|
|
376
|
+
const triggerMatch = combinedText.match(triggerRegex);
|
|
377
|
+
let cleanText = combinedText;
|
|
378
|
+
if (triggerMatch && triggerMatch.index !== undefined) {
|
|
379
|
+
cleanText = combinedText.slice(triggerMatch.index + triggerMatch[0].length).trim();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Load skill context (SCRIPT.md + contact memory)
|
|
383
|
+
const scriptPrompt = this.loadActiveScript(channelConfig);
|
|
384
|
+
let contactMemory = '';
|
|
385
|
+
try {
|
|
386
|
+
const customerDataDir = this.getSkillCustomerDataDir(channelConfig);
|
|
387
|
+
if (customerDataDir) {
|
|
388
|
+
const memoryPath = path.join(WORKSPACE_DIR, customerDataDir, `${phone}.md`);
|
|
389
|
+
if (fs.existsSync(memoryPath)) {
|
|
390
|
+
contactMemory = fs.readFileSync(memoryPath, 'utf-8').trim();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
} catch {}
|
|
394
|
+
|
|
395
|
+
// Build enriched text: skill instructions + conversation context + command
|
|
396
|
+
let enrichedText = '';
|
|
397
|
+
if (scriptPrompt) enrichedText += `# Assistant Skill Instructions\n${scriptPrompt}\n\n---\n`;
|
|
398
|
+
if (contactMemory) enrichedText += `# Contact Memory (${phone})\n${contactMemory}\n\n---\n`;
|
|
399
|
+
if (buffer.length > 0) {
|
|
400
|
+
enrichedText += `# Recent conversation with ${senderName || phone}\n`;
|
|
401
|
+
enrichedText += buffer.map((m) => m.content).join('\n');
|
|
402
|
+
enrichedText += '\n\n---\n';
|
|
403
|
+
}
|
|
404
|
+
enrichedText += cleanText;
|
|
405
|
+
|
|
367
406
|
const message: InboundMessage = {
|
|
368
407
|
channel,
|
|
369
|
-
sender:
|
|
408
|
+
sender: phone,
|
|
370
409
|
senderName,
|
|
371
410
|
role: 'assistant',
|
|
372
|
-
text:
|
|
411
|
+
text: enrichedText,
|
|
412
|
+
displayText: cleanText,
|
|
373
413
|
rawSender: sender,
|
|
374
414
|
attachments: attachments.length > 0 ? attachments : undefined,
|
|
375
415
|
};
|
|
376
|
-
|
|
377
|
-
|
|
416
|
+
|
|
417
|
+
log.info(`[channels] Assistant mode | triggered in chat with ${phone} | buffer=${buffer.length} msgs | "${cleanText.slice(0, 60)}"`);
|
|
418
|
+
await this.handleAdminMessage(message);
|
|
378
419
|
return;
|
|
379
420
|
}
|
|
380
421
|
|
|
@@ -415,7 +456,7 @@ export class ChannelManager {
|
|
|
415
456
|
return 'customer';
|
|
416
457
|
}
|
|
417
458
|
|
|
418
|
-
/** Handle message from an admin — mirrors to chat conversation, uses main system prompt */
|
|
459
|
+
/** Handle message from an admin (or assistant trigger) — mirrors to chat conversation, uses main system prompt */
|
|
419
460
|
private async handleAdminMessage(msg: InboundMessage) {
|
|
420
461
|
const { workerApi, broadcastBloby, getModel } = this.opts;
|
|
421
462
|
const model = getModel();
|
|
@@ -436,11 +477,14 @@ export class ChannelManager {
|
|
|
436
477
|
return;
|
|
437
478
|
}
|
|
438
479
|
|
|
480
|
+
// Use display text for DB/chat (hides enriched agent context from the UI)
|
|
481
|
+
const displayContent = msg.displayText || msg.text;
|
|
482
|
+
|
|
439
483
|
// Save user message to DB
|
|
440
484
|
try {
|
|
441
485
|
await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
442
486
|
role: 'user',
|
|
443
|
-
content:
|
|
487
|
+
content: displayContent,
|
|
444
488
|
meta: { model, channel: msg.channel },
|
|
445
489
|
});
|
|
446
490
|
} catch (err: any) {
|
|
@@ -450,7 +494,7 @@ export class ChannelManager {
|
|
|
450
494
|
// Broadcast to chat clients (mirroring)
|
|
451
495
|
broadcastBloby('chat:sync', {
|
|
452
496
|
conversationId: convId,
|
|
453
|
-
message: { role: 'user', content:
|
|
497
|
+
message: { role: 'user', content: displayContent, timestamp: new Date().toISOString() },
|
|
454
498
|
});
|
|
455
499
|
|
|
456
500
|
// Fetch names and recent messages
|
|
@@ -474,8 +518,10 @@ export class ChannelManager {
|
|
|
474
518
|
}
|
|
475
519
|
} catch {}
|
|
476
520
|
|
|
477
|
-
// Channel context —
|
|
478
|
-
const
|
|
521
|
+
// Channel context — dynamic role (admin for self-chat, assistant for triggered messages)
|
|
522
|
+
const roleTag = msg.senderName && msg.role === 'assistant'
|
|
523
|
+
? `${msg.role} | ${msg.senderName}` : msg.role;
|
|
524
|
+
const channelContext = `[WhatsApp | ${msg.sender} | ${roleTag}]\n`;
|
|
479
525
|
|
|
480
526
|
// Convert inbound attachments to agent format
|
|
481
527
|
const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({
|
|
@@ -485,7 +531,7 @@ export class ChannelManager {
|
|
|
485
531
|
data: att.data,
|
|
486
532
|
}));
|
|
487
533
|
|
|
488
|
-
// Show "typing..."
|
|
534
|
+
// Show "typing..." in the correct chat
|
|
489
535
|
this.startTyping(msg.channel, msg.rawSender);
|
|
490
536
|
|
|
491
537
|
// Track text chunks for WhatsApp — lives for the conversation lifetime
|
|
@@ -501,24 +547,37 @@ export class ChannelManager {
|
|
|
501
547
|
waChunkBuf += eventData.token;
|
|
502
548
|
}
|
|
503
549
|
|
|
550
|
+
// Use dynamic reply target (self-chat or contact's chat depending on latest push)
|
|
551
|
+
const target = this.waReplyTarget;
|
|
552
|
+
if (!target) return;
|
|
553
|
+
|
|
504
554
|
// Agent paused to use a tool — send accumulated text as an intermediate WhatsApp message
|
|
505
555
|
if (type === 'bot:tool' && waChunkBuf.trim()) {
|
|
506
|
-
this.sendMessage(
|
|
556
|
+
this.sendMessage(target.channel, target.rawSender, this.formatBotReply(waChunkBuf.trim(), botName)).catch((err) => {
|
|
507
557
|
log.warn(`[channels] Failed to send WhatsApp chunk: ${err.message}`);
|
|
508
558
|
});
|
|
509
559
|
waChunkBuf = '';
|
|
510
560
|
}
|
|
511
561
|
|
|
512
562
|
if (type === 'bot:response' && eventData.content) {
|
|
513
|
-
// Send remaining text
|
|
563
|
+
// Send remaining text to the correct chat
|
|
514
564
|
const remaining = waChunkBuf.trim();
|
|
515
565
|
if (remaining) {
|
|
516
|
-
this.sendMessage(
|
|
566
|
+
this.sendMessage(target.channel, target.rawSender, this.formatBotReply(remaining, botName)).catch((err) => {
|
|
517
567
|
log.warn(`[channels] Failed to send WhatsApp reply: ${err.message}`);
|
|
518
568
|
});
|
|
519
569
|
waChunkBuf = '';
|
|
520
570
|
}
|
|
521
571
|
|
|
572
|
+
// If this was an assistant response, store in the contact's context buffer
|
|
573
|
+
if (target.assistantBufferKey) {
|
|
574
|
+
const buf = this.customerBuffers.get(target.assistantBufferKey);
|
|
575
|
+
if (buf) {
|
|
576
|
+
buf.push({ role: 'assistant', content: eventData.content });
|
|
577
|
+
if (buf.length > MAX_BUFFER_MESSAGES) buf.splice(0, buf.length - MAX_BUFFER_MESSAGES);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
522
581
|
// Save response to DB
|
|
523
582
|
workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
524
583
|
role: 'assistant',
|
|
@@ -540,6 +599,13 @@ export class ChannelManager {
|
|
|
540
599
|
}, { botName, humanName }, recentMessages);
|
|
541
600
|
}
|
|
542
601
|
|
|
602
|
+
// Set reply target BEFORE pushing — callback reads this to know where to send
|
|
603
|
+
this.waReplyTarget = {
|
|
604
|
+
channel: msg.channel,
|
|
605
|
+
rawSender: msg.rawSender,
|
|
606
|
+
assistantBufferKey: msg.role === 'assistant' ? `${msg.channel}:${msg.sender}` : undefined,
|
|
607
|
+
};
|
|
608
|
+
|
|
543
609
|
// Push the message into the live conversation
|
|
544
610
|
const channelContent = channelContext + msg.text;
|
|
545
611
|
pushMessage(convId, channelContent, agentAttachments);
|
|
@@ -702,146 +768,6 @@ export class ChannelManager {
|
|
|
702
768
|
log.info(`[channels] Assistant context stored: ${bufferKey} | ${buffer.length} msgs | [${label}]: "${text.slice(0, 60)}"`);
|
|
703
769
|
}
|
|
704
770
|
|
|
705
|
-
/** Handle a triggered assistant message — runs one-shot agent with conversation context */
|
|
706
|
-
private async handleAssistantMessage(msg: InboundMessage, channelConfig: ChannelConfig) {
|
|
707
|
-
const agentKey = `${msg.channel}:${msg.sender}`;
|
|
708
|
-
|
|
709
|
-
// Check concurrent limit
|
|
710
|
-
if (this.activeAgents.size >= MAX_CONCURRENT_AGENTS && !this.activeAgents.has(agentKey)) {
|
|
711
|
-
log.info(`[channels] Max concurrent agents reached — queuing assistant message for ${msg.sender}`);
|
|
712
|
-
this.messageQueue.push(msg);
|
|
713
|
-
return;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
const { workerApi, getModel } = this.opts;
|
|
717
|
-
const model = getModel();
|
|
718
|
-
|
|
719
|
-
// Extract command after trigger: "Ok.\n\n@bloby: do X" → "do X"
|
|
720
|
-
const config = loadConfig();
|
|
721
|
-
const botName = config.username || 'bloby';
|
|
722
|
-
const triggerRegex = new RegExp(`@${botName}[:\\s]+`, 'i');
|
|
723
|
-
const triggerMatch = msg.text.match(triggerRegex);
|
|
724
|
-
let cleanText = msg.text;
|
|
725
|
-
if (triggerMatch && triggerMatch.index !== undefined) {
|
|
726
|
-
cleanText = msg.text.slice(triggerMatch.index + triggerMatch[0].length).trim();
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
// Load SCRIPT.md from configured skill
|
|
730
|
-
const scriptPrompt = this.loadActiveScript(channelConfig);
|
|
731
|
-
|
|
732
|
-
// Fetch agent name
|
|
733
|
-
let agentBotName = 'Bloby', humanName = 'Human';
|
|
734
|
-
try {
|
|
735
|
-
const status = await workerApi('/api/onboard/status');
|
|
736
|
-
agentBotName = status.agentName || 'Bloby';
|
|
737
|
-
humanName = status.userName || 'Human';
|
|
738
|
-
} catch {}
|
|
739
|
-
|
|
740
|
-
// Get conversation buffer (already populated by storeAssistantContext)
|
|
741
|
-
let buffer = this.customerBuffers.get(agentKey);
|
|
742
|
-
if (!buffer) {
|
|
743
|
-
buffer = [];
|
|
744
|
-
this.customerBuffers.set(agentKey, buffer);
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
log.info(`[channels] Assistant trigger: agentKey=${agentKey} | buffer=${buffer.length} msgs | cleanText="${cleanText.slice(0, 80)}"`);
|
|
748
|
-
if (buffer.length > 0) {
|
|
749
|
-
log.info(`[channels] Assistant context preview: ${buffer.slice(-5).map((m) => `[${m.role}] ${m.content.slice(0, 50)}`).join(' | ')}`);
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
// All buffered messages are context for the agent
|
|
753
|
-
const recentMessages: RecentMessage[] = buffer.map((m) => ({
|
|
754
|
-
role: m.role,
|
|
755
|
-
content: m.content,
|
|
756
|
-
}));
|
|
757
|
-
|
|
758
|
-
// Load per-contact memory from the skill's customer_data directory
|
|
759
|
-
let contactMemory = '';
|
|
760
|
-
try {
|
|
761
|
-
const customerDataDir = this.getSkillCustomerDataDir(channelConfig);
|
|
762
|
-
if (customerDataDir) {
|
|
763
|
-
const memoryPath = path.join(WORKSPACE_DIR, customerDataDir, `${msg.sender}.md`);
|
|
764
|
-
if (fs.existsSync(memoryPath)) {
|
|
765
|
-
contactMemory = fs.readFileSync(memoryPath, 'utf-8').trim();
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
} catch {}
|
|
769
|
-
|
|
770
|
-
// Build enriched script with contact memory
|
|
771
|
-
let enrichedScript = scriptPrompt;
|
|
772
|
-
if (contactMemory && enrichedScript) {
|
|
773
|
-
enrichedScript += `\n\n---\n# Contact History (${msg.sender})\n\n${contactMemory}`;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
const channelContext = `[WhatsApp | ${msg.sender} | assistant${msg.senderName ? ` | ${msg.senderName}` : ''}]\n`;
|
|
777
|
-
|
|
778
|
-
// Convert inbound attachments to agent format
|
|
779
|
-
const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({
|
|
780
|
-
type: 'image' as const,
|
|
781
|
-
name: `whatsapp_image.${att.mediaType.split('/')[1] || 'jpg'}`,
|
|
782
|
-
mediaType: att.mediaType,
|
|
783
|
-
data: att.data,
|
|
784
|
-
}));
|
|
785
|
-
|
|
786
|
-
// Stable convId per contact
|
|
787
|
-
const convId = `channel-${agentKey}`;
|
|
788
|
-
|
|
789
|
-
this.activeAgents.set(agentKey, { sender: msg.sender, channel: msg.channel });
|
|
790
|
-
|
|
791
|
-
// Show "typing..." while the agent processes
|
|
792
|
-
this.startTyping(msg.channel, msg.rawSender);
|
|
793
|
-
|
|
794
|
-
// Track text chunks for WhatsApp
|
|
795
|
-
let waChunkBuf = '';
|
|
796
|
-
|
|
797
|
-
startBlobyAgentQuery(
|
|
798
|
-
convId,
|
|
799
|
-
channelContext + cleanText,
|
|
800
|
-
model,
|
|
801
|
-
(type, eventData) => {
|
|
802
|
-
// Accumulate text tokens
|
|
803
|
-
if (type === 'bot:token' && eventData.token) {
|
|
804
|
-
waChunkBuf += eventData.token;
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
// Agent paused to use a tool — send accumulated text as intermediate message
|
|
808
|
-
if (type === 'bot:tool' && waChunkBuf.trim()) {
|
|
809
|
-
this.sendMessage(msg.channel, msg.rawSender, this.formatBotReply(waChunkBuf.trim(), agentBotName)).catch((err) => {
|
|
810
|
-
log.warn(`[channels] Failed to send assistant chunk: ${err.message}`);
|
|
811
|
-
});
|
|
812
|
-
waChunkBuf = '';
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
if (type === 'bot:response' && eventData.content) {
|
|
816
|
-
// Add response to buffer for continuity across triggers
|
|
817
|
-
buffer!.push({ role: 'assistant', content: eventData.content });
|
|
818
|
-
if (buffer!.length > MAX_BUFFER_MESSAGES) {
|
|
819
|
-
buffer!.splice(0, buffer!.length - MAX_BUFFER_MESSAGES);
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
// Send remaining text
|
|
823
|
-
const remaining = waChunkBuf.trim();
|
|
824
|
-
if (remaining) {
|
|
825
|
-
this.sendMessage(msg.channel, msg.rawSender, this.formatBotReply(remaining, agentBotName)).catch((err) => {
|
|
826
|
-
log.warn(`[channels] Failed to send assistant reply: ${err.message}`);
|
|
827
|
-
});
|
|
828
|
-
waChunkBuf = '';
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
if (type === 'bot:done') {
|
|
833
|
-
this.activeAgents.delete(agentKey);
|
|
834
|
-
if (eventData.usedFileTools) this.opts.restartBackend();
|
|
835
|
-
this.processQueue();
|
|
836
|
-
}
|
|
837
|
-
},
|
|
838
|
-
agentAttachments,
|
|
839
|
-
undefined,
|
|
840
|
-
{ botName: agentBotName, humanName },
|
|
841
|
-
recentMessages,
|
|
842
|
-
enrichedScript,
|
|
843
|
-
);
|
|
844
|
-
}
|
|
845
771
|
|
|
846
772
|
/** Transcribe audio via the existing whisper endpoint */
|
|
847
773
|
private async transcribeAudio(audioBase64: string): Promise<string | null> {
|
|
@@ -29,8 +29,10 @@ export interface InboundMessage {
|
|
|
29
29
|
senderName?: string;
|
|
30
30
|
/** Resolved role of the sender */
|
|
31
31
|
role: SenderRole;
|
|
32
|
-
/** Message text content */
|
|
32
|
+
/** Message text content (may include enriched context for assistant triggers) */
|
|
33
33
|
text: string;
|
|
34
|
+
/** Display-friendly text for DB/chat UI (when text contains enriched agent context) */
|
|
35
|
+
displayText?: string;
|
|
34
36
|
/** Raw sender JID (channel-specific format, used for replies) */
|
|
35
37
|
rawSender: string;
|
|
36
38
|
/** Image attachments */
|
package/supervisor/index.ts
CHANGED
|
@@ -11,7 +11,8 @@ import { log } from '../shared/logger.js';
|
|
|
11
11
|
import { startTunnel, stopTunnel, isTunnelAlive, restartTunnel, startNamedTunnel, restartNamedTunnel } from './tunnel.js';
|
|
12
12
|
import { createWorkerApp } from '../worker/index.js';
|
|
13
13
|
import { closeDb, getSession, getSetting } from '../worker/db.js';
|
|
14
|
-
import { spawnBackend, stopBackend, getBackendPort, isBackendAlive, isBackendStopping, resetBackendRestarts } from './backend.js';
|
|
14
|
+
import { spawnBackend, stopBackend, getBackendPort, isBackendAlive, isBackendStopping, resetBackendRestarts, setBackendEnv } from './backend.js';
|
|
15
|
+
import { handleAgentQuery, type AgentQueryRequest } from './agent-api.js';
|
|
15
16
|
import { updateTunnelUrl, startHeartbeat, stopHeartbeat, disconnect } from '../shared/relay.js';
|
|
16
17
|
import {
|
|
17
18
|
startConversation, pushMessage, hasConversation, endConversation, endAllConversations,
|
|
@@ -237,6 +238,13 @@ export async function startSupervisor() {
|
|
|
237
238
|
const config = loadConfig();
|
|
238
239
|
const backendPort = getBackendPort(config.port);
|
|
239
240
|
const internalSecret = crypto.randomBytes(16).toString('hex');
|
|
241
|
+
const agentSecret = crypto.randomBytes(32).toString('hex');
|
|
242
|
+
|
|
243
|
+
// Inject agent secret + supervisor port into workspace backend env
|
|
244
|
+
setBackendEnv({
|
|
245
|
+
BLOBY_AGENT_SECRET: agentSecret,
|
|
246
|
+
SUPERVISOR_PORT: String(config.port),
|
|
247
|
+
});
|
|
240
248
|
|
|
241
249
|
// Kill any stale processes from previous crashes/updates
|
|
242
250
|
log.info(`[startup] Clearing ports ${config.port}, ${config.port + 2}, ${backendPort}...`);
|
|
@@ -803,6 +811,57 @@ ${!connected ? `<script>
|
|
|
803
811
|
return;
|
|
804
812
|
}
|
|
805
813
|
|
|
814
|
+
// ── Agent API — SDK gateway for workspace code ──
|
|
815
|
+
if (req.method === 'POST' && req.url?.startsWith('/api/agent/')) {
|
|
816
|
+
const agentPath = req.url.split('?')[0];
|
|
817
|
+
res.setHeader('Content-Type', 'application/json');
|
|
818
|
+
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
819
|
+
|
|
820
|
+
// Localhost-only guard
|
|
821
|
+
const remoteIp = req.socket.remoteAddress || '';
|
|
822
|
+
if (remoteIp !== '127.0.0.1' && remoteIp !== '::1' && remoteIp !== '::ffff:127.0.0.1') {
|
|
823
|
+
res.writeHead(403);
|
|
824
|
+
res.end(JSON.stringify({ ok: false, error: 'Agent API is localhost-only.' }));
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Auth: x-agent-secret header
|
|
829
|
+
if (req.headers['x-agent-secret'] !== agentSecret) {
|
|
830
|
+
res.writeHead(401);
|
|
831
|
+
res.end(JSON.stringify({ ok: false, error: 'Invalid or missing x-agent-secret header.' }));
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (agentPath === '/api/agent/query') {
|
|
836
|
+
let body = '';
|
|
837
|
+
req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
|
|
838
|
+
req.on('end', async () => {
|
|
839
|
+
try {
|
|
840
|
+
const parsed = JSON.parse(body) as AgentQueryRequest;
|
|
841
|
+
const result = await handleAgentQuery(parsed);
|
|
842
|
+
|
|
843
|
+
// If the agent wrote files, restart backend + broadcast HMR
|
|
844
|
+
if (result.usedFileTools) {
|
|
845
|
+
resetBackendRestarts();
|
|
846
|
+
stopBackend().then(() => spawnBackend(backendPort));
|
|
847
|
+
broadcastBloby('app:hmr-update', {});
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
res.writeHead(result.ok ? 200 : 400);
|
|
851
|
+
res.end(JSON.stringify(result));
|
|
852
|
+
} catch (err: any) {
|
|
853
|
+
res.writeHead(500);
|
|
854
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
res.writeHead(404);
|
|
861
|
+
res.end(JSON.stringify({ ok: false, error: 'Unknown agent endpoint.' }));
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
|
|
806
865
|
// API routes → handled in-process by worker Express app
|
|
807
866
|
if (req.url?.startsWith('/api')) {
|
|
808
867
|
// Internal supervisor calls (workerApi) bypass auth — they carry a per-process secret
|
|
@@ -300,7 +300,7 @@ GET | `/api/marketplace/balance/bot` | Bearer (required) | Check your owner's cr
|
|
|
300
300
|
|
|
301
301
|
Agents will soon have a USDC wallet for autonomous purchases. You'll be able to check your balance and buy skills directly without a redeem code. This is not yet available — for now, paid skills require your human to purchase from the web marketplace and give you a redeem code.
|
|
302
302
|
|
|
303
|
-
For a machine-readable catalog: `GET https://bloby.bot/api/marketplace/products`
|
|
303
|
+
For a machine-readable catalog: `GET https://bloby.bot/api/marketplace/products` — **always include your relay token** (`Authorization: Bearer $RELAY_TOKEN`) on every request to the relay API, even public endpoints. This enables your presence in Bloby World so other users can see your bloby on the world map.
|
|
304
304
|
|
|
305
305
|
---
|
|
306
306
|
|