@vellumai/vellum-gateway 0.7.3 → 0.8.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 (63) hide show
  1. package/AGENTS.md +10 -0
  2. package/Dockerfile +4 -2
  3. package/bun.lock +8 -1
  4. package/knip.json +1 -0
  5. package/package.json +2 -1
  6. package/src/__tests__/contact-prompt-submit.test.ts +5 -5
  7. package/src/__tests__/contact-store-mark-channel-verified.test.ts +177 -0
  8. package/src/__tests__/contacts-control-plane-proxy.test.ts +297 -6
  9. package/src/__tests__/contacts-control-plane-route-match.test.ts +16 -0
  10. package/src/__tests__/edge-auth.test.ts +253 -0
  11. package/src/__tests__/edge-guardian-auth.test.ts +198 -0
  12. package/src/__tests__/ipc-route-policy-coverage.test.ts +297 -0
  13. package/src/__tests__/ipc-route-policy.test.ts +43 -0
  14. package/src/__tests__/ipc-server-watchdog.test.ts +189 -0
  15. package/src/__tests__/live-voice-websocket.test.ts +1 -1
  16. package/src/__tests__/slack-normalize.test.ts +132 -0
  17. package/src/auth/guardian-bootstrap.ts +3 -1
  18. package/src/auth/ipc-route-policy.ts +34 -0
  19. package/src/db/assistant-db-proxy.ts +76 -7
  20. package/src/db/contact-store.ts +767 -1
  21. package/src/db/schema.ts +29 -0
  22. package/src/feature-flag-registry.json +17 -17
  23. package/src/handlers/handle-inbound.ts +9 -23
  24. package/src/http/middleware/auth.ts +193 -40
  25. package/src/http/router.ts +26 -4
  26. package/src/http/routes/channel-verification-session-proxy.ts +53 -6
  27. package/src/http/routes/contact-prompt.ts +44 -15
  28. package/src/http/routes/contacts-control-plane-proxy.ts +329 -2
  29. package/src/http/routes/contacts-control-plane-route-match.ts +12 -0
  30. package/src/http/routes/ipc-runtime-proxy.test.ts +38 -43
  31. package/src/http/routes/ipc-runtime-proxy.ts +2 -2
  32. package/src/http/routes/log-export.test.ts +1 -0
  33. package/src/http/routes/log-export.ts +9 -2
  34. package/src/http/routes/log-tail.test.ts +10 -9
  35. package/src/http/routes/log-tail.ts +5 -2
  36. package/src/http/routes/pair.ts +8 -0
  37. package/src/http/routes/twilio-voice-webhook.ts +11 -2
  38. package/src/index.ts +98 -13
  39. package/src/ipc/assistant-client.test.ts +67 -15
  40. package/src/ipc/assistant-client.ts +12 -118
  41. package/src/ipc/risk-classification-handlers.test.ts +76 -0
  42. package/src/ipc/risk-classification-handlers.ts +20 -9
  43. package/src/ipc/server.ts +113 -46
  44. package/src/logger.ts +71 -17
  45. package/src/post-assistant-ready.ts +9 -3
  46. package/src/risk/bash-risk-classifier.test.ts +106 -0
  47. package/src/risk/bash-risk-classifier.ts +19 -15
  48. package/src/risk/command-registry/commands/assistant.ts +75 -26
  49. package/src/risk/command-registry.test.ts +3 -1
  50. package/src/risk/shell-parser.test.ts +159 -0
  51. package/src/risk/shell-parser.ts +150 -19
  52. package/src/runtime/client.ts +0 -11
  53. package/src/schema.ts +32 -0
  54. package/src/slack/normalize.test.ts +5 -0
  55. package/src/slack/normalize.ts +7 -0
  56. package/src/velay/bridge-utils.ts +10 -0
  57. package/src/velay/client.test.ts +156 -0
  58. package/src/velay/client.ts +83 -0
  59. package/src/velay/http-bridge.test.ts +29 -0
  60. package/src/velay/http-bridge.ts +7 -0
  61. package/src/velay/protocol.ts +12 -1
  62. package/src/verification/outbound-voice-verification-sync.ts +171 -0
  63. package/src/verification/voice-approval-sync.ts +107 -0
