@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.
- package/ARCHITECTURE.md +84 -7
- package/bun.lock +0 -83
- package/docs/trusted-contact-access.md +20 -0
- package/package.json +2 -3
- package/src/__tests__/access-request-decision.test.ts +0 -1
- package/src/__tests__/assistant-id-boundary-guard.test.ts +290 -0
- package/src/__tests__/call-routes-http.test.ts +0 -25
- package/src/__tests__/channel-approval-routes.test.ts +55 -5
- package/src/__tests__/channel-guardian.test.ts +6 -5
- package/src/__tests__/config-schema.test.ts +2 -0
- package/src/__tests__/daemon-server-session-init.test.ts +54 -1
- package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +21 -0
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +4 -2
- package/src/__tests__/guardian-outbound-http.test.ts +0 -1
- package/src/__tests__/guardian-routing-invariants.test.ts +50 -9
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +161 -2
- package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
- package/src/__tests__/ingress-routes-http.test.ts +55 -0
- package/src/__tests__/non-member-access-request.test.ts +28 -1
- package/src/__tests__/notification-decision-strategy.test.ts +44 -0
- package/src/__tests__/relay-server.test.ts +644 -4
- package/src/__tests__/send-endpoint-busy.test.ts +129 -3
- package/src/__tests__/session-init.benchmark.test.ts +0 -1
- package/src/__tests__/session-runtime-assembly.test.ts +4 -1
- package/src/__tests__/session-surfaces-task-progress.test.ts +43 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
- package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
- package/src/__tests__/trusted-contact-verification.test.ts +0 -1
- package/src/__tests__/twilio-routes.test.ts +4 -3
- package/src/__tests__/update-bulletin.test.ts +0 -1
- package/src/approvals/guardian-decision-primitive.ts +24 -2
- package/src/approvals/guardian-request-resolvers.ts +42 -3
- package/src/calls/call-constants.ts +8 -0
- package/src/calls/call-controller.ts +2 -1
- package/src/calls/call-domain.ts +5 -4
- package/src/calls/relay-server.ts +513 -116
- package/src/calls/twilio-routes.ts +3 -5
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +4 -3
- package/src/cli/core-commands.ts +7 -4
- package/src/config/bundled-skills/app-builder/SKILL.md +164 -1
- package/src/config/bundled-skills/vercel-token-setup/SKILL.md +214 -0
- package/src/config/calls-schema.ts +12 -0
- package/src/config/feature-flag-registry.json +0 -8
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -2
- package/src/daemon/handlers/config-channels.ts +5 -7
- package/src/daemon/handlers/config-inbox.ts +2 -0
- package/src/daemon/handlers/index.ts +2 -1
- package/src/daemon/handlers/publish.ts +11 -46
- package/src/daemon/handlers/sessions.ts +136 -13
- package/src/daemon/ipc-contract/apps.ts +1 -0
- package/src/daemon/ipc-contract/inbox.ts +4 -0
- package/src/daemon/ipc-contract/integrations.ts +3 -1
- package/src/daemon/server.ts +19 -3
- package/src/daemon/session-agent-loop.ts +35 -23
- package/src/daemon/session-runtime-assembly.ts +3 -1
- package/src/daemon/session-surfaces.ts +29 -1
- package/src/memory/app-store.ts +6 -0
- package/src/memory/conversation-crud.ts +2 -1
- package/src/memory/conversation-title-service.ts +16 -2
- package/src/memory/db-init.ts +4 -0
- package/src/memory/delivery-crud.ts +2 -1
- package/src/memory/embedding-local.ts +25 -13
- package/src/memory/embedding-runtime-manager.ts +24 -6
- package/src/memory/guardian-action-store.ts +2 -1
- package/src/memory/guardian-approvals.ts +3 -2
- package/src/memory/ingress-invite-store.ts +12 -2
- package/src/memory/ingress-member-store.ts +4 -3
- package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/schema.ts +10 -5
- package/src/notifications/copy-composer.ts +11 -1
- package/src/notifications/emit-signal.ts +2 -1
- package/src/runtime/access-request-helper.ts +11 -3
- package/src/runtime/actor-trust-resolver.ts +2 -2
- package/src/runtime/assistant-scope.ts +10 -0
- package/src/runtime/guardian-context-resolver.ts +5 -1
- package/src/runtime/guardian-outbound-actions.ts +5 -4
- package/src/runtime/guardian-reply-router.ts +12 -0
- package/src/runtime/http-server.ts +12 -20
- package/src/runtime/ingress-service.ts +14 -0
- package/src/runtime/invite-redemption-service.ts +2 -1
- package/src/runtime/middleware/twilio-validation.ts +2 -4
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-route-shared.ts +3 -3
- package/src/runtime/routes/conversation-attention-routes.ts +2 -1
- package/src/runtime/routes/conversation-routes.ts +33 -11
- package/src/runtime/routes/events-routes.ts +2 -3
- package/src/runtime/routes/inbound-conversation.ts +4 -3
- package/src/runtime/routes/inbound-message-handler.ts +16 -4
- package/src/runtime/routes/ingress-routes.ts +2 -0
- package/src/tools/apps/executors.ts +15 -0
- package/src/tools/calls/call-start.ts +2 -1
- package/src/tools/terminal/parser.ts +12 -0
- package/src/tools/tool-approval-handler.ts +2 -1
- 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
|
];
|
package/src/memory/db-init.ts
CHANGED
|
@@ -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 ===
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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,
|
package/src/memory/schema.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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 ??
|
|
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
|
|
110
|
-
// the existing request ID so callers know the guardian
|
|
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
|
|
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 {
|
|
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 =
|
|
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:
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|