@vellumai/assistant 0.4.1 → 0.4.3

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 (97) hide show
  1. package/ARCHITECTURE.md +84 -7
  2. package/bun.lock +0 -83
  3. package/docs/trusted-contact-access.md +20 -0
  4. package/package.json +2 -3
  5. package/src/__tests__/access-request-decision.test.ts +0 -1
  6. package/src/__tests__/assistant-id-boundary-guard.test.ts +290 -0
  7. package/src/__tests__/call-routes-http.test.ts +0 -25
  8. package/src/__tests__/channel-approval-routes.test.ts +55 -5
  9. package/src/__tests__/channel-guardian.test.ts +6 -5
  10. package/src/__tests__/config-schema.test.ts +2 -0
  11. package/src/__tests__/daemon-server-session-init.test.ts +54 -1
  12. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  13. package/src/__tests__/guardian-actions-endpoint.test.ts +21 -0
  14. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +4 -2
  15. package/src/__tests__/guardian-outbound-http.test.ts +0 -1
  16. package/src/__tests__/guardian-routing-invariants.test.ts +50 -9
  17. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +161 -2
  18. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  19. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  20. package/src/__tests__/non-member-access-request.test.ts +28 -1
  21. package/src/__tests__/notification-decision-strategy.test.ts +44 -0
  22. package/src/__tests__/relay-server.test.ts +644 -4
  23. package/src/__tests__/send-endpoint-busy.test.ts +129 -3
  24. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  25. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  26. package/src/__tests__/session-surfaces-task-progress.test.ts +43 -0
  27. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  28. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  29. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  30. package/src/__tests__/twilio-routes.test.ts +4 -3
  31. package/src/__tests__/update-bulletin.test.ts +0 -1
  32. package/src/approvals/guardian-decision-primitive.ts +24 -2
  33. package/src/approvals/guardian-request-resolvers.ts +42 -3
  34. package/src/calls/call-constants.ts +8 -0
  35. package/src/calls/call-controller.ts +2 -1
  36. package/src/calls/call-domain.ts +5 -4
  37. package/src/calls/relay-server.ts +513 -116
  38. package/src/calls/twilio-routes.ts +3 -5
  39. package/src/calls/types.ts +1 -1
  40. package/src/calls/voice-session-bridge.ts +4 -3
  41. package/src/cli/core-commands.ts +7 -4
  42. package/src/config/bundled-skills/app-builder/SKILL.md +164 -1
  43. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +214 -0
  44. package/src/config/calls-schema.ts +12 -0
  45. package/src/config/feature-flag-registry.json +0 -8
  46. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -2
  47. package/src/daemon/handlers/config-channels.ts +5 -7
  48. package/src/daemon/handlers/config-inbox.ts +2 -0
  49. package/src/daemon/handlers/index.ts +2 -1
  50. package/src/daemon/handlers/publish.ts +11 -46
  51. package/src/daemon/handlers/sessions.ts +136 -13
  52. package/src/daemon/ipc-contract/apps.ts +1 -0
  53. package/src/daemon/ipc-contract/inbox.ts +4 -0
  54. package/src/daemon/ipc-contract/integrations.ts +3 -1
  55. package/src/daemon/server.ts +19 -3
  56. package/src/daemon/session-agent-loop.ts +35 -23
  57. package/src/daemon/session-runtime-assembly.ts +3 -1
  58. package/src/daemon/session-surfaces.ts +29 -1
  59. package/src/memory/app-store.ts +6 -0
  60. package/src/memory/conversation-crud.ts +2 -1
  61. package/src/memory/conversation-title-service.ts +16 -2
  62. package/src/memory/db-init.ts +4 -0
  63. package/src/memory/delivery-crud.ts +2 -1
  64. package/src/memory/embedding-local.ts +25 -13
  65. package/src/memory/embedding-runtime-manager.ts +24 -6
  66. package/src/memory/guardian-action-store.ts +2 -1
  67. package/src/memory/guardian-approvals.ts +3 -2
  68. package/src/memory/ingress-invite-store.ts +12 -2
  69. package/src/memory/ingress-member-store.ts +4 -3
  70. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  71. package/src/memory/migrations/index.ts +1 -0
  72. package/src/memory/schema.ts +10 -5
  73. package/src/notifications/copy-composer.ts +11 -1
  74. package/src/notifications/emit-signal.ts +2 -1
  75. package/src/runtime/access-request-helper.ts +11 -3
  76. package/src/runtime/actor-trust-resolver.ts +2 -2
  77. package/src/runtime/assistant-scope.ts +10 -0
  78. package/src/runtime/guardian-context-resolver.ts +5 -1
  79. package/src/runtime/guardian-outbound-actions.ts +5 -4
  80. package/src/runtime/guardian-reply-router.ts +12 -0
  81. package/src/runtime/http-server.ts +12 -20
  82. package/src/runtime/ingress-service.ts +14 -0
  83. package/src/runtime/invite-redemption-service.ts +2 -1
  84. package/src/runtime/middleware/twilio-validation.ts +2 -4
  85. package/src/runtime/routes/call-routes.ts +2 -1
  86. package/src/runtime/routes/channel-route-shared.ts +3 -3
  87. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  88. package/src/runtime/routes/conversation-routes.ts +33 -11
  89. package/src/runtime/routes/events-routes.ts +2 -3
  90. package/src/runtime/routes/inbound-conversation.ts +4 -3
  91. package/src/runtime/routes/inbound-message-handler.ts +16 -4
  92. package/src/runtime/routes/ingress-routes.ts +2 -0
  93. package/src/tools/apps/executors.ts +15 -0
  94. package/src/tools/calls/call-start.ts +2 -1
  95. package/src/tools/terminal/parser.ts +12 -0
  96. package/src/tools/tool-approval-handler.ts +2 -1
  97. package/src/workspace/git-service.ts +19 -0
@@ -286,7 +286,7 @@ function buildTitlePrompt(
286
286
  assistantResponse?: string,
287
287
  ): string {
288
288
  const parts: string[] = [
289
- 'Generate a very short title for this conversation. Rules: at most 5 words, at most 40 characters, no quotes.',
289
+ 'Generate a very short title for this conversation. Rules: at most 5 words, at most 40 characters, no quotes, no markdown formatting.',
290
290
  ];
291
291
 
292
292
  if (context) {
@@ -313,12 +313,26 @@ function buildTitlePrompt(
313
313
 
314
314
  function normalizeTitle(raw: string): string {
315
315
  let title = raw.trim().replace(/^["']|["']$/g, '');
316
+ title = stripMarkdown(title);
316
317
  const words = title.split(/\s+/);
317
318
  if (words.length > 5) title = words.slice(0, 5).join(' ');
318
319
  if (title.length > 40) title = title.slice(0, 40).trimEnd();
319
320
  return title;
320
321
  }
321
322
 
323
+ /** Strip common markdown formatting so titles render as plain text. */
324
+ function stripMarkdown(text: string): string {
325
+ return text
326
+ .replace(/\*\*(.+?)\*\*/g, '$1') // **bold**
327
+ .replace(/__(.+?)__/g, '$1') // __bold__
328
+ .replace(/\*(.+?)\*/g, '$1') // *italic*
329
+ .replace(/(?<!\w)_(.+?)_(?!\w)/g, '$1') // _italic_ (word-boundary-aware to preserve snake_case)
330
+ .replace(/~~(.+?)~~/g, '$1') // ~~strikethrough~~
331
+ .replace(/`(.+?)`/g, '$1') // `code`
332
+ .replace(/\[(.+?)\]\(.+?\)/g, '$1') // [link](url)
333
+ .replace(/^#{1,6}\s+/gm, ''); // # headings
334
+ }
335
+
322
336
  function deriveFallbackTitle(context?: TitleContext): string | null {
323
337
  if (!context) return null;
324
338
  if (context.systemHint) return truncate(context.systemHint, 40, '');
@@ -328,7 +342,7 @@ function deriveFallbackTitle(context?: TitleContext): string | null {
328
342
 
329
343
  function buildRegenerationPrompt(recentMessages: MessageRow[]): string {
330
344
  const parts: string[] = [
331
- 'Generate a very short title for this conversation based on the recent messages below. Rules: at most 5 words, at most 40 characters, no quotes.',
345
+ 'Generate a very short title for this conversation based on the recent messages below. Rules: at most 5 words, at most 40 characters, no quotes, no markdown formatting.',
332
346
  '',
333
347
  'Recent messages:',
334
348
  ];
@@ -37,6 +37,7 @@ import {
37
37
  migrateReminderRoutingIntent,
38
38
  migrateSchemaIndexesAndColumns,
39
39
  migrateVoiceInviteColumns,
40
+ migrateVoiceInviteDisplayMetadata,
40
41
  recoverCrashedMigrations,
41
42
  runComplexMigrations,
42
43
  runLateMigrations,
@@ -165,5 +166,8 @@ export function initializeDb(): void {
165
166
  // 26. Voice invite columns on assistant_ingress_invites
166
167
  migrateVoiceInviteColumns(database);
167
168
 
169
+ // 27. Voice invite display metadata (friend_name, guardian_name) for personalized prompts
170
+ migrateVoiceInviteDisplayMetadata(database);
171
+
168
172
  validateMigrationState(database);
169
173
  }
@@ -8,6 +8,7 @@
8
8
  import { and, desc, eq, isNotNull } from 'drizzle-orm';
9
9
  import { v4 as uuid } from 'uuid';
10
10
 
11
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
11
12
  import { getConversationByKey, getOrCreateConversation, setConversationKeyIfAbsent } from './conversation-key-store.js';
12
13
  import { getDb } from './db.js';
13
14
  import { channelInboundEvents, conversations } from './schema.js';
@@ -73,7 +74,7 @@ export function recordInbound(
73
74
  const scopedMapping = assistantId ? getConversationByKey(scopedKey) : null;
74
75
  if (scopedMapping) {
75
76
  mapping = { conversationId: scopedMapping.conversationId, created: false };
76
- } else if (assistantId === 'self') {
77
+ } else if (assistantId === DAEMON_INTERNAL_ASSISTANT_ID) {
77
78
  const legacyMapping = getConversationByKey(legacyKey);
78
79
  if (legacyMapping) {
79
80
  mapping = { conversationId: legacyMapping.conversationId, created: false };
@@ -81,7 +81,11 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
81
81
  }
82
82
  this.pendingRequests.set(id, { resolve });
83
83
  this.workerProc.stdin.write(JSON.stringify({ id, texts }) + '\n');
84
- this.workerProc.stdin.flush();
84
+ try {
85
+ this.workerProc.stdin.flush();
86
+ } catch {
87
+ // Worker may have exited — pending request will be resolved by stdout reader cleanup
88
+ }
85
89
  });
86
90
  }
87
91
 
@@ -138,8 +142,10 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
138
142
  // Wait for the worker to signal it's ready (model loaded)
139
143
  await this.waitForReady();
140
144
  } catch (err) {
141
- // Worker failed to start — collect stderr for diagnosis
145
+ // Worker failed to start — kill it to avoid deadlock, then collect stderr
142
146
  this.workerProc = null;
147
+ this.stdoutReaderActive = false;
148
+ try { proc.kill(); } catch { /* may already be dead */ }
143
149
  const exitCode = await proc.exited.catch(() => undefined);
144
150
  const stderr = await new Response(proc.stderr).text().catch(() => '');
145
151
  if (stderr.trim()) {
@@ -180,7 +186,9 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
180
186
  if (this.stdoutReaderActive || !this.workerProc) return;
181
187
  this.stdoutReaderActive = true;
182
188
 
183
- const reader = this.workerProc.stdout.getReader();
189
+ // Capture reference to detect if a new worker was spawned during cleanup
190
+ const proc = this.workerProc;
191
+ const reader = proc.stdout.getReader();
184
192
  const decoder = new TextDecoder();
185
193
 
186
194
  (async () => {
@@ -195,17 +203,21 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
195
203
  // Reader cancelled or stream errored
196
204
  }
197
205
 
198
- // Worker exited reject all pending requests and clean up
199
- for (const [, pending] of this.pendingRequests) {
200
- pending.resolve({ error: 'Embedding worker process exited unexpectedly' });
206
+ // Only clean up if this reader's proc is still the active one.
207
+ // A new worker may have been spawned during the async cleanup window.
208
+ if (this.workerProc === proc) {
209
+ // Worker exited — reject all pending requests and clean up
210
+ for (const [, pending] of this.pendingRequests) {
211
+ pending.resolve({ error: 'Embedding worker process exited unexpectedly' });
212
+ }
213
+ this.pendingRequests.clear();
214
+ this.workerProc = null;
215
+ this.stdoutReaderActive = false;
216
+ this.removePidFile();
217
+ this.stdoutBuffer = '';
218
+ // Allow re-initialization on next embed() call
219
+ this.initGuard.reset();
201
220
  }
202
- this.pendingRequests.clear();
203
- this.workerProc = null;
204
- this.stdoutReaderActive = false;
205
- this.removePidFile();
206
- this.stdoutBuffer = '';
207
- // Allow re-initialization on next embed() call
208
- this.initGuard.reset();
209
221
  })();
210
222
  }
211
223
 
@@ -27,12 +27,13 @@ const log = getLogger('embedding-runtime-manager');
27
27
  const ONNXRUNTIME_NODE_VERSION = '1.21.0';
28
28
  const ONNXRUNTIME_COMMON_VERSION = '1.21.0';
29
29
  const TRANSFORMERS_VERSION = '3.8.1';
30
+ const JINJA_VERSION = '0.5.5';
30
31
 
31
32
  /** Bun version to download when system bun is not available. */
32
33
  const BUN_VERSION = '1.2.0';
33
34
 
34
35
  /** Composite version string for cache invalidation. */
35
- const RUNTIME_VERSION = `ort-${ONNXRUNTIME_NODE_VERSION}_hf-${TRANSFORMERS_VERSION}`;
36
+ const RUNTIME_VERSION = `ort-${ONNXRUNTIME_NODE_VERSION}_hf-${TRANSFORMERS_VERSION}_jinja-${JINJA_VERSION}`;
36
37
 
37
38
  const WORKER_FILENAME = 'embed-worker.mjs';
38
39
 
@@ -104,6 +105,7 @@ function generateWorkerScript(): string {
104
105
  return `\
105
106
  // embed-worker.mjs — Auto-generated by EmbeddingRuntimeManager
106
107
  // Runs in a separate bun process, communicates via JSON-lines over stdin/stdout.
108
+ process.title = 'embed-worker';
107
109
  import { pipeline, env } from '@huggingface/transformers';
108
110
 
109
111
  const model = process.argv[2];
@@ -272,6 +274,12 @@ export class EmbeddingRuntimeManager {
272
274
  const tmpDir = join(this.baseDir, `.installing-${Date.now()}`);
273
275
  mkdirSync(tmpDir, { recursive: true });
274
276
 
277
+ // Declared outside try so catch/finally can reference them for cleanup
278
+ const modelCacheDir = join(this.baseDir, 'model-cache');
279
+ const existingBinDir = join(this.baseDir, 'bin');
280
+ let tmpModelCache: string | null = null;
281
+ let tmpBinDir: string | null = null;
282
+
275
283
  try {
276
284
  // Step 1: Download npm packages (and bun if needed) in parallel
277
285
  const nodeModules = join(tmpDir, 'node_modules');
@@ -291,6 +299,11 @@ export class EmbeddingRuntimeManager {
291
299
  join(nodeModules, '@huggingface', 'transformers'),
292
300
  signal,
293
301
  ),
302
+ downloadAndExtract(
303
+ npmTarballUrl('@huggingface/jinja', JINJA_VERSION),
304
+ join(nodeModules, '@huggingface', 'jinja'),
305
+ signal,
306
+ ),
294
307
  ];
295
308
 
296
309
  // Download bun binary if not already available on the system
@@ -358,19 +371,15 @@ export class EmbeddingRuntimeManager {
358
371
 
359
372
  // Step 6: Atomic swap — remove old install and rename temp to final
360
373
  // Preserve model-cache/, bin/ (downloaded bun), and .gitignore
361
- const modelCacheDir = join(this.baseDir, 'model-cache');
362
374
  const hadModelCache = existsSync(modelCacheDir);
363
- let tmpModelCache: string | null = null;
364
375
  if (hadModelCache) {
365
376
  tmpModelCache = join(this.baseDir, `.model-cache-preserve-${Date.now()}`);
366
377
  renameSync(modelCacheDir, tmpModelCache);
367
378
  }
368
379
 
369
380
  // Preserve downloaded bun binary if it exists and we didn't just download a new one
370
- const existingBinDir = join(this.baseDir, 'bin');
371
381
  const newBinDir = join(tmpDir, 'bin');
372
382
  const hadBinDir = existsSync(existingBinDir) && !existsSync(newBinDir);
373
- let tmpBinDir: string | null = null;
374
383
  if (hadBinDir) {
375
384
  tmpBinDir = join(this.baseDir, `.bin-preserve-${Date.now()}`);
376
385
  renameSync(existingBinDir, tmpBinDir);
@@ -399,11 +408,20 @@ export class EmbeddingRuntimeManager {
399
408
 
400
409
  log.info({ runtimeVersion: RUNTIME_VERSION }, 'Embedding runtime installed successfully');
401
410
  } catch (err) {
411
+ // Restore preserved directories if the swap failed
412
+ if (tmpModelCache && existsSync(tmpModelCache) && !existsSync(modelCacheDir)) {
413
+ try { renameSync(tmpModelCache, modelCacheDir); } catch { /* best effort */ }
414
+ }
415
+ if (tmpBinDir && existsSync(tmpBinDir) && !existsSync(existingBinDir)) {
416
+ try { renameSync(tmpBinDir, existingBinDir); } catch { /* best effort */ }
417
+ }
402
418
  log.error({ err }, 'Failed to install embedding runtime');
403
419
  throw err;
404
420
  } finally {
405
- // Clean up temp directory
421
+ // Clean up temp directory and any leftover preserve dirs
406
422
  rmSync(tmpDir, { recursive: true, force: true });
423
+ if (tmpModelCache) rmSync(tmpModelCache, { recursive: true, force: true });
424
+ if (tmpBinDir) rmSync(tmpBinDir, { recursive: true, force: true });
407
425
  }
408
426
  }
409
427
 
@@ -10,6 +10,7 @@
10
10
  import { and, desc, eq, inArray, lt } from 'drizzle-orm';
11
11
  import { v4 as uuid } from 'uuid';
12
12
 
13
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
13
14
  import { getLogger } from '../util/logger.js';
14
15
  import { getDb, rawChanges } from './db.js';
15
16
  import {
@@ -160,7 +161,7 @@ export function createGuardianActionRequest(params: {
160
161
 
161
162
  const row = {
162
163
  id,
163
- assistantId: params.assistantId ?? 'self',
164
+ assistantId: params.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID,
164
165
  kind: params.kind,
165
166
  sourceChannel: params.sourceChannel,
166
167
  sourceConversationId: params.sourceConversationId,
@@ -9,6 +9,7 @@
9
9
  import { and, count, desc, eq, gt, lte } from 'drizzle-orm';
10
10
  import { v4 as uuid } from 'uuid';
11
11
 
12
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
12
13
  import { getDb } from './db.js';
13
14
  import { channelGuardianApprovalRequests } from './schema.js';
14
15
 
@@ -100,7 +101,7 @@ export function createApprovalRequest(params: {
100
101
  runId: params.runId,
101
102
  requestId: params.requestId ?? null,
102
103
  conversationId: params.conversationId,
103
- assistantId: params.assistantId ?? 'self',
104
+ assistantId: params.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID,
104
105
  channel: params.channel,
105
106
  requesterExternalUserId: params.requesterExternalUserId,
106
107
  requesterChatId: params.requesterChatId,
@@ -402,7 +403,7 @@ export function listPendingApprovalRequests(params: {
402
403
  const db = getDb();
403
404
 
404
405
  const conditions = [
405
- eq(channelGuardianApprovalRequests.assistantId, params.assistantId ?? 'self'),
406
+ eq(channelGuardianApprovalRequests.assistantId, params.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID),
406
407
  ];
407
408
  if (params.channel) {
408
409
  conditions.push(eq(channelGuardianApprovalRequests.channel, params.channel));
@@ -10,6 +10,7 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto';
10
10
 
11
11
  import { and, desc, eq } from 'drizzle-orm';
12
12
 
13
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
13
14
  import { getDb } from './db.js';
14
15
  import { assistantIngressInvites, assistantIngressMembers } from './schema.js';
15
16
 
@@ -37,6 +38,9 @@ export interface IngressInvite {
37
38
  expectedExternalUserId: string | null;
38
39
  voiceCodeHash: string | null;
39
40
  voiceCodeDigits: number | null;
41
+ // Display metadata for personalized voice prompts (null for non-voice invites)
42
+ friendName: string | null;
43
+ guardianName: string | null;
40
44
  createdAt: number;
41
45
  updatedAt: number;
42
46
  }
@@ -97,6 +101,8 @@ function rowToInvite(row: typeof assistantIngressInvites.$inferSelect): IngressI
97
101
  expectedExternalUserId: row.expectedExternalUserId,
98
102
  voiceCodeHash: row.voiceCodeHash,
99
103
  voiceCodeDigits: row.voiceCodeDigits,
104
+ friendName: row.friendName,
105
+ guardianName: row.guardianName,
100
106
  createdAt: row.createdAt,
101
107
  updatedAt: row.updatedAt,
102
108
  };
@@ -138,6 +144,8 @@ export function createInvite(params: {
138
144
  expectedExternalUserId?: string;
139
145
  voiceCodeHash?: string;
140
146
  voiceCodeDigits?: number;
147
+ friendName?: string;
148
+ guardianName?: string;
141
149
  }): { invite: IngressInvite; rawToken: string } {
142
150
  const db = getDb();
143
151
  const now = Date.now();
@@ -147,7 +155,7 @@ export function createInvite(params: {
147
155
 
148
156
  const row = {
149
157
  id,
150
- assistantId: params.assistantId ?? 'self',
158
+ assistantId: params.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID,
151
159
  sourceChannel: params.sourceChannel,
152
160
  tokenHash: tokenH,
153
161
  createdBySessionId: params.createdBySessionId ?? null,
@@ -162,6 +170,8 @@ export function createInvite(params: {
162
170
  expectedExternalUserId: params.expectedExternalUserId ?? null,
163
171
  voiceCodeHash: params.voiceCodeHash ?? null,
164
172
  voiceCodeDigits: params.voiceCodeDigits ?? null,
173
+ friendName: params.friendName ?? null,
174
+ guardianName: params.guardianName ?? null,
165
175
  createdAt: now,
166
176
  updatedAt: now,
167
177
  };
@@ -183,7 +193,7 @@ export function listInvites(params: {
183
193
  offset?: number;
184
194
  }): IngressInvite[] {
185
195
  const db = getDb();
186
- const assistantId = params.assistantId ?? 'self';
196
+ const assistantId = params.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
187
197
 
188
198
  const conditions = [eq(assistantIngressInvites.assistantId, assistantId)];
189
199
 
@@ -8,6 +8,7 @@
8
8
  import { and, desc, eq, or } from 'drizzle-orm';
9
9
  import { v4 as uuid } from 'uuid';
10
10
 
11
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
11
12
  import { getDb } from './db.js';
12
13
  import { assistantIngressMembers } from './schema.js';
13
14
 
@@ -78,7 +79,7 @@ export function upsertMember(params: {
78
79
  createdBySessionId?: string;
79
80
  assistantId?: string;
80
81
  }): IngressMember {
81
- const assistantId = params.assistantId ?? 'self';
82
+ const assistantId = params.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
82
83
 
83
84
  if (!params.externalUserId && !params.externalChatId) {
84
85
  throw new Error('At least one of externalUserId or externalChatId must be provided');
@@ -181,7 +182,7 @@ export function listMembers(params?: {
181
182
  offset?: number;
182
183
  }): IngressMember[] {
183
184
  const db = getDb();
184
- const assistantId = params?.assistantId ?? 'self';
185
+ const assistantId = params?.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
185
186
 
186
187
  const conditions = [eq(assistantIngressMembers.assistantId, assistantId)];
187
188
  if (params?.sourceChannel) {
@@ -304,7 +305,7 @@ export function findMember(params: {
304
305
  }
305
306
 
306
307
  const db = getDb();
307
- const assistantId = params.assistantId ?? 'self';
308
+ const assistantId = params.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
308
309
 
309
310
  // Prefer lookup by externalUserId when available, fall back to externalChatId
310
311
  const matchConditions = [];
@@ -0,0 +1,14 @@
1
+ import type { DrizzleDb } from '../db-connection.js';
2
+
3
+ /**
4
+ * Add display metadata columns to assistant_ingress_invites for personalized
5
+ * voice invite prompts. Both columns are nullable to keep existing invite
6
+ * rows compatible.
7
+ *
8
+ * - friend_name: the name of the person being invited (used in welcome prompt)
9
+ * - guardian_name: the name of the guardian who created the invite (used in prompts)
10
+ */
11
+ export function migrateVoiceInviteDisplayMetadata(database: DrizzleDb): void {
12
+ try { database.run(/*sql*/ `ALTER TABLE assistant_ingress_invites ADD COLUMN friend_name TEXT`); } catch { /* already exists */ }
13
+ try { database.run(/*sql*/ `ALTER TABLE assistant_ingress_invites ADD COLUMN guardian_name TEXT`); } catch { /* already exists */ }
14
+ }
@@ -63,6 +63,7 @@ export { migrateFkCascadeRebuilds } from './120-fk-cascade-rebuilds.js';
63
63
  export { createCanonicalGuardianTables } from './121-canonical-guardian-requests.js';
64
64
  export { migrateCanonicalGuardianRequesterChatId } from './122-canonical-guardian-requester-chat-id.js';
65
65
  export { migrateCanonicalGuardianDeliveriesDestinationIndex } from './123-canonical-guardian-deliveries-destination-index.js';
66
+ export { migrateVoiceInviteDisplayMetadata } from './124-voice-invite-display-metadata.js';
66
67
  export {
67
68
  MIGRATION_REGISTRY,
68
69
  type MigrationRegistryEntry,
@@ -1,5 +1,7 @@
1
1
  import { blob, index,integer, real, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
2
2
 
3
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
4
+
3
5
  export const conversations = sqliteTable('conversations', {
4
6
  id: text('id').primaryKey(),
5
7
  title: text('title'),
@@ -683,7 +685,7 @@ export const channelGuardianApprovalRequests = sqliteTable('channel_guardian_app
683
685
  runId: text('run_id').notNull(),
684
686
  requestId: text('request_id'),
685
687
  conversationId: text('conversation_id').notNull(),
686
- assistantId: text('assistant_id').notNull().default('self'),
688
+ assistantId: text('assistant_id').notNull().default(DAEMON_INTERNAL_ASSISTANT_ID),
687
689
  channel: text('channel').notNull(),
688
690
  requesterExternalUserId: text('requester_external_user_id').notNull(),
689
691
  requesterChatId: text('requester_chat_id').notNull(),
@@ -819,7 +821,7 @@ export const mediaEventFeedback = sqliteTable('media_event_feedback', {
819
821
 
820
822
  export const guardianActionRequests = sqliteTable('guardian_action_requests', {
821
823
  id: text('id').primaryKey(),
822
- assistantId: text('assistant_id').notNull().default('self'),
824
+ assistantId: text('assistant_id').notNull().default(DAEMON_INTERNAL_ASSISTANT_ID),
823
825
  kind: text('kind').notNull(), // 'ask_guardian'
824
826
  sourceChannel: text('source_channel').notNull(), // 'voice'
825
827
  sourceConversationId: text('source_conversation_id').notNull(),
@@ -930,7 +932,7 @@ export const canonicalGuardianDeliveries = sqliteTable('canonical_guardian_deliv
930
932
 
931
933
  export const assistantIngressInvites = sqliteTable('assistant_ingress_invites', {
932
934
  id: text('id').primaryKey(),
933
- assistantId: text('assistant_id').notNull().default('self'),
935
+ assistantId: text('assistant_id').notNull().default(DAEMON_INTERNAL_ASSISTANT_ID),
934
936
  sourceChannel: text('source_channel').notNull(),
935
937
  tokenHash: text('token_hash').notNull(),
936
938
  createdBySessionId: text('created_by_session_id'),
@@ -946,13 +948,16 @@ export const assistantIngressInvites = sqliteTable('assistant_ingress_invites',
946
948
  expectedExternalUserId: text('expected_external_user_id'),
947
949
  voiceCodeHash: text('voice_code_hash'),
948
950
  voiceCodeDigits: integer('voice_code_digits'),
951
+ // Display metadata for personalized voice prompts (nullable — non-voice invites leave these NULL)
952
+ friendName: text('friend_name'),
953
+ guardianName: text('guardian_name'),
949
954
  createdAt: integer('created_at').notNull(),
950
955
  updatedAt: integer('updated_at').notNull(),
951
956
  });
952
957
 
953
958
  export const assistantIngressMembers = sqliteTable('assistant_ingress_members', {
954
959
  id: text('id').primaryKey(),
955
- assistantId: text('assistant_id').notNull().default('self'),
960
+ assistantId: text('assistant_id').notNull().default(DAEMON_INTERNAL_ASSISTANT_ID),
956
961
  sourceChannel: text('source_channel').notNull(),
957
962
  externalUserId: text('external_user_id'),
958
963
  externalChatId: text('external_chat_id'),
@@ -974,7 +979,7 @@ export const assistantInboxThreadState = sqliteTable('assistant_inbox_thread_sta
974
979
  conversationId: text('conversation_id')
975
980
  .primaryKey()
976
981
  .references(() => conversations.id, { onDelete: 'cascade' }),
977
- assistantId: text('assistant_id').notNull().default('self'),
982
+ assistantId: text('assistant_id').notNull().default(DAEMON_INTERNAL_ASSISTANT_ID),
978
983
  sourceChannel: text('source_channel').notNull(),
979
984
  externalChatId: text('external_chat_id').notNull(),
980
985
  externalUserId: text('external_user_id'),
@@ -57,7 +57,17 @@ const TEMPLATES: Record<string, CopyTemplate> = {
57
57
  'ingress.access_request': (payload) => {
58
58
  const requester = str(payload.senderIdentifier, 'Someone');
59
59
  const requestCode = nonEmpty(typeof payload.requestCode === 'string' ? payload.requestCode : undefined);
60
- const lines: string[] = [`${requester} is requesting access to the assistant.`];
60
+ const sourceChannel = typeof payload.sourceChannel === 'string' ? payload.sourceChannel : undefined;
61
+ const callerName = nonEmpty(typeof payload.senderName === 'string' ? payload.senderName : undefined);
62
+ const lines: string[] = [];
63
+
64
+ // Voice-originated access requests include caller name context
65
+ if (sourceChannel === 'voice' && callerName) {
66
+ lines.push(`${callerName} (${str(payload.senderExternalUserId, requester)}) is calling and requesting access to the assistant.`);
67
+ } else {
68
+ lines.push(`${requester} is requesting access to the assistant.`);
69
+ }
70
+
61
71
  if (requestCode) {
62
72
  const code = requestCode.toUpperCase();
63
73
  lines.push(`Reply "${code} approve" to grant access or "${code} reject" to deny.`);
@@ -13,6 +13,7 @@ import { v4 as uuid } from 'uuid';
13
13
 
14
14
  import { getDeliverableChannels } from '../channels/config.js';
15
15
  import { getActiveBinding } from '../memory/channel-guardian-store.js';
16
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
16
17
  import { getLogger } from '../util/logger.js';
17
18
  import { type BroadcastFn, VellumAdapter } from './adapters/macos.js';
18
19
  import { SmsAdapter } from './adapters/sms.js';
@@ -170,7 +171,7 @@ export interface EmitSignalResult {
170
171
  */
171
172
  export async function emitNotificationSignal(params: EmitSignalParams): Promise<EmitSignalResult> {
172
173
  const signalId = uuid();
173
- const assistantId = params.assistantId ?? 'self';
174
+ const assistantId = params.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
174
175
 
175
176
  const signal: NotificationSignal = {
176
177
  signalId,
@@ -105,14 +105,22 @@ export function notifyGuardianOfAccessRequest(
105
105
  }
106
106
  }
107
107
 
108
+ // The conversationId is assistant-scoped so the dedupe query below only
109
+ // matches requests for the same assistant. Without this, a pending request
110
+ // from assistant A could be returned for assistant B, allowing the caller
111
+ // to piggyback on A's guardian approval.
112
+ const conversationId = `access-req-${canonicalAssistantId}-${sourceChannel}-${senderExternalUserId}`;
113
+
108
114
  // Deduplicate: skip creation if there is already a pending canonical request
109
- // for the same requester on this channel. Still return notified: true with
110
- // the existing request ID so callers know the guardian was already notified.
115
+ // for the same requester on this channel *and* assistant. Still return
116
+ // notified: true with the existing request ID so callers know the guardian
117
+ // was already notified.
111
118
  const existingCanonical = listCanonicalGuardianRequests({
112
119
  status: 'pending',
113
120
  requesterExternalUserId: senderExternalUserId,
114
121
  sourceChannel,
115
122
  kind: 'access_request',
123
+ conversationId,
116
124
  });
117
125
  if (existingCanonical.length > 0) {
118
126
  log.debug(
@@ -130,7 +138,7 @@ export function notifyGuardianOfAccessRequest(
130
138
  kind: 'access_request',
131
139
  sourceType: 'channel',
132
140
  sourceChannel,
133
- conversationId: `access-req-${sourceChannel}-${senderExternalUserId}`,
141
+ conversationId,
134
142
  requesterExternalUserId: senderExternalUserId,
135
143
  requesterChatId: externalChatId,
136
144
  guardianExternalUserId: guardianExternalUserId ?? undefined,
@@ -17,7 +17,7 @@ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.
17
17
  import type { IngressMember } from '../memory/ingress-member-store.js';
18
18
  import { findMember } from '../memory/ingress-member-store.js';
19
19
  import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
20
- import { normalizeAssistantId } from '../util/platform.js';
20
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from './assistant-scope.js';
21
21
  import { getGuardianBinding } from './channel-guardian-service.js';
22
22
 
23
23
  // ---------------------------------------------------------------------------
@@ -76,7 +76,7 @@ export interface ResolveActorTrustInput {
76
76
  * 5. Classify: guardian > trusted_contact (active member) > unknown.
77
77
  */
78
78
  export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustContext {
79
- const assistantId = normalizeAssistantId(input.assistantId);
79
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
80
80
 
81
81
  const rawUserId = typeof input.senderExternalUserId === 'string' && input.senderExternalUserId.trim().length > 0
82
82
  ? input.senderExternalUserId.trim()
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Canonical internal scope ID for all daemon-side assistant-scoped storage.
3
+ *
4
+ * The daemon uses a single fixed identity (`'self'`) for its own assistant
5
+ * scope. Public/external assistant IDs are an edge concern owned by the
6
+ * gateway and platform layers (hatch, invite links, etc.). Daemon code
7
+ * should never derive scoping decisions from externally-provided assistant
8
+ * IDs — use this constant instead.
9
+ */
10
+ export const DAEMON_INTERNAL_ASSISTANT_ID = 'self' as const;
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { ChannelId } from '../channels/types.js';
9
9
  import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
10
+ import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
10
11
  import {
11
12
  type DenialReason,
12
13
  resolveActorTrust,
@@ -41,11 +42,14 @@ export type ResolveGuardianContextInput = ResolveActorTrustInput;
41
42
  */
42
43
  export function resolveGuardianContext(input: ResolveGuardianContextInput): GuardianContext {
43
44
  const trust = resolveActorTrust(input);
45
+ const canonicalGuardianExternalUserId = trust.guardianBindingMatch?.guardianExternalUserId
46
+ ? canonicalizeInboundIdentity(input.sourceChannel, trust.guardianBindingMatch.guardianExternalUserId) ?? undefined
47
+ : undefined;
44
48
  return {
45
49
  trustClass: trust.trustClass,
46
50
  guardianChatId: trust.guardianBindingMatch?.guardianDeliveryChatId ??
47
51
  (trust.trustClass === 'guardian' ? input.externalChatId : undefined),
48
- guardianExternalUserId: trust.guardianBindingMatch?.guardianExternalUserId,
52
+ guardianExternalUserId: canonicalGuardianExternalUserId,
49
53
  requesterIdentifier: trust.actorMetadata.identifier,
50
54
  requesterDisplayName: trust.actorMetadata.displayName,
51
55
  requesterSenderDisplayName: trust.actorMetadata.senderDisplayName,
@@ -16,7 +16,8 @@ import { sendMessage as sendSms } from '../messaging/providers/sms/client.js';
16
16
  import { getCredentialMetadata } from '../tools/credentials/metadata-store.js';
17
17
  import { getLogger } from '../util/logger.js';
18
18
  import { normalizePhoneNumber } from '../util/phone.js';
19
- import { normalizeAssistantId, readHttpToken } from '../util/platform.js';
19
+ import { readHttpToken } from '../util/platform.js';
20
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from './assistant-scope.js';
20
21
  import {
21
22
  countRecentSendsToDestination,
22
23
  createOutboundSession,
@@ -243,7 +244,7 @@ function initiateGuardianVoiceCall(
243
244
  // ---------------------------------------------------------------------------
244
245
 
245
246
  export function startOutbound(params: StartOutboundParams): OutboundActionResult {
246
- const assistantId = normalizeAssistantId(params.assistantId ?? 'self');
247
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
247
248
  const channel = params.channel;
248
249
  const originConversationId = params.originConversationId;
249
250
 
@@ -541,7 +542,7 @@ function startOutboundVoice(
541
542
  // ---------------------------------------------------------------------------
542
543
 
543
544
  export function resendOutbound(params: ResendOutboundParams): OutboundActionResult {
544
- const assistantId = normalizeAssistantId(params.assistantId ?? 'self');
545
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
545
546
  const channel = params.channel;
546
547
  const originConversationId = params.originConversationId;
547
548
 
@@ -707,7 +708,7 @@ export function resendOutbound(params: ResendOutboundParams): OutboundActionResu
707
708
  // ---------------------------------------------------------------------------
708
709
 
709
710
  export function cancelOutbound(params: CancelOutboundParams): OutboundActionResult {
710
- const assistantId = normalizeAssistantId(params.assistantId ?? 'self');
711
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
711
712
  const channel = params.channel;
712
713
 
713
714
  const session = findActiveSession(assistantId, channel);
@@ -237,6 +237,7 @@ export async function routeGuardianReply(
237
237
  ): Promise<GuardianReplyResult> {
238
238
  const { messageText, actor, conversationId, callbackData, approvalConversationGenerator, channelDeliveryContext } = ctx;
239
239
  const pendingRequests = findPendingCanonicalRequests(actor, ctx.pendingRequestIds, conversationId);
240
+ const scopedPendingRequestIds = ctx.pendingRequestIds ? new Set(ctx.pendingRequestIds) : null;
240
241
 
241
242
  // ── 1. Deterministic callback parsing (button presses) ──
242
243
  // No conversationId scoping here — the guardian's reply comes from a
@@ -257,6 +258,17 @@ export async function routeGuardianReply(
257
258
  const codeResult = parseRequestCode(messageText);
258
259
  if (codeResult) {
259
260
  const { request } = codeResult;
261
+ if (scopedPendingRequestIds && !scopedPendingRequestIds.has(request.id)) {
262
+ log.info(
263
+ {
264
+ event: 'router_code_out_of_scope',
265
+ requestId: request.id,
266
+ pendingHintCount: scopedPendingRequestIds.size,
267
+ },
268
+ 'Request code matched a pending request outside the caller-provided scope; ignoring',
269
+ );
270
+ return notConsumed();
271
+ }
260
272
 
261
273
  if (request.status !== 'pending') {
262
274
  log.info(