@@ -1,6 +1,17 @@
1
- import { desc, eq, and, ne, or, sql } from "drizzle-orm";
1
+ import { type Database } from "bun:sqlite";
2
+
3
+ import { and, desc, eq, ne, or, sql } from "drizzle-orm";
4
+
5
+ import {
6
+ type SqliteValue,
7
+ assistantDbQuery,
8
+ assistantDbRun,
9
+ } from "./assistant-db-proxy.js";
2
10
  import { type GatewayDb, getGatewayDb } from "./connection.js";
3
11
  import { contacts, contactChannels } from "./schema.js";
12
+ import { getLogger } from "../logger.js";
13
+
14
+ const log = getLogger("contact-store");
4
15
 
5
16
  export type Contact = typeof contacts.$inferSelect;
6
17
  export type ContactChannel = typeof contactChannels.$inferSelect;
@@ -120,4 +131,759 @@ export class ContactStore {
120
131
  .where(eq(contactChannels.id, channelId))
121
132
  .run();
122
133
  }
134
+
135
+ /**
136
+ * Mark a channel as verified by guardian attestation, bypassing the
137
+ * standard challenge-code exchange. Sets `status="active"`, stamps
138
+ * `verifiedAt=now`, and sets `verifiedVia="manual"` for audit trail.
139
+ *
140
+ * Atomic + idempotent. The UPDATE is gated on the row not already being
141
+ * `(status="active" AND verified_via="manual")`, so two concurrent
142
+ * verify requests can't both write — exactly one will see `changes=1`
143
+ * and the other will see `changes=0`. Both still return the post-state
144
+ * row.
145
+ *
146
+ * Returns the channel after the write, or `null` if no channel with
147
+ * that id exists in the gateway DB.
148
+ *
149
+ * Gateway DB (source of truth) + best-effort assistant DB dual-write.
150
+ */
151
+ async markChannelVerified(channelId: string): Promise<{
152
+ channel: ContactChannel;
153
+ didWrite: boolean;
154
+ } | null> {
155
+ const now = Date.now();
156
+ const raw = (this.db as unknown as { $client: Database }).$client;
157
+ const result = raw
158
+ .prepare(
159
+ `UPDATE contact_channels
160
+ SET status = ?, verified_at = ?, verified_via = ?, updated_at = ?
161
+ WHERE id = ?
162
+ AND (status != ? OR verified_via != ? OR verified_via IS NULL)`,
163
+ )
164
+ .run("active", now, "manual", now, channelId, "active", "manual");
165
+
166
+ const after = this.db
167
+ .select()
168
+ .from(contactChannels)
169
+ .where(eq(contactChannels.id, channelId))
170
+ .get();
171
+
172
+ if (!after) return null;
173
+ const didWrite = result.changes > 0;
174
+
175
+ // Mirror the write to the assistant DB only when the gateway actually
176
+ // wrote (best-effort dual-write). Skipping the no-op case prevents
177
+ // spurious verified_at/updated_at drift in the assistant DB on idempotent
178
+ // calls. The gateway DB remains source of truth.
179
+ if (didWrite) {
180
+ try {
181
+ await assistantDbRun(
182
+ `UPDATE contact_channels
183
+ SET status = 'active', verified_at = ?, verified_via = 'manual', updated_at = ?
184
+ WHERE id = ?`,
185
+ [now, now, channelId],
186
+ );
187
+ } catch (err) {
188
+ log.warn(
189
+ { channelId, err },
190
+ "markChannelVerified: assistant DB dual-write failed (best-effort)",
191
+ );
192
+ }
193
+ }
194
+
195
+ return { channel: after, didWrite };
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Upsert (gateway DB + assistant DB dual-write)
200
+ // ---------------------------------------------------------------------------
201
+
202
+ /**
203
+ * Upsert a contact + channels in the gateway DB and dual-write the same
204
+ * change to the assistant DB (best-effort).
205
+ *
206
+ * Resolution order (mirrors the assistant's upsertContact):
207
+ * 1. Match by `params.id` if provided.
208
+ * 2. Match by (type, address) on any provided channel.
209
+ * 3. Create a new contact with a generated id.
210
+ *
211
+ * Channel sync follows the same no-reassignment path: existing channels
212
+ * on the same contact are updated; conflicting channels on a different
213
+ * contact are skipped.
214
+ *
215
+ * The gateway DB is the source of truth for auth/authz fields (id,
216
+ * displayName, role, principalId). The assistant DB receives a mirrored
217
+ * write for the assistant-only columns (notes, userFile, contactType,
218
+ * assistantContactMetadata) plus a copy of the channel rows. The
219
+ * assistant-DB dual-write is best-effort: failures are logged but do not
220
+ * fail the call. The returned `contact` shape is read back from the
221
+ * assistant DB when available, falling back to a synthetic shape built
222
+ * from the gateway row on any read-back failure.
223
+ */
224
+ async upsertContact(params: {
225
+ id?: string;
226
+ displayName: string;
227
+ role?: string;
228
+ principalId?: string | null;
229
+ notes?: string | null;
230
+ contactType?: string;
231
+ assistantMetadata?: {
232
+ species: string;
233
+ metadata?: Record<string, unknown> | null;
234
+ };
235
+ channels?: Array<{
236
+ type: string;
237
+ address: string;
238
+ isPrimary?: boolean;
239
+ externalUserId?: string | null;
240
+ externalChatId?: string | null;
241
+ status?: string;
242
+ policy?: string;
243
+ verifiedAt?: number | null;
244
+ verifiedVia?: string | null;
245
+ inviteId?: string | null;
246
+ revokedReason?: string | null;
247
+ blockedReason?: string | null;
248
+ }>;
249
+ }): Promise<{ contact: ContactWithChannels; created: boolean }> {
250
+ const now = Date.now();
251
+ let contactId = params.id;
252
+ let created = false;
253
+
254
+ // ── 1. Look up by id ──────────────────────────────────────────────
255
+ if (contactId) {
256
+ const existing = this.db
257
+ .select()
258
+ .from(contacts)
259
+ .where(eq(contacts.id, contactId))
260
+ .get();
261
+
262
+ if (existing) {
263
+ this.db
264
+ .update(contacts)
265
+ .set({
266
+ displayName: params.displayName,
267
+ role: params.role ?? existing.role,
268
+ principalId:
269
+ params.principalId !== undefined
270
+ ? params.principalId
271
+ : existing.principalId,
272
+ updatedAt: now,
273
+ })
274
+ .where(eq(contacts.id, contactId))
275
+ .run();
276
+ } else {
277
+ this.db
278
+ .insert(contacts)
279
+ .values({
280
+ id: contactId,
281
+ displayName: params.displayName,
282
+ role: params.role ?? "contact",
283
+ principalId: params.principalId ?? null,
284
+ createdAt: now,
285
+ updatedAt: now,
286
+ })
287
+ .run();
288
+ created = true;
289
+ }
290
+ }
291
+
292
+ // ── 2. Look up by channel address ─────────────────────────────────
293
+ // When matching by channel, preserve existing role/principalId — only
294
+ // overwrite when the caller explicitly provides those fields. Otherwise
295
+ // a partial upsert by channel can silently demote a guardian to "contact"
296
+ // or clear principalId, breaking auth.
297
+ if (!contactId && params.channels?.length) {
298
+ for (const ch of params.channels) {
299
+ const address = ch.address.toLowerCase();
300
+ const match = this.db
301
+ .select({ contactId: contactChannels.contactId })
302
+ .from(contactChannels)
303
+ .where(
304
+ and(
305
+ eq(contactChannels.type, ch.type),
306
+ eq(contactChannels.address, address),
307
+ ),
308
+ )
309
+ .get();
310
+
311
+ if (match) {
312
+ contactId = match.contactId;
313
+ const existingContact = this.db
314
+ .select()
315
+ .from(contacts)
316
+ .where(eq(contacts.id, contactId))
317
+ .get();
318
+ this.db
319
+ .update(contacts)
320
+ .set({
321
+ displayName: params.displayName,
322
+ role: params.role ?? existingContact?.role ?? "contact",
323
+ principalId:
324
+ params.principalId !== undefined
325
+ ? params.principalId
326
+ : existingContact?.principalId ?? null,
327
+ updatedAt: now,
328
+ })
329
+ .where(eq(contacts.id, contactId))
330
+ .run();
331
+ break;
332
+ }
333
+ }
334
+ }
335
+
336
+ // ── 3. Create new ─────────────────────────────────────────────────
337
+ if (!contactId) {
338
+ contactId = crypto.randomUUID();
339
+ this.db
340
+ .insert(contacts)
341
+ .values({
342
+ id: contactId,
343
+ displayName: params.displayName,
344
+ role: params.role ?? "contact",
345
+ principalId: params.principalId ?? null,
346
+ createdAt: now,
347
+ updatedAt: now,
348
+ })
349
+ .run();
350
+ created = true;
351
+ }
352
+
353
+ // ── 4. Sync channels (gateway DB) ─────────────────────────────────
354
+ if (params.channels?.length) {
355
+ this.syncChannels(contactId, params.channels, now);
356
+ }
357
+
358
+ // ── 5. Dual-write to assistant DB (best-effort) ───────────────────
359
+ try {
360
+ await this.dualWriteContactToAssistantDb(contactId, params, now, created);
361
+ } catch (err) {
362
+ log.warn(
363
+ { contactId, err },
364
+ "upsertContact: assistant DB dual-write failed (best-effort)",
365
+ );
366
+ }
367
+
368
+ // ── 6. Read back full contact shape (best-effort) ─────────────────
369
+ const fullContact = await this.readAssistantContact(contactId).catch(
370
+ (err) => {
371
+ log.warn(
372
+ { contactId, err },
373
+ "upsertContact: assistant DB read-back failed; returning gateway fallback",
374
+ );
375
+ return null;
376
+ },
377
+ );
378
+
379
+ if (fullContact) {
380
+ return { contact: fullContact, created };
381
+ }
382
+
383
+ // Fallback: synthesize from gateway row + provided params.
384
+ const gatewayRow = this.db
385
+ .select()
386
+ .from(contacts)
387
+ .where(eq(contacts.id, contactId))
388
+ .get()!;
389
+ return {
390
+ contact: {
391
+ id: gatewayRow.id,
392
+ displayName: gatewayRow.displayName,
393
+ role: gatewayRow.role,
394
+ principalId: gatewayRow.principalId,
395
+ notes: params.notes ?? null,
396
+ contactType: params.contactType ?? "human",
397
+ userFile: null,
398
+ createdAt: gatewayRow.createdAt,
399
+ updatedAt: gatewayRow.updatedAt,
400
+ interactionCount: 0,
401
+ lastInteraction: null,
402
+ channels: [],
403
+ },
404
+ created,
405
+ };
406
+ }
407
+
408
+ // ---------------------------------------------------------------------------
409
+ // Channel sync (gateway DB)
410
+ // ---------------------------------------------------------------------------
411
+
412
+ private syncChannels(
413
+ contactId: string,
414
+ channels: NonNullable<
415
+ Parameters<ContactStore["upsertContact"]>[0]["channels"]
416
+ >,
417
+ now: number,
418
+ ): void {
419
+ for (const ch of channels) {
420
+ const address = ch.address.toLowerCase();
421
+
422
+ const existing = this.db
423
+ .select()
424
+ .from(contactChannels)
425
+ .where(
426
+ and(
427
+ eq(contactChannels.contactId, contactId),
428
+ eq(contactChannels.type, ch.type),
429
+ eq(contactChannels.address, address),
430
+ ),
431
+ )
432
+ .get();
433
+
434
+ if (existing) {
435
+ const isBlocked = existing.status === "blocked";
436
+ const updateSet: Record<string, unknown> = { updatedAt: now };
437
+ if (ch.isPrimary !== undefined) updateSet.isPrimary = ch.isPrimary;
438
+ if (ch.externalUserId !== undefined)
439
+ updateSet.externalUserId = ch.externalUserId;
440
+ if (ch.externalChatId !== undefined)
441
+ updateSet.externalChatId = ch.externalChatId;
442
+ if (!isBlocked) {
443
+ if (ch.status !== undefined) updateSet.status = ch.status;
444
+ if (ch.policy !== undefined) updateSet.policy = ch.policy;
445
+ if (ch.revokedReason !== undefined)
446
+ updateSet.revokedReason = ch.revokedReason;
447
+ if (ch.blockedReason !== undefined)
448
+ updateSet.blockedReason = ch.blockedReason;
449
+ }
450
+ if (ch.verifiedAt !== undefined) updateSet.verifiedAt = ch.verifiedAt;
451
+ if (ch.verifiedVia !== undefined) updateSet.verifiedVia = ch.verifiedVia;
452
+ if (ch.inviteId !== undefined) updateSet.inviteId = ch.inviteId;
453
+ this.db
454
+ .update(contactChannels)
455
+ .set(updateSet)
456
+ .where(eq(contactChannels.id, existing.id))
457
+ .run();
458
+ continue;
459
+ }
460
+
461
+ // Cross-contact conflict check — skip to avoid unique-address violations
462
+ const conflict = this.db
463
+ .select({ id: contactChannels.id })
464
+ .from(contactChannels)
465
+ .where(
466
+ and(
467
+ eq(contactChannels.type, ch.type),
468
+ eq(contactChannels.address, address),
469
+ ),
470
+ )
471
+ .get();
472
+ if (conflict) continue;
473
+
474
+ // New channel
475
+ this.db
476
+ .insert(contactChannels)
477
+ .values({
478
+ id: crypto.randomUUID(),
479
+ contactId,
480
+ type: ch.type,
481
+ address,
482
+ isPrimary: ch.isPrimary ?? false,
483
+ externalUserId: ch.externalUserId ?? null,
484
+ externalChatId: ch.externalChatId ?? null,
485
+ status: (ch.status as ContactChannel["status"]) ?? "unverified",
486
+ policy: (ch.policy as ContactChannel["policy"]) ?? "allow",
487
+ verifiedAt: ch.verifiedAt ?? null,
488
+ verifiedVia: ch.verifiedVia ?? null,
489
+ inviteId: ch.inviteId ?? null,
490
+ revokedReason: ch.revokedReason ?? null,
491
+ blockedReason: ch.blockedReason ?? null,
492
+ interactionCount: 0,
493
+ createdAt: now,
494
+ updatedAt: now,
495
+ })
496
+ .run();
497
+ }
498
+ }
499
+
500
+ // ---------------------------------------------------------------------------
501
+ // Assistant DB dual-write
502
+ // ---------------------------------------------------------------------------
503
+
504
+ /**
505
+ * Mirror the contact + channels write to the assistant DB.
506
+ *
507
+ * - For an existing contact, build a dynamic SET clause that only touches
508
+ * fields the caller explicitly provided. Without this guard, a partial
509
+ * upsert (e.g. `{displayName: "X"}`) would clobber `notes`, `role`,
510
+ * `contact_type`, and `principal_id` to default values — silently losing
511
+ * data that the assistant DB may have but the gateway DB doesn't carry.
512
+ *
513
+ * - For a new contact, INSERT the full row with a freshly resolved
514
+ * `user_file` slug.
515
+ *
516
+ * - For each channel: UPDATE if a row already exists on the same contact;
517
+ * otherwise INSERT (skipping addresses claimed by a different contact).
518
+ */
519
+ private async dualWriteContactToAssistantDb(
520
+ contactId: string,
521
+ params: Parameters<ContactStore["upsertContact"]>[0],
522
+ now: number,
523
+ isNew: boolean,
524
+ ): Promise<void> {
525
+ const existing = await assistantDbQuery<{ userFile: string | null }>(
526
+ "SELECT user_file AS userFile FROM contacts WHERE id = ?",
527
+ [contactId],
528
+ );
529
+
530
+ if (existing.length) {
531
+ // Dynamic SET clause: only touch fields the caller actually provided.
532
+ const setParts: string[] = ["display_name = ?", "updated_at = ?"];
533
+ const setParams: SqliteValue[] = [params.displayName, now];
534
+
535
+ if (params.notes !== undefined) {
536
+ setParts.push("notes = ?");
537
+ setParams.push(params.notes ?? null);
538
+ }
539
+ if (params.role !== undefined) {
540
+ setParts.push("role = ?");
541
+ setParams.push(params.role);
542
+ }
543
+ if (params.contactType !== undefined) {
544
+ setParts.push("contact_type = ?");
545
+ setParams.push(params.contactType);
546
+ }
547
+ if (params.principalId !== undefined) {
548
+ setParts.push("principal_id = ?");
549
+ setParams.push(params.principalId);
550
+ }
551
+ setParams.push(contactId);
552
+
553
+ await assistantDbRun(
554
+ `UPDATE contacts SET ${setParts.join(", ")} WHERE id = ?`,
555
+ setParams,
556
+ );
557
+ } else {
558
+ const userFile = await this.resolveAssistantUserFileSlug(
559
+ params.displayName,
560
+ params.principalId ?? null,
561
+ );
562
+ await assistantDbRun(
563
+ `INSERT INTO contacts
564
+ (id, display_name, notes, role, contact_type, principal_id,
565
+ user_file, created_at, updated_at)
566
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
567
+ [
568
+ contactId,
569
+ params.displayName,
570
+ params.notes ?? null,
571
+ params.role ?? "contact",
572
+ params.contactType ?? "human",
573
+ params.principalId ?? null,
574
+ userFile,
575
+ now,
576
+ now,
577
+ ],
578
+ );
579
+ }
580
+
581
+ // Assistant contact metadata (assistant-type contacts only).
582
+ if (params.contactType === "assistant" && params.assistantMetadata) {
583
+ await assistantDbRun(
584
+ `INSERT INTO assistant_contact_metadata (contact_id, species, metadata)
585
+ VALUES (?, ?, ?)
586
+ ON CONFLICT(contact_id) DO UPDATE SET
587
+ species = excluded.species,
588
+ metadata = excluded.metadata`,
589
+ [
590
+ contactId,
591
+ params.assistantMetadata.species,
592
+ params.assistantMetadata.metadata != null
593
+ ? JSON.stringify(params.assistantMetadata.metadata)
594
+ : null,
595
+ ],
596
+ );
597
+ }
598
+
599
+ // Sync channels to the assistant DB.
600
+ for (const ch of params.channels ?? []) {
601
+ const address = ch.address.toLowerCase();
602
+
603
+ const existingCh = await assistantDbQuery<{ id: string; status: string }>(
604
+ "SELECT id, status FROM contact_channels WHERE contact_id = ? AND type = ? AND address = ?",
605
+ [contactId, ch.type, address],
606
+ );
607
+
608
+ if (existingCh.length) {
609
+ const isBlocked = existingCh[0].status === "blocked";
610
+ const setParts: string[] = [
611
+ "external_user_id = ?",
612
+ "external_chat_id = ?",
613
+ "updated_at = ?",
614
+ ];
615
+ const setParams: SqliteValue[] = [
616
+ ch.externalUserId ?? null,
617
+ ch.externalChatId ?? null,
618
+ now,
619
+ ];
620
+ if (!isBlocked) {
621
+ if (ch.status !== undefined) {
622
+ setParts.push("status = ?");
623
+ setParams.push(ch.status);
624
+ }
625
+ if (ch.policy !== undefined) {
626
+ setParts.push("policy = ?");
627
+ setParams.push(ch.policy);
628
+ }
629
+ }
630
+ setParams.push(existingCh[0].id);
631
+ await assistantDbRun(
632
+ `UPDATE contact_channels SET ${setParts.join(", ")} WHERE id = ?`,
633
+ setParams,
634
+ );
635
+ } else {
636
+ // Skip if an address conflict exists on a different contact.
637
+ const conflict = await assistantDbQuery<{ id: string }>(
638
+ "SELECT id FROM contact_channels WHERE type = ? AND address = ?",
639
+ [ch.type, address],
640
+ );
641
+ if (conflict.length) continue;
642
+
643
+ await assistantDbRun(
644
+ `INSERT INTO contact_channels
645
+ (id, contact_id, type, address, is_primary,
646
+ external_user_id, external_chat_id,
647
+ status, policy, interaction_count, created_at, updated_at)
648
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
649
+ [
650
+ crypto.randomUUID(),
651
+ contactId,
652
+ ch.type,
653
+ address,
654
+ ch.isPrimary ? 1 : 0,
655
+ ch.externalUserId ?? null,
656
+ ch.externalChatId ?? null,
657
+ ch.status ?? "unverified",
658
+ ch.policy ?? "allow",
659
+ now,
660
+ now,
661
+ ],
662
+ );
663
+ }
664
+ }
665
+
666
+ // Touch the variable so the parameter isn't flagged unused.
667
+ void isNew;
668
+ }
669
+
670
+ /**
671
+ * Compute a unique `user_file` slug for a new contact in the assistant DB.
672
+ *
673
+ * Mirrors the assistant's slug logic in two ways:
674
+ * 1. Sibling contacts that share a `principalId` reuse the existing
675
+ * `userFile` of any sibling — every channel for one principal must
676
+ * resolve to the same persona + journal slug.
677
+ * 2. Otherwise: lowercase kebab from `displayName`, collision-suffixed
678
+ * with `-2`, `-3`, etc.
679
+ */
680
+ private async resolveAssistantUserFileSlug(
681
+ displayName: string,
682
+ principalId: string | null,
683
+ ): Promise<string> {
684
+ if (principalId) {
685
+ const sibling = await assistantDbQuery<{ userFile: string | null }>(
686
+ `SELECT user_file AS userFile
687
+ FROM contacts
688
+ WHERE principal_id = ?
689
+ AND user_file IS NOT NULL
690
+ LIMIT 1`,
691
+ [principalId],
692
+ );
693
+ if (sibling.length && sibling[0].userFile) {
694
+ return sibling[0].userFile;
695
+ }
696
+ }
697
+
698
+ const slug =
699
+ displayName
700
+ .toLowerCase()
701
+ .replace(/[^a-z0-9]+/g, "-")
702
+ .replace(/^-+|-+$/g, "")
703
+ .slice(0, 100) || "user";
704
+
705
+ const rows = await assistantDbQuery<{ userFile: string | null }>(
706
+ "SELECT user_file AS userFile FROM contacts WHERE user_file LIKE ?",
707
+ [`${slug}%`],
708
+ );
709
+ const taken = new Set(
710
+ rows.map((r) => r.userFile?.toLowerCase()).filter(Boolean),
711
+ );
712
+
713
+ const base = `${slug}.md`;
714
+ if (!taken.has(base)) return base;
715
+
716
+ for (let i = 2; i <= 100; i++) {
717
+ const candidate = `${slug}-${i}.md`;
718
+ if (!taken.has(candidate)) return candidate;
719
+ }
720
+ return `${slug}-${crypto.randomUUID().slice(0, 8)}.md`;
721
+ }
722
+
723
+ /**
724
+ * Read a contact + channels from the assistant DB and return the full
725
+ * `ContactWithChannels` shape used in API responses. Returns null if the
726
+ * contact is not found in the assistant DB.
727
+ */
728
+ private async readAssistantContact(
729
+ contactId: string,
730
+ ): Promise<ContactWithChannels | null> {
731
+ const rows = await assistantDbQuery<AssistantContactRow>(
732
+ `SELECT c.id,
733
+ c.display_name AS displayName,
734
+ c.notes,
735
+ c.role,
736
+ c.contact_type AS contactType,
737
+ c.principal_id AS principalId,
738
+ c.user_file AS userFile,
739
+ c.created_at AS createdAt,
740
+ c.updated_at AS updatedAt,
741
+ cc.id AS channelId,
742
+ cc.type AS channelType,
743
+ cc.address,
744
+ cc.is_primary AS isPrimary,
745
+ cc.external_user_id AS externalUserId,
746
+ cc.external_chat_id AS externalChatId,
747
+ cc.status AS channelStatus,
748
+ cc.policy AS channelPolicy,
749
+ cc.verified_at AS verifiedAt,
750
+ cc.verified_via AS verifiedVia,
751
+ cc.invite_id AS inviteId,
752
+ cc.revoked_reason AS revokedReason,
753
+ cc.blocked_reason AS blockedReason,
754
+ cc.last_seen_at AS lastSeenAt,
755
+ cc.interaction_count AS interactionCount,
756
+ cc.last_interaction AS lastInteraction,
757
+ cc.created_at AS channelCreatedAt,
758
+ cc.updated_at AS channelUpdatedAt
759
+ FROM contacts c
760
+ LEFT JOIN contact_channels cc ON cc.contact_id = c.id
761
+ WHERE c.id = ?
762
+ ORDER BY cc.is_primary DESC, cc.created_at ASC`,
763
+ [contactId],
764
+ );
765
+
766
+ if (!rows.length) return null;
767
+
768
+ const first = rows[0];
769
+ const channels = rows
770
+ .filter((r) => r.channelId !== null)
771
+ .map((r) => ({
772
+ id: r.channelId!,
773
+ contactId,
774
+ type: r.channelType!,
775
+ address: r.address!,
776
+ isPrimary: Boolean(r.isPrimary),
777
+ externalUserId: r.externalUserId,
778
+ externalChatId: r.externalChatId,
779
+ status: r.channelStatus,
780
+ policy: r.channelPolicy,
781
+ verifiedAt: r.verifiedAt,
782
+ verifiedVia: r.verifiedVia,
783
+ inviteId: r.inviteId,
784
+ revokedReason: r.revokedReason,
785
+ blockedReason: r.blockedReason,
786
+ lastSeenAt: r.lastSeenAt,
787
+ interactionCount: r.interactionCount ?? 0,
788
+ lastInteraction: r.lastInteraction,
789
+ createdAt: r.channelCreatedAt,
790
+ updatedAt: r.channelUpdatedAt,
791
+ }));
792
+
793
+ const interactionCount = channels.reduce(
794
+ (sum, ch) => sum + (ch.interactionCount ?? 0),
795
+ 0,
796
+ );
797
+ const lastInteraction =
798
+ channels.reduce(
799
+ (max, ch) => Math.max(max, ch.lastInteraction ?? 0),
800
+ 0,
801
+ ) || null;
802
+
803
+ return {
804
+ id: first.id,
805
+ displayName: first.displayName,
806
+ notes: first.notes,
807
+ role: first.role,
808
+ contactType: first.contactType,
809
+ principalId: first.principalId,
810
+ userFile: first.userFile,
811
+ createdAt: first.createdAt,
812
+ updatedAt: first.updatedAt,
813
+ interactionCount,
814
+ lastInteraction,
815
+ channels,
816
+ };
817
+ }
818
+ }
819
+
820
+ // ---------------------------------------------------------------------------
821
+ // Public response shapes
822
+ // ---------------------------------------------------------------------------
823
+
824
+ export interface ContactChannelShape {
825
+ id: string;
826
+ contactId: string;
827
+ type: string;
828
+ address: string;
829
+ isPrimary: boolean;
830
+ externalUserId: string | null;
831
+ externalChatId: string | null;
832
+ status: string | null;
833
+ policy: string | null;
834
+ verifiedAt: number | null;
835
+ verifiedVia: string | null;
836
+ inviteId: string | null;
837
+ revokedReason: string | null;
838
+ blockedReason: string | null;
839
+ lastSeenAt: number | null;
840
+ interactionCount: number;
841
+ lastInteraction: number | null;
842
+ createdAt: number | null;
843
+ updatedAt: number | null;
844
+ }
845
+
846
+ export interface ContactWithChannels {
847
+ id: string;
848
+ displayName: string;
849
+ notes: string | null;
850
+ role: string;
851
+ contactType: string;
852
+ principalId: string | null;
853
+ userFile: string | null;
854
+ createdAt: number;
855
+ updatedAt: number;
856
+ interactionCount: number;
857
+ lastInteraction: number | null;
858
+ channels: ContactChannelShape[];
859
+ }
860
+
861
+ interface AssistantContactRow {
862
+ id: string;
863
+ displayName: string;
864
+ notes: string | null;
865
+ role: string;
866
+ contactType: string;
867
+ principalId: string | null;
868
+ userFile: string | null;
869
+ createdAt: number;
870
+ updatedAt: number;
871
+ channelId: string | null;
872
+ channelType: string | null;
873
+ address: string | null;
874
+ isPrimary: number | null;
875
+ externalUserId: string | null;
876
+ externalChatId: string | null;
877
+ channelStatus: string | null;
878
+ channelPolicy: string | null;
879
+ verifiedAt: number | null;
880
+ verifiedVia: string | null;
881
+ inviteId: string | null;
882
+ revokedReason: string | null;
883
+ blockedReason: string | null;
884
+ lastSeenAt: number | null;
885
+ interactionCount: number | null;
886
+ lastInteraction: number | null;
887
+ channelCreatedAt: number | null;
888
+ channelUpdatedAt: number | null;
123
889
  }