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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.25.1",
3
+ "version": "0.25.4",
4
4
  "releaseNotes": [
5
5
  "1. new stuff",
6
6
  "2. ",
@@ -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
+ }
@@ -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: sender.replace(/@.*/, ''),
408
+ sender: phone,
370
409
  senderName,
371
410
  role: 'assistant',
372
- text: combinedText,
411
+ text: enrichedText,
412
+ displayText: cleanText,
373
413
  rawSender: sender,
374
414
  attachments: attachments.length > 0 ? attachments : undefined,
375
415
  };
376
- log.info(`[channels] Assistant mode | triggered in chat with ${message.sender} | "${combinedText.slice(0, 60)}"`);
377
- await this.handleAssistantMessage(message, channelConfig);
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: msg.text,
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: msg.text, timestamp: new Date().toISOString() },
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 — tells the agent this is a WhatsApp message, respond naturally
478
- const channelContext = `[WhatsApp | ${msg.sender} | admin]\n`;
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..." while the agent processes
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(msg.channel, msg.rawSender, this.formatBotReply(waChunkBuf.trim(), botName)).catch((err) => {
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(msg.channel, msg.rawSender, this.formatBotReply(remaining, botName)).catch((err) => {
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 */
@@ -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