vellum 0.2.0 → 0.2.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.
Files changed (80) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +28 -0
  3. package/src/__tests__/app-bundler.test.ts +12 -33
  4. package/src/__tests__/browser-skill-endstate.test.ts +1 -5
  5. package/src/__tests__/call-orchestrator.test.ts +328 -0
  6. package/src/__tests__/call-state.test.ts +133 -0
  7. package/src/__tests__/call-store.test.ts +476 -0
  8. package/src/__tests__/commit-message-enrichment-service.test.ts +409 -0
  9. package/src/__tests__/config-schema.test.ts +49 -0
  10. package/src/__tests__/doordash-session.test.ts +9 -0
  11. package/src/__tests__/ipc-snapshot.test.ts +34 -0
  12. package/src/__tests__/registry.test.ts +13 -8
  13. package/src/__tests__/run-orchestrator-assistant-events.test.ts +218 -0
  14. package/src/__tests__/run-orchestrator.test.ts +3 -3
  15. package/src/__tests__/runtime-attachment-metadata.test.ts +17 -19
  16. package/src/__tests__/runtime-runs-http.test.ts +1 -19
  17. package/src/__tests__/runtime-runs.test.ts +7 -7
  18. package/src/__tests__/session-queue.test.ts +50 -0
  19. package/src/__tests__/turn-commit.test.ts +56 -0
  20. package/src/__tests__/workspace-git-service.test.ts +217 -0
  21. package/src/__tests__/workspace-heartbeat-service.test.ts +129 -0
  22. package/src/bundler/app-bundler.ts +29 -12
  23. package/src/calls/call-constants.ts +10 -0
  24. package/src/calls/call-orchestrator.ts +364 -0
  25. package/src/calls/call-state.ts +64 -0
  26. package/src/calls/call-store.ts +229 -0
  27. package/src/calls/relay-server.ts +298 -0
  28. package/src/calls/twilio-config.ts +34 -0
  29. package/src/calls/twilio-provider.ts +169 -0
  30. package/src/calls/twilio-routes.ts +236 -0
  31. package/src/calls/types.ts +37 -0
  32. package/src/calls/voice-provider.ts +14 -0
  33. package/src/cli/doordash.ts +5 -24
  34. package/src/config/bundled-skills/doordash/SKILL.md +104 -0
  35. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
  36. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -1
  37. package/src/config/defaults.ts +11 -0
  38. package/src/config/schema.ts +57 -0
  39. package/src/config/system-prompt.ts +50 -1
  40. package/src/config/types.ts +1 -0
  41. package/src/daemon/handlers/config.ts +30 -0
  42. package/src/daemon/handlers/index.ts +6 -0
  43. package/src/daemon/handlers/work-items.ts +142 -2
  44. package/src/daemon/ipc-contract-inventory.json +12 -0
  45. package/src/daemon/ipc-contract.ts +52 -0
  46. package/src/daemon/lifecycle.ts +27 -5
  47. package/src/daemon/server.ts +10 -12
  48. package/src/daemon/session-tool-setup.ts +6 -0
  49. package/src/daemon/session.ts +40 -1
  50. package/src/index.ts +2 -0
  51. package/src/media/gemini-image-service.ts +1 -1
  52. package/src/memory/db.ts +266 -0
  53. package/src/memory/schema.ts +42 -0
  54. package/src/runtime/http-server.ts +189 -25
  55. package/src/runtime/http-types.ts +0 -2
  56. package/src/runtime/routes/attachment-routes.ts +6 -6
  57. package/src/runtime/routes/channel-routes.ts +16 -18
  58. package/src/runtime/routes/conversation-routes.ts +5 -9
  59. package/src/runtime/routes/run-routes.ts +4 -8
  60. package/src/runtime/run-orchestrator.ts +32 -5
  61. package/src/tools/calls/call-end.ts +117 -0
  62. package/src/tools/calls/call-start.ts +134 -0
  63. package/src/tools/calls/call-status.ts +97 -0
  64. package/src/tools/credentials/vault.ts +1 -1
  65. package/src/tools/registry.ts +2 -4
  66. package/src/tools/tasks/index.ts +2 -0
  67. package/src/tools/tasks/task-delete.ts +49 -8
  68. package/src/tools/tasks/task-run.ts +9 -1
  69. package/src/tools/tasks/work-item-enqueue.ts +93 -3
  70. package/src/tools/tasks/work-item-list.ts +10 -25
  71. package/src/tools/tasks/work-item-remove.ts +112 -0
  72. package/src/tools/tasks/work-item-update.ts +186 -0
  73. package/src/tools/tool-manifest.ts +39 -31
  74. package/src/tools/ui-surface/definitions.ts +3 -0
  75. package/src/work-items/work-item-store.ts +209 -0
  76. package/src/workspace/commit-message-enrichment-service.ts +260 -0
  77. package/src/workspace/commit-message-provider.ts +95 -0
  78. package/src/workspace/git-service.ts +187 -32
  79. package/src/workspace/heartbeat-service.ts +70 -13
  80. package/src/workspace/turn-commit.ts +39 -49
@@ -10,6 +10,8 @@ import { resolve } from 'node:path';
10
10
  import { timingSafeEqual } from 'node:crypto';
11
11
  import { ConfigError, IngressBlockedError } from '../util/errors.js';
12
12
  import { getLogger } from '../util/logger.js';
13
+ import { getSecureKey } from '../security/secure-keys.js';
14
+ import { TwilioConversationRelayProvider } from '../calls/twilio-provider.js';
13
15
  import type { RunOrchestrator } from './run-orchestrator.js';
14
16
 
15
17
  // Route handlers — grouped by domain
@@ -45,6 +47,14 @@ import {
45
47
  handleDeleteSharedApp,
46
48
  } from './routes/app-routes.js';
47
49
  import { handleAddSecret } from './routes/secret-routes.js';
50
+ import {
51
+ handleVoiceWebhook,
52
+ handleStatusCallback,
53
+ handleConnectAction,
54
+ handleCallAnswer,
55
+ } from '../calls/twilio-routes.js';
56
+ import { RelayConnection, activeRelayConnections } from '../calls/relay-server.js';
57
+ import type { RelayWebSocketData } from '../calls/relay-server.js';
48
58
 
49
59
  // Re-export shared types so existing consumers don't need to update imports
50
60
  export type {
@@ -95,6 +105,82 @@ function getDiskSpaceInfo(): DiskSpaceInfo | null {
95
105
  }
96
106
  }
97
107
 
108
+ /**
109
+ * Regex to extract the Twilio webhook subpath from both top-level and
110
+ * assistant-scoped route shapes:
111
+ * /v1/calls/twilio/<subpath>
112
+ * /v1/assistants/<id>/calls/twilio/<subpath>
113
+ */
114
+ const TWILIO_WEBHOOK_RE = /^\/v1\/(?:assistants\/[^/]+\/)?calls\/twilio\/(.+)$/;
115
+
116
+ /**
117
+ * Validate a Twilio webhook request's X-Twilio-Signature header.
118
+ *
119
+ * Returns the raw body text on success so callers can reconstruct the Request
120
+ * for downstream handlers (which also need to read the body).
121
+ * Returns a 403 Response if signature validation fails.
122
+ * If the Twilio auth token is not configured, skips validation with a warning.
123
+ */
124
+ async function validateTwilioWebhook(
125
+ req: Request,
126
+ ): Promise<{ body: string } | Response> {
127
+ const rawBody = await req.text();
128
+ const authToken = getSecureKey('twilio_auth_token');
129
+
130
+ if (!authToken) {
131
+ log.warn('Twilio auth token not configured — skipping webhook signature validation');
132
+ return { body: rawBody };
133
+ }
134
+
135
+ const signature = req.headers.get('x-twilio-signature');
136
+ if (!signature) {
137
+ log.warn('Twilio webhook request missing X-Twilio-Signature header');
138
+ return Response.json({ error: 'Forbidden' }, { status: 403 });
139
+ }
140
+
141
+ // Parse form-urlencoded body into key-value params for signature computation
142
+ const params: Record<string, string> = {};
143
+ const formData = new URLSearchParams(rawBody);
144
+ for (const [key, value] of formData.entries()) {
145
+ params[key] = value;
146
+ }
147
+
148
+ // Reconstruct the public-facing URL that Twilio signed against.
149
+ // Behind proxies/gateways, req.url is the local server URL (e.g.
150
+ // http://127.0.0.1:7821/...) which differs from the public URL Twilio
151
+ // used to compute the HMAC-SHA1 signature.
152
+ const publicBaseUrl = process.env.TWILIO_WEBHOOK_BASE_URL;
153
+ const parsedUrl = new URL(req.url);
154
+ const publicUrl = publicBaseUrl
155
+ ? publicBaseUrl.replace(/\/$/, '') + parsedUrl.pathname + parsedUrl.search
156
+ : req.url;
157
+
158
+ const isValid = TwilioConversationRelayProvider.verifyWebhookSignature(
159
+ publicUrl,
160
+ params,
161
+ signature,
162
+ );
163
+
164
+ if (!isValid) {
165
+ log.warn('Twilio webhook signature validation failed');
166
+ return Response.json({ error: 'Forbidden' }, { status: 403 });
167
+ }
168
+
169
+ return { body: rawBody };
170
+ }
171
+
172
+ /**
173
+ * Re-create a Request with the same method, headers, and URL but with a
174
+ * pre-read body string so downstream handlers can call req.text() again.
175
+ */
176
+ function cloneRequestWithBody(original: Request, body: string): Request {
177
+ return new Request(original.url, {
178
+ method: original.method,
179
+ headers: original.headers,
180
+ body,
181
+ });
182
+ }
183
+
98
184
  export class RuntimeHttpServer {
99
185
  private server: ReturnType<typeof Bun.serve> | null = null;
100
186
  private port: number;
@@ -120,11 +206,37 @@ export class RuntimeHttpServer {
120
206
  }
121
207
 
122
208
  async start(): Promise<void> {
123
- this.server = Bun.serve({
209
+ this.server = Bun.serve<RelayWebSocketData>({
124
210
  port: this.port,
125
211
  hostname: this.hostname,
126
212
  maxRequestBodySize: MAX_REQUEST_BODY_BYTES,
127
- fetch: (req) => this.handleRequest(req),
213
+ fetch: (req, server) => this.handleRequest(req, server),
214
+ websocket: {
215
+ open(ws) {
216
+ const callSessionId = ws.data?.callSessionId;
217
+ log.info({ callSessionId }, 'ConversationRelay WebSocket opened');
218
+ if (callSessionId) {
219
+ const connection = new RelayConnection(ws, callSessionId);
220
+ activeRelayConnections.set(callSessionId, connection);
221
+ }
222
+ },
223
+ message(ws, message) {
224
+ const callSessionId = ws.data?.callSessionId;
225
+ if (callSessionId) {
226
+ const connection = activeRelayConnections.get(callSessionId);
227
+ connection?.handleMessage(typeof message === 'string' ? message : new TextDecoder().decode(message));
228
+ }
229
+ },
230
+ close(ws, code, reason) {
231
+ const callSessionId = ws.data?.callSessionId;
232
+ log.info({ callSessionId, code, reason: reason?.toString() }, 'ConversationRelay WebSocket closed');
233
+ if (callSessionId) {
234
+ const connection = activeRelayConnections.get(callSessionId);
235
+ connection?.destroy();
236
+ activeRelayConnections.delete(callSessionId);
237
+ }
238
+ },
239
+ },
128
240
  });
129
241
 
130
242
  // Sweep failed channel inbound events for retry every 30 seconds
@@ -162,7 +274,7 @@ export class RuntimeHttpServer {
162
274
  return timingSafeEqual(a, b);
163
275
  }
164
276
 
165
- private async handleRequest(req: Request): Promise<Response> {
277
+ private async handleRequest(req: Request, server: ReturnType<typeof Bun.serve>): Promise<Response> {
166
278
  const url = new URL(req.url);
167
279
  const path = url.pathname;
168
280
 
@@ -171,6 +283,49 @@ export class RuntimeHttpServer {
171
283
  return this.handleHealth();
172
284
  }
173
285
 
286
+ // WebSocket upgrade for ConversationRelay — before auth check because
287
+ // Twilio WebSocket connections don't use bearer tokens.
288
+ if (path.startsWith('/v1/calls/relay') && req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
289
+ const wsUrl = new URL(req.url);
290
+ const callSessionId = wsUrl.searchParams.get('callSessionId');
291
+ if (!callSessionId) {
292
+ return new Response('Missing callSessionId', { status: 400 });
293
+ }
294
+ const upgraded = server.upgrade(req, { data: { callSessionId } });
295
+ if (!upgraded) {
296
+ return new Response('WebSocket upgrade failed', { status: 500 });
297
+ }
298
+ // Bun handles the response after a successful upgrade.
299
+ // The RelayConnection is created in the websocket.open handler.
300
+ return undefined as unknown as Response;
301
+ }
302
+
303
+ // ── Twilio webhook endpoints — before auth check because Twilio
304
+ // webhook POSTs don't include bearer tokens.
305
+ // Supports both /v1/calls/twilio/* and /v1/assistants/:id/calls/twilio/*
306
+ // Validates X-Twilio-Signature to prevent unauthorized access. ──
307
+ const twilioMatch = path.match(TWILIO_WEBHOOK_RE);
308
+ if (twilioMatch && req.method === 'POST') {
309
+ const twilioSubpath = twilioMatch[1];
310
+
311
+ // Validate Twilio request signature before dispatching
312
+ const validation = await validateTwilioWebhook(req);
313
+ if (validation instanceof Response) return validation;
314
+
315
+ // Reconstruct request so handlers can read the body
316
+ const validatedReq = cloneRequestWithBody(req, validation.body);
317
+
318
+ if (twilioSubpath === 'voice-webhook') {
319
+ return await handleVoiceWebhook(validatedReq);
320
+ }
321
+ if (twilioSubpath === 'status') {
322
+ return await handleStatusCallback(validatedReq);
323
+ }
324
+ if (twilioSubpath === 'connect-action') {
325
+ return await handleConnectAction(validatedReq);
326
+ }
327
+ }
328
+
174
329
  // Require bearer token when configured
175
330
  if ((process.env.DISABLE_HTTP_AUTH ?? "").toLowerCase() !== "true" && this.bearerToken) {
176
331
  const authHeader = req.headers.get('authorization');
@@ -180,6 +335,17 @@ export class RuntimeHttpServer {
180
335
  }
181
336
  }
182
337
 
338
+ // ── Call answer endpoint — behind auth gate ──────────────────────
339
+ const callAnswerMatch = path.match(/^\/v1\/calls\/([^/]+)\/answer$/);
340
+ if (callAnswerMatch && req.method === 'POST') {
341
+ try {
342
+ return await handleCallAnswer(req, callAnswerMatch[1]);
343
+ } catch (err) {
344
+ log.error({ err, callSessionId: callAnswerMatch[1] }, 'Runtime HTTP handler error answering call');
345
+ return Response.json({ error: 'Internal server error' }, { status: 500 });
346
+ }
347
+ }
348
+
183
349
  // Serve shareable app pages
184
350
  const pagesMatch = path.match(/^\/pages\/([^/]+)$/);
185
351
  if (pagesMatch && req.method === 'GET') {
@@ -247,7 +413,7 @@ export class RuntimeHttpServer {
247
413
  // Paths already handled above (/v1/apps/..., /v1/secrets) will never reach here.
248
414
  const newRouteMatch = path.match(/^\/v1\/(?!assistants\/)(.+)$/);
249
415
  if (newRouteMatch) {
250
- return this.dispatchEndpoint('local-assistant', newRouteMatch[1], req, url);
416
+ return this.dispatchEndpoint(newRouteMatch[1], req, url);
251
417
  }
252
418
 
253
419
  // Legacy: /v1/assistants/:assistantId/<endpoint>
@@ -259,7 +425,7 @@ export class RuntimeHttpServer {
259
425
  const assistantId = match[1];
260
426
  const endpoint = match[2];
261
427
  log.warn({ endpoint, assistantId }, '[deprecated] /v1/assistants/:assistantId/... route used; migrate to /v1/...');
262
- return this.dispatchEndpoint(assistantId, endpoint, req, url);
428
+ return this.dispatchEndpoint(endpoint, req, url);
263
429
  }
264
430
 
265
431
  /**
@@ -268,7 +434,6 @@ export class RuntimeHttpServer {
268
434
  * legacy assistant-scoped routes (/v1/assistants/:assistantId/<endpoint>).
269
435
  */
270
436
  private async dispatchEndpoint(
271
- assistantId: string,
272
437
  endpoint: string,
273
438
  req: Request,
274
439
  url: URL,
@@ -279,32 +444,32 @@ export class RuntimeHttpServer {
279
444
  }
280
445
 
281
446
  if (endpoint === 'messages' && req.method === 'GET') {
282
- return handleListMessages(assistantId, url, this.interfacesDir);
447
+ return handleListMessages(url, this.interfacesDir);
283
448
  }
284
449
 
285
450
  if (endpoint === 'messages' && req.method === 'POST') {
286
- return await handleSendMessage(assistantId, req, {
451
+ return await handleSendMessage(req, {
287
452
  processMessage: this.processMessage,
288
453
  persistAndProcessMessage: this.persistAndProcessMessage,
289
454
  });
290
455
  }
291
456
 
292
457
  if (endpoint === 'attachments' && req.method === 'POST') {
293
- return await handleUploadAttachment(assistantId, req);
458
+ return await handleUploadAttachment(req);
294
459
  }
295
460
 
296
461
  if (endpoint === 'attachments' && req.method === 'DELETE') {
297
- return await handleDeleteAttachment(assistantId, req);
462
+ return await handleDeleteAttachment(req);
298
463
  }
299
464
 
300
465
  // Match attachments/:attachmentId
301
466
  const attachmentMatch = endpoint.match(/^attachments\/([^/]+)$/);
302
467
  if (attachmentMatch && req.method === 'GET') {
303
- return handleGetAttachment(assistantId, attachmentMatch[1]);
468
+ return handleGetAttachment(attachmentMatch[1]);
304
469
  }
305
470
 
306
471
  if (endpoint === 'suggestion' && req.method === 'GET') {
307
- return await handleGetSuggestion(assistantId, url, {
472
+ return await handleGetSuggestion(url, {
308
473
  suggestionCache: this.suggestionCache,
309
474
  suggestionInFlight: this.suggestionInFlight,
310
475
  });
@@ -314,7 +479,7 @@ export class RuntimeHttpServer {
314
479
  if (!this.runOrchestrator) {
315
480
  return Response.json({ error: 'Run orchestration not configured' }, { status: 503 });
316
481
  }
317
- return await handleCreateRun(assistantId, req, this.runOrchestrator);
482
+ return await handleCreateRun(req, this.runOrchestrator);
318
483
  }
319
484
 
320
485
  // Match runs/:runId, runs/:runId/decision, runs/:runId/trust-rule
@@ -325,17 +490,17 @@ export class RuntimeHttpServer {
325
490
  }
326
491
  const runId = runsMatch[1];
327
492
  if (runsMatch[2] === '/decision' && req.method === 'POST') {
328
- return await handleRunDecision(assistantId, runId, req, this.runOrchestrator);
493
+ return await handleRunDecision(runId, req, this.runOrchestrator);
329
494
  }
330
495
  if (runsMatch[2] === '/trust-rule' && req.method === 'POST') {
331
496
  const run = this.runOrchestrator.getRun(runId);
332
- if (!run || run.assistantId !== assistantId) {
497
+ if (!run) {
333
498
  return Response.json({ error: 'Run not found' }, { status: 404 });
334
499
  }
335
500
  return await handleAddTrustRule(runId, req);
336
501
  }
337
502
  if (req.method === 'GET') {
338
- return handleGetRun(assistantId, runId, this.runOrchestrator);
503
+ return handleGetRun(runId, this.runOrchestrator);
339
504
  }
340
505
  }
341
506
 
@@ -345,36 +510,36 @@ export class RuntimeHttpServer {
345
510
  }
346
511
 
347
512
  if (endpoint === 'channels/conversation' && req.method === 'DELETE') {
348
- return await handleDeleteConversation(assistantId, req);
513
+ return await handleDeleteConversation(req);
349
514
  }
350
515
 
351
516
  if (endpoint === 'channels/inbound' && req.method === 'POST') {
352
- return await handleChannelInbound(assistantId, req, this.processMessage);
517
+ return await handleChannelInbound(req, this.processMessage);
353
518
  }
354
519
 
355
520
  if (endpoint === 'channels/delivery-ack' && req.method === 'POST') {
356
- return await handleChannelDeliveryAck(assistantId, req);
521
+ return await handleChannelDeliveryAck(req);
357
522
  }
358
523
 
359
524
  if (endpoint === 'channels/dead-letters' && req.method === 'GET') {
360
- return handleListDeadLetters(assistantId);
525
+ return handleListDeadLetters();
361
526
  }
362
527
 
363
528
  if (endpoint === 'channels/replay' && req.method === 'POST') {
364
- return await handleReplayDeadLetters(assistantId, req);
529
+ return await handleReplayDeadLetters(req);
365
530
  }
366
531
 
367
532
  return Response.json({ error: 'Not found', source: 'runtime' }, { status: 404 });
368
533
  } catch (err) {
369
534
  if (err instanceof IngressBlockedError) {
370
- log.warn({ endpoint, assistantId, detectedTypes: err.detectedTypes }, 'Blocked HTTP request containing secrets');
535
+ log.warn({ endpoint, detectedTypes: err.detectedTypes }, 'Blocked HTTP request containing secrets');
371
536
  return Response.json({ error: err.message, code: err.code }, { status: 422 });
372
537
  }
373
538
  if (err instanceof ConfigError) {
374
- log.warn({ err, endpoint, assistantId }, 'Runtime HTTP config error');
539
+ log.warn({ err, endpoint }, 'Runtime HTTP config error');
375
540
  return Response.json({ error: err.message, code: err.code }, { status: 422 });
376
541
  }
377
- log.error({ err, endpoint, assistantId }, 'Runtime HTTP handler error');
542
+ log.error({ err, endpoint }, 'Runtime HTTP handler error');
378
543
  const message = err instanceof Error ? err.message : 'Internal server error';
379
544
  return Response.json({ error: message }, { status: 500 });
380
545
  }
@@ -428,7 +593,6 @@ export class RuntimeHttpServer {
428
593
 
429
594
  try {
430
595
  const { messageId: userMessageId } = await this.processMessage(
431
- event.assistantId,
432
596
  event.conversationId,
433
597
  content,
434
598
  attachmentIds,
@@ -12,7 +12,6 @@ export interface RuntimeMessageSessionOptions {
12
12
  }
13
13
 
14
14
  export type MessageProcessor = (
15
- assistantId: string,
16
15
  conversationId: string,
17
16
  content: string,
18
17
  attachmentIds?: string[],
@@ -26,7 +25,6 @@ export type MessageProcessor = (
26
25
  * immediately.
27
26
  */
28
27
  export type NonBlockingMessageProcessor = (
29
- assistantId: string,
30
28
  conversationId: string,
31
29
  content: string,
32
30
  attachmentIds?: string[],
@@ -7,7 +7,7 @@ import { validateAttachmentUpload, AttachmentUploadError } from '../../memory/at
7
7
  /** 30 MB — base64-encoded 20 MB attachment ≈ 27 MB plus JSON wrapper overhead. */
8
8
  const MAX_UPLOAD_BODY_BYTES = 30 * 1024 * 1024;
9
9
 
10
- export async function handleUploadAttachment(assistantId: string, req: Request): Promise<Response> {
10
+ export async function handleUploadAttachment(req: Request): Promise<Response> {
11
11
  const rawBody = await req.arrayBuffer();
12
12
  if (rawBody.byteLength > MAX_UPLOAD_BODY_BYTES) {
13
13
  return Response.json(
@@ -56,7 +56,7 @@ export async function handleUploadAttachment(assistantId: string, req: Request):
56
56
  let attachment: attachmentsStore.StoredAttachment;
57
57
  try {
58
58
  attachment = attachmentsStore.uploadAttachment(
59
- assistantId,
59
+ "self",
60
60
  filename,
61
61
  mimeType,
62
62
  data,
@@ -78,7 +78,7 @@ export async function handleUploadAttachment(assistantId: string, req: Request):
78
78
  });
79
79
  }
80
80
 
81
- export async function handleDeleteAttachment(assistantId: string, req: Request): Promise<Response> {
81
+ export async function handleDeleteAttachment(req: Request): Promise<Response> {
82
82
  let body: { attachmentId?: string };
83
83
  try {
84
84
  body = await req.json() as { attachmentId?: string };
@@ -98,7 +98,7 @@ export async function handleDeleteAttachment(assistantId: string, req: Request):
98
98
  );
99
99
  }
100
100
 
101
- const result = attachmentsStore.deleteAttachment(assistantId, attachmentId);
101
+ const result = attachmentsStore.deleteAttachment("self", attachmentId);
102
102
 
103
103
  if (result === 'not_found') {
104
104
  return Response.json(
@@ -117,8 +117,8 @@ export async function handleDeleteAttachment(assistantId: string, req: Request):
117
117
  return new Response(null, { status: 204 });
118
118
  }
119
119
 
120
- export function handleGetAttachment(assistantId: string, attachmentId: string): Response {
121
- const attachment = attachmentsStore.getAttachmentById(assistantId, attachmentId);
120
+ export function handleGetAttachment(attachmentId: string): Response {
121
+ const attachment = attachmentsStore.getAttachmentById("self", attachmentId);
122
122
  if (!attachment) {
123
123
  return Response.json({ error: 'Attachment not found' }, { status: 404 });
124
124
  }
@@ -18,7 +18,7 @@ import type {
18
18
 
19
19
  const log = getLogger('runtime-http');
20
20
 
21
- export async function handleDeleteConversation(assistantId: string, req: Request): Promise<Response> {
21
+ export async function handleDeleteConversation(req: Request): Promise<Response> {
22
22
  const body = await req.json() as {
23
23
  sourceChannel?: string;
24
24
  externalChatId?: string;
@@ -34,13 +34,12 @@ export async function handleDeleteConversation(assistantId: string, req: Request
34
34
  }
35
35
 
36
36
  const conversationKey = `${sourceChannel}:${externalChatId}`;
37
- deleteConversationKey(assistantId, conversationKey);
37
+ deleteConversationKey("self", conversationKey);
38
38
 
39
39
  return Response.json({ ok: true });
40
40
  }
41
41
 
42
42
  export async function handleChannelInbound(
43
- assistantId: string,
44
43
  req: Request,
45
44
  processMessage?: MessageProcessor,
46
45
  ): Promise<Response> {
@@ -90,7 +89,7 @@ export async function handleChannelInbound(
90
89
  }
91
90
 
92
91
  if (hasAttachments) {
93
- const resolved = attachmentsStore.getAttachmentsByIds(assistantId, attachmentIds);
92
+ const resolved = attachmentsStore.getAttachmentsByIds("self", attachmentIds);
94
93
  if (resolved.length !== attachmentIds.length) {
95
94
  const resolvedIds = new Set(resolved.map((a) => a.id));
96
95
  const missing = attachmentIds.filter((id) => !resolvedIds.has(id));
@@ -113,7 +112,7 @@ export async function handleChannelInbound(
113
112
  if (isEdit && sourceMessageId) {
114
113
  // Dedup the edit event itself (retried edited_message webhooks)
115
114
  const editResult = channelDeliveryStore.recordInbound(
116
- assistantId,
115
+ "self",
117
116
  sourceChannel,
118
117
  externalChatId,
119
118
  externalMessageId,
@@ -137,7 +136,7 @@ export async function handleChannelInbound(
137
136
  let original: { messageId: string; conversationId: string } | null = null;
138
137
  for (let attempt = 0; attempt <= EDIT_LOOKUP_RETRIES; attempt++) {
139
138
  original = channelDeliveryStore.findMessageBySourceId(
140
- assistantId,
139
+ "self",
141
140
  sourceChannel,
142
141
  externalChatId,
143
142
  sourceMessageId,
@@ -145,7 +144,7 @@ export async function handleChannelInbound(
145
144
  if (original) break;
146
145
  if (attempt < EDIT_LOOKUP_RETRIES) {
147
146
  log.info(
148
- { assistantId, sourceMessageId, attempt: attempt + 1, maxAttempts: EDIT_LOOKUP_RETRIES },
147
+ { assistantId: "self", sourceMessageId, attempt: attempt + 1, maxAttempts: EDIT_LOOKUP_RETRIES },
149
148
  'Original message not linked yet, retrying edit lookup',
150
149
  );
151
150
  await new Promise((resolve) => setTimeout(resolve, EDIT_LOOKUP_DELAY_MS));
@@ -155,12 +154,12 @@ export async function handleChannelInbound(
155
154
  if (original) {
156
155
  conversationStore.updateMessageContent(original.messageId, content ?? '');
157
156
  log.info(
158
- { assistantId, sourceMessageId, messageId: original.messageId },
157
+ { assistantId: "self", sourceMessageId, messageId: original.messageId },
159
158
  'Updated message content from edited_message',
160
159
  );
161
160
  } else {
162
161
  log.warn(
163
- { assistantId, sourceChannel, externalChatId, sourceMessageId },
162
+ { assistantId: "self", sourceChannel, externalChatId, sourceMessageId },
164
163
  'Could not find original message for edit after retries, ignoring',
165
164
  );
166
165
  }
@@ -174,7 +173,7 @@ export async function handleChannelInbound(
174
173
 
175
174
  // ── New message path ──
176
175
  const result = channelDeliveryStore.recordInbound(
177
- assistantId,
176
+ "self",
178
177
  sourceChannel,
179
178
  externalChatId,
180
179
  externalMessageId,
@@ -221,7 +220,6 @@ export async function handleChannelInbound(
221
220
  }
222
221
 
223
222
  const { messageId: userMessageId } = await processMessage(
224
- assistantId,
225
223
  result.conversationId,
226
224
  content ?? '',
227
225
  hasAttachments ? attachmentIds : undefined,
@@ -259,7 +257,7 @@ export async function handleChannelInbound(
259
257
  try { parsed = JSON.parse(msgs[i].content); } catch { parsed = msgs[i].content; }
260
258
  const rendered = renderHistoryContent(parsed);
261
259
 
262
- const linked = attachmentsStore.getAttachmentMetadataForMessage(msgs[i].id, assistantId);
260
+ const linked = attachmentsStore.getAttachmentMetadataForMessage(msgs[i].id, "self");
263
261
  const replyAttachments: RuntimeAttachmentMetadata[] = linked.map((a) => ({
264
262
  id: a.id,
265
263
  filename: a.originalFilename,
@@ -291,12 +289,12 @@ export async function handleChannelInbound(
291
289
  });
292
290
  }
293
291
 
294
- export function handleListDeadLetters(assistantId: string): Response {
295
- const events = channelDeliveryStore.getDeadLetterEvents(assistantId);
292
+ export function handleListDeadLetters(): Response {
293
+ const events = channelDeliveryStore.getDeadLetterEvents("self");
296
294
  return Response.json({ events });
297
295
  }
298
296
 
299
- export async function handleReplayDeadLetters(assistantId: string, req: Request): Promise<Response> {
297
+ export async function handleReplayDeadLetters(req: Request): Promise<Response> {
300
298
  const body = await req.json() as { eventIds?: string[] };
301
299
  const eventIds = body.eventIds;
302
300
 
@@ -304,11 +302,11 @@ export async function handleReplayDeadLetters(assistantId: string, req: Request)
304
302
  return Response.json({ error: 'eventIds array is required' }, { status: 400 });
305
303
  }
306
304
 
307
- const replayed = channelDeliveryStore.replayDeadLetters(assistantId, eventIds);
305
+ const replayed = channelDeliveryStore.replayDeadLetters("self", eventIds);
308
306
  return Response.json({ replayed });
309
307
  }
310
308
 
311
- export async function handleChannelDeliveryAck(assistantId: string, req: Request): Promise<Response> {
309
+ export async function handleChannelDeliveryAck(req: Request): Promise<Response> {
312
310
  const body = await req.json() as {
313
311
  sourceChannel?: string;
314
312
  externalChatId?: string;
@@ -328,7 +326,7 @@ export async function handleChannelDeliveryAck(assistantId: string, req: Request
328
326
  }
329
327
 
330
328
  const acked = channelDeliveryStore.acknowledgeDelivery(
331
- assistantId,
329
+ "self",
332
330
  sourceChannel,
333
331
  externalChatId,
334
332
  externalMessageId,
@@ -43,7 +43,6 @@ function getInterfaceFilesWithMtimes(interfacesDir: string | null): Array<{ path
43
43
  }
44
44
 
45
45
  export function handleListMessages(
46
- assistantId: string,
47
46
  url: URL,
48
47
  interfacesDir: string | null,
49
48
  ): Response {
@@ -55,7 +54,7 @@ export function handleListMessages(
55
54
  );
56
55
  }
57
56
 
58
- const mapping = getConversationByKey(assistantId, conversationKey);
57
+ const mapping = getConversationByKey("self", conversationKey);
59
58
  if (!mapping) {
60
59
  return Response.json({ messages: [] });
61
60
  }
@@ -90,7 +89,7 @@ export function handleListMessages(
90
89
  const messages: RuntimeMessagePayload[] = merged.map((m) => {
91
90
  let msgAttachments: RuntimeAttachmentMetadata[] = [];
92
91
  if (m.role === 'assistant' && m.id) {
93
- const linked = attachmentsStore.getAttachmentMetadataForMessage(m.id, assistantId);
92
+ const linked = attachmentsStore.getAttachmentMetadataForMessage(m.id, "self");
94
93
  if (linked.length > 0) {
95
94
  msgAttachments = linked.map((a) => ({
96
95
  id: a.id,
@@ -129,7 +128,6 @@ export function handleListMessages(
129
128
  }
130
129
 
131
130
  export async function handleSendMessage(
132
- assistantId: string,
133
131
  req: Request,
134
132
  deps: {
135
133
  processMessage?: MessageProcessor;
@@ -172,7 +170,7 @@ export async function handleSendMessage(
172
170
 
173
171
  // Validate that all attachment IDs resolve
174
172
  if (hasAttachments) {
175
- const resolved = attachmentsStore.getAttachmentsByIds(assistantId, attachmentIds);
173
+ const resolved = attachmentsStore.getAttachmentsByIds("self", attachmentIds);
176
174
  if (resolved.length !== attachmentIds.length) {
177
175
  const resolvedIds = new Set(resolved.map((a) => a.id));
178
176
  const missing = attachmentIds.filter((id) => !resolvedIds.has(id));
@@ -183,7 +181,7 @@ export async function handleSendMessage(
183
181
  }
184
182
  }
185
183
 
186
- const mapping = getOrCreateConversation(assistantId, conversationKey);
184
+ const mapping = getOrCreateConversation("self", conversationKey);
187
185
 
188
186
  const processor = deps.persistAndProcessMessage ?? deps.processMessage;
189
187
  if (!processor) {
@@ -192,7 +190,6 @@ export async function handleSendMessage(
192
190
 
193
191
  try {
194
192
  const result = await processor(
195
- assistantId,
196
193
  mapping.conversationId,
197
194
  content ?? '',
198
195
  hasAttachments ? attachmentIds : undefined,
@@ -236,7 +233,6 @@ async function generateLlmSuggestion(provider: Provider, assistantText: string):
236
233
  }
237
234
 
238
235
  export async function handleGetSuggestion(
239
- assistantId: string,
240
236
  url: URL,
241
237
  deps: {
242
238
  suggestionCache: Map<string, string>;
@@ -251,7 +247,7 @@ export async function handleGetSuggestion(
251
247
  );
252
248
  }
253
249
 
254
- const mapping = getConversationByKey(assistantId, conversationKey);
250
+ const mapping = getConversationByKey("self", conversationKey);
255
251
  if (!mapping) {
256
252
  return Response.json({ suggestion: null, messageId: null, source: 'none' as const });
257
253
  }