@vellumai/assistant 0.3.2 → 0.3.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 (52) hide show
  1. package/README.md +82 -13
  2. package/package.json +1 -1
  3. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +16 -0
  4. package/src/__tests__/app-git-history.test.ts +22 -27
  5. package/src/__tests__/app-git-service.test.ts +44 -78
  6. package/src/__tests__/channel-approval-routes.test.ts +930 -14
  7. package/src/__tests__/channel-approval.test.ts +2 -0
  8. package/src/__tests__/channel-delivery-store.test.ts +104 -1
  9. package/src/__tests__/channel-guardian.test.ts +184 -1
  10. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  11. package/src/__tests__/daemon-server-session-init.test.ts +5 -0
  12. package/src/__tests__/gateway-only-enforcement.test.ts +87 -8
  13. package/src/__tests__/handlers-telegram-config.test.ts +82 -0
  14. package/src/__tests__/handlers-twilio-config.test.ts +665 -5
  15. package/src/__tests__/ingress-url-consistency.test.ts +64 -0
  16. package/src/__tests__/ipc-snapshot.test.ts +10 -0
  17. package/src/__tests__/run-orchestrator.test.ts +1 -1
  18. package/src/__tests__/session-process-bridge.test.ts +2 -0
  19. package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
  20. package/src/calls/twilio-config.ts +10 -1
  21. package/src/calls/twilio-rest.ts +70 -0
  22. package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
  23. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  24. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  25. package/src/config/schema.ts +3 -0
  26. package/src/config/vellum-skills/twilio-setup/SKILL.md +11 -4
  27. package/src/daemon/handlers/config.ts +168 -15
  28. package/src/daemon/handlers/sessions.ts +5 -3
  29. package/src/daemon/handlers/skills.ts +61 -17
  30. package/src/daemon/ipc-contract-inventory.json +4 -0
  31. package/src/daemon/ipc-contract.ts +10 -0
  32. package/src/daemon/session-agent-loop.ts +4 -0
  33. package/src/daemon/session-process.ts +20 -3
  34. package/src/daemon/session-slash.ts +50 -2
  35. package/src/daemon/session-surfaces.ts +17 -1
  36. package/src/inbound/public-ingress-urls.ts +20 -3
  37. package/src/index.ts +1 -23
  38. package/src/memory/app-git-service.ts +24 -0
  39. package/src/memory/app-store.ts +0 -21
  40. package/src/memory/channel-delivery-store.ts +74 -3
  41. package/src/memory/channel-guardian-store.ts +54 -26
  42. package/src/memory/conversation-key-store.ts +20 -0
  43. package/src/memory/conversation-store.ts +14 -2
  44. package/src/memory/db.ts +12 -0
  45. package/src/memory/schema.ts +5 -0
  46. package/src/runtime/http-server.ts +13 -5
  47. package/src/runtime/routes/channel-routes.ts +134 -43
  48. package/src/skills/clawhub.ts +6 -2
  49. package/src/subagent/manager.ts +4 -1
  50. package/src/subagent/types.ts +2 -0
  51. package/src/tools/skills/vellum-catalog.ts +45 -2
  52. package/src/tools/subagent/spawn.ts +2 -0
@@ -58,6 +58,7 @@ export interface GuardianApprovalRequest {
58
58
  id: string;
59
59
  runId: string;
60
60
  conversationId: string;
61
+ assistantId: string;
61
62
  channel: string;
62
63
  requesterExternalUserId: string;
63
64
  requesterChatId: string;
@@ -114,6 +115,7 @@ function rowToApprovalRequest(row: typeof channelGuardianApprovalRequests.$infer
114
115
  id: row.id,
115
116
  runId: row.runId,
116
117
  conversationId: row.conversationId,
118
+ assistantId: row.assistantId,
117
119
  channel: row.channel,
118
120
  requesterExternalUserId: row.requesterExternalUserId,
119
121
  requesterChatId: row.requesterChatId,
@@ -305,6 +307,7 @@ export function consumeChallenge(
305
307
  export function createApprovalRequest(params: {
306
308
  runId: string;
307
309
  conversationId: string;
310
+ assistantId?: string;
308
311
  channel: string;
309
312
  requesterExternalUserId: string;
310
313
  requesterChatId: string;
@@ -323,6 +326,7 @@ export function createApprovalRequest(params: {
323
326
  id,
324
327
  runId: params.runId,
325
328
  conversationId: params.conversationId,
329
+ assistantId: params.assistantId ?? 'self',
326
330
  channel: params.channel,
327
331
  requesterExternalUserId: params.requesterExternalUserId,
328
332
  requesterChatId: params.requesterChatId,
@@ -388,25 +392,32 @@ export function getUnresolvedApprovalForRun(runId: string): GuardianApprovalRequ
388
392
  /**
389
393
  * Find a pending guardian approval request by the guardian's chat ID.
390
394
  * Used when the guardian sends a decision from their chat.
395
+ *
396
+ * When `assistantId` is provided, the lookup is scoped to that assistant,
397
+ * preventing cross-assistant approval consumption in shared guardian chats.
391
398
  */
392
399
  export function getPendingApprovalByGuardianChat(
393
400
  channel: string,
394
401
  guardianChatId: string,
402
+ assistantId?: string,
395
403
  ): GuardianApprovalRequest | null {
396
404
  const db = getDb();
397
405
  const now = Date.now();
398
406
 
407
+ const conditions = [
408
+ eq(channelGuardianApprovalRequests.channel, channel),
409
+ eq(channelGuardianApprovalRequests.guardianChatId, guardianChatId),
410
+ eq(channelGuardianApprovalRequests.status, 'pending'),
411
+ gt(channelGuardianApprovalRequests.expiresAt, now),
412
+ ];
413
+ if (assistantId) {
414
+ conditions.push(eq(channelGuardianApprovalRequests.assistantId, assistantId));
415
+ }
416
+
399
417
  const row = db
400
418
  .select()
401
419
  .from(channelGuardianApprovalRequests)
402
- .where(
403
- and(
404
- eq(channelGuardianApprovalRequests.channel, channel),
405
- eq(channelGuardianApprovalRequests.guardianChatId, guardianChatId),
406
- eq(channelGuardianApprovalRequests.status, 'pending'),
407
- gt(channelGuardianApprovalRequests.expiresAt, now),
408
- ),
409
- )
420
+ .where(and(...conditions))
410
421
  .orderBy(desc(channelGuardianApprovalRequests.createdAt))
411
422
  .get();
412
423
 
@@ -418,27 +429,34 @@ export function getPendingApprovalByGuardianChat(
418
429
  * guardian chat, and channel. Used when a callback button provides a run ID,
419
430
  * so the decision is applied to exactly the right approval even when
420
431
  * multiple approvals target the same guardian chat.
432
+ *
433
+ * When `assistantId` is provided, the lookup is further scoped to that
434
+ * assistant to prevent cross-assistant approval consumption.
421
435
  */
422
436
  export function getPendingApprovalByRunAndGuardianChat(
423
437
  runId: string,
424
438
  channel: string,
425
439
  guardianChatId: string,
440
+ assistantId?: string,
426
441
  ): GuardianApprovalRequest | null {
427
442
  const db = getDb();
428
443
  const now = Date.now();
429
444
 
445
+ const conditions = [
446
+ eq(channelGuardianApprovalRequests.runId, runId),
447
+ eq(channelGuardianApprovalRequests.channel, channel),
448
+ eq(channelGuardianApprovalRequests.guardianChatId, guardianChatId),
449
+ eq(channelGuardianApprovalRequests.status, 'pending'),
450
+ gt(channelGuardianApprovalRequests.expiresAt, now),
451
+ ];
452
+ if (assistantId) {
453
+ conditions.push(eq(channelGuardianApprovalRequests.assistantId, assistantId));
454
+ }
455
+
430
456
  const row = db
431
457
  .select()
432
458
  .from(channelGuardianApprovalRequests)
433
- .where(
434
- and(
435
- eq(channelGuardianApprovalRequests.runId, runId),
436
- eq(channelGuardianApprovalRequests.channel, channel),
437
- eq(channelGuardianApprovalRequests.guardianChatId, guardianChatId),
438
- eq(channelGuardianApprovalRequests.status, 'pending'),
439
- gt(channelGuardianApprovalRequests.expiresAt, now),
440
- ),
441
- )
459
+ .where(and(...conditions))
442
460
  .get();
443
461
 
444
462
  return row ? rowToApprovalRequest(row) : null;
@@ -448,25 +466,32 @@ export function getPendingApprovalByRunAndGuardianChat(
448
466
  * Return all pending (non-expired) guardian approval requests for a given
449
467
  * guardian chat and channel. Used to detect ambiguity when a guardian sends
450
468
  * a plain-text decision while multiple approvals are pending.
469
+ *
470
+ * When `assistantId` is provided, the results are scoped to that assistant
471
+ * to prevent cross-assistant approval consumption.
451
472
  */
452
473
  export function getAllPendingApprovalsByGuardianChat(
453
474
  channel: string,
454
475
  guardianChatId: string,
476
+ assistantId?: string,
455
477
  ): GuardianApprovalRequest[] {
456
478
  const db = getDb();
457
479
  const now = Date.now();
458
480
 
481
+ const conditions = [
482
+ eq(channelGuardianApprovalRequests.channel, channel),
483
+ eq(channelGuardianApprovalRequests.guardianChatId, guardianChatId),
484
+ eq(channelGuardianApprovalRequests.status, 'pending'),
485
+ gt(channelGuardianApprovalRequests.expiresAt, now),
486
+ ];
487
+ if (assistantId) {
488
+ conditions.push(eq(channelGuardianApprovalRequests.assistantId, assistantId));
489
+ }
490
+
459
491
  const rows = db
460
492
  .select()
461
493
  .from(channelGuardianApprovalRequests)
462
- .where(
463
- and(
464
- eq(channelGuardianApprovalRequests.channel, channel),
465
- eq(channelGuardianApprovalRequests.guardianChatId, guardianChatId),
466
- eq(channelGuardianApprovalRequests.status, 'pending'),
467
- gt(channelGuardianApprovalRequests.expiresAt, now),
468
- ),
469
- )
494
+ .where(and(...conditions))
470
495
  .orderBy(desc(channelGuardianApprovalRequests.createdAt))
471
496
  .all();
472
497
 
@@ -525,7 +550,7 @@ export interface VerificationRateLimit {
525
550
  actorChatId: string;
526
551
  /** Individual attempt timestamps (epoch-ms) within the sliding window. */
527
552
  attemptTimestamps: number[];
528
- /** Derived count of attempts currently inside the window (convenience). */
553
+ /** Total stored attempt count (may include expired timestamps; use lockedUntil for enforcement decisions). */
529
554
  invalidAttempts: number;
530
555
  lockedUntil: number | null;
531
556
  createdAt: number;
@@ -645,6 +670,9 @@ export function recordInvalidAttempt(
645
670
  channel,
646
671
  actorExternalUserId,
647
672
  actorChatId,
673
+ // Legacy columns kept for backward compatibility with upgraded databases
674
+ invalidAttempts: 0,
675
+ windowStartedAt: 0,
648
676
  attemptTimestampsJson: JSON.stringify(timestamps),
649
677
  lockedUntil,
650
678
  createdAt: now,
@@ -67,6 +67,26 @@ export function setConversationKey(conversationKey: string, conversationId: stri
67
67
  .run();
68
68
  }
69
69
 
70
+ /**
71
+ * Insert a conversation-key mapping only if the key does not already exist.
72
+ *
73
+ * Uses `onConflictDoNothing` on the unique `conversationKey` column to
74
+ * avoid unique-constraint races when concurrent first messages attempt
75
+ * to migrate a legacy key to a new scoped alias.
76
+ */
77
+ export function setConversationKeyIfAbsent(conversationKey: string, conversationId: string): void {
78
+ const db = getDb();
79
+ db.insert(conversationKeys)
80
+ .values({
81
+ id: uuid(),
82
+ conversationKey,
83
+ conversationId,
84
+ createdAt: Date.now(),
85
+ })
86
+ .onConflictDoNothing()
87
+ .run();
88
+ }
89
+
70
90
  /**
71
91
  * Get or create a conversation for the given conversationKey.
72
92
  *
@@ -84,7 +84,7 @@ export function deleteConversation(id: string): void {
84
84
  });
85
85
  }
86
86
 
87
- export function listConversations(limit?: number, includeBackground = false) {
87
+ export function listConversations(limit?: number, includeBackground = false, offset = 0) {
88
88
  const db = getDb();
89
89
  const where = includeBackground ? undefined : sql`${conversations.threadType} != 'background'`;
90
90
  const query = db
@@ -92,10 +92,22 @@ export function listConversations(limit?: number, includeBackground = false) {
92
92
  .from(conversations)
93
93
  .where(where)
94
94
  .orderBy(desc(conversations.updatedAt))
95
- .limit(limit ?? 100);
95
+ .limit(limit ?? 100)
96
+ .offset(offset);
96
97
  return query.all();
97
98
  }
98
99
 
100
+ export function countConversations(includeBackground = false): number {
101
+ const db = getDb();
102
+ const where = includeBackground ? undefined : sql`${conversations.threadType} != 'background'`;
103
+ const [{ total }] = db
104
+ .select({ total: count() })
105
+ .from(conversations)
106
+ .where(where)
107
+ .all();
108
+ return total;
109
+ }
110
+
99
111
  export function getLatestConversation() {
100
112
  const db = getDb();
101
113
  const result = db
package/src/memory/db.ts CHANGED
@@ -992,6 +992,10 @@ export function initializeDb(): void {
992
992
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_channel_guardian_approval_run ON channel_guardian_approval_requests(run_id, status)`);
993
993
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_channel_guardian_approval_status ON channel_guardian_approval_requests(status)`);
994
994
 
995
+ // Migration: add assistant_id column to scope approval requests by assistant.
996
+ // Existing rows default to 'self' for backward compatibility.
997
+ try { database.run(/*sql*/ `ALTER TABLE channel_guardian_approval_requests ADD COLUMN assistant_id TEXT NOT NULL DEFAULT 'self'`); } catch { /* already exists */ }
998
+
995
999
  // ── Channel Guardian Verification Rate Limits ─────────────────────
996
1000
 
997
1001
  database.run(/*sql*/ `
@@ -1001,6 +1005,8 @@ export function initializeDb(): void {
1001
1005
  channel TEXT NOT NULL,
1002
1006
  actor_external_user_id TEXT NOT NULL,
1003
1007
  actor_chat_id TEXT NOT NULL,
1008
+ invalid_attempts INTEGER NOT NULL DEFAULT 0,
1009
+ window_started_at INTEGER NOT NULL DEFAULT 0,
1004
1010
  attempt_timestamps_json TEXT NOT NULL DEFAULT '[]',
1005
1011
  locked_until INTEGER,
1006
1012
  created_at INTEGER NOT NULL,
@@ -1013,6 +1019,12 @@ export function initializeDb(): void {
1013
1019
  // doesn't support DROP COLUMN in older versions) but are no longer read by the app.
1014
1020
  try { database.run(/*sql*/ `ALTER TABLE channel_guardian_rate_limits ADD COLUMN attempt_timestamps_json TEXT NOT NULL DEFAULT '[]'`); } catch { /* already exists */ }
1015
1021
 
1022
+ // Migration: re-add legacy columns for databases created during the brief window when
1023
+ // PR #6748 was live (columns were absent from CREATE TABLE). These columns are not read
1024
+ // by app logic but must exist so drizzle inserts don't fail.
1025
+ try { database.run(/*sql*/ `ALTER TABLE channel_guardian_rate_limits ADD COLUMN invalid_attempts INTEGER NOT NULL DEFAULT 0`); } catch { /* already exists */ }
1026
+ try { database.run(/*sql*/ `ALTER TABLE channel_guardian_rate_limits ADD COLUMN window_started_at INTEGER NOT NULL DEFAULT 0`); } catch { /* already exists */ }
1027
+
1016
1028
  database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_guardian_rate_limits_actor ON channel_guardian_rate_limits(assistant_id, channel, actor_external_user_id, actor_chat_id)`);
1017
1029
 
1018
1030
  migrateMemoryFtsBackfill(database);
@@ -646,6 +646,7 @@ export const channelGuardianApprovalRequests = sqliteTable('channel_guardian_app
646
646
  id: text('id').primaryKey(),
647
647
  runId: text('run_id').notNull(),
648
648
  conversationId: text('conversation_id').notNull(),
649
+ assistantId: text('assistant_id').notNull().default('self'),
649
650
  channel: text('channel').notNull(),
650
651
  requesterExternalUserId: text('requester_external_user_id').notNull(),
651
652
  requesterChatId: text('requester_chat_id').notNull(),
@@ -669,6 +670,10 @@ export const channelGuardianRateLimits = sqliteTable('channel_guardian_rate_limi
669
670
  channel: text('channel').notNull(),
670
671
  actorExternalUserId: text('actor_external_user_id').notNull(),
671
672
  actorChatId: text('actor_chat_id').notNull(),
673
+ // Legacy columns kept with defaults for backward compatibility with upgraded databases
674
+ // that still have the old NOT NULL columns without DEFAULT. Not read by app logic.
675
+ invalidAttempts: integer('invalid_attempts').notNull().default(0),
676
+ windowStartedAt: integer('window_started_at').notNull().default(0),
672
677
  attemptTimestampsJson: text('attempt_timestamps_json').notNull().default('[]'),
673
678
  lockedUntil: integer('locked_until'),
674
679
  createdAt: integer('created_at').notNull(),
@@ -154,13 +154,16 @@ const GATEWAY_SUBPATH_MAP: Record<string, string> = {
154
154
  voice: 'voice-webhook',
155
155
  status: 'status',
156
156
  'connect-action': 'connect-action',
157
+ sms: 'sms',
157
158
  };
158
159
 
159
160
  /**
160
161
  * Direct Twilio webhook subpaths that are blocked in gateway_only mode.
162
+ * Includes all public-facing webhook paths (voice, status, connect-action, SMS)
163
+ * because the runtime must never serve as a direct ingress for external webhooks.
161
164
  * Internal forwarding endpoints (gateway→runtime) are unaffected.
162
165
  */
163
- const GATEWAY_ONLY_BLOCKED_SUBPATHS = new Set(['voice-webhook', 'status', 'connect-action']);
166
+ const GATEWAY_ONLY_BLOCKED_SUBPATHS = new Set(['voice-webhook', 'status', 'connect-action', 'sms']);
164
167
 
165
168
  /**
166
169
  * Check if a request origin is from a private/internal network address.
@@ -616,7 +619,7 @@ export class RuntimeHttpServer {
616
619
  const assistantId = match[1];
617
620
  const endpoint = match[2];
618
621
  log.warn({ endpoint, assistantId }, '[deprecated] /v1/assistants/:assistantId/... route used; migrate to /v1/...');
619
- return this.dispatchEndpoint(endpoint, req, url);
622
+ return this.dispatchEndpoint(endpoint, req, url, assistantId);
620
623
  }
621
624
 
622
625
  /**
@@ -628,6 +631,7 @@ export class RuntimeHttpServer {
628
631
  endpoint: string,
629
632
  req: Request,
630
633
  url: URL,
634
+ assistantId: string = 'self',
631
635
  ): Promise<Response> {
632
636
  try {
633
637
  if (endpoint === 'health' && req.method === 'GET') {
@@ -636,7 +640,9 @@ export class RuntimeHttpServer {
636
640
 
637
641
  if (endpoint === 'conversations' && req.method === 'GET') {
638
642
  const limit = Number(url.searchParams.get('limit') ?? 50);
639
- const conversations = conversationStore.listConversations(limit);
643
+ const offset = Number(url.searchParams.get('offset') ?? 0);
644
+ const conversations = conversationStore.listConversations(limit, false, offset);
645
+ const totalCount = conversationStore.countConversations();
640
646
  const bindings = externalConversationStore.getBindingsForConversations(
641
647
  conversations.map((c) => c.id),
642
648
  );
@@ -659,6 +665,7 @@ export class RuntimeHttpServer {
659
665
  } : {}),
660
666
  };
661
667
  }),
668
+ hasMore: offset + conversations.length < totalCount,
662
669
  });
663
670
  }
664
671
 
@@ -732,11 +739,12 @@ export class RuntimeHttpServer {
732
739
  }
733
740
 
734
741
  if (endpoint === 'channels/conversation' && req.method === 'DELETE') {
735
- return await handleDeleteConversation(req);
742
+ return await handleDeleteConversation(req, assistantId);
736
743
  }
737
744
 
738
745
  if (endpoint === 'channels/inbound' && req.method === 'POST') {
739
- return await handleChannelInbound(req, this.processMessage, this.bearerToken, this.runOrchestrator);
746
+ const gatewayOriginSecret = process.env.RUNTIME_GATEWAY_ORIGIN_SECRET || undefined;
747
+ return await handleChannelInbound(req, this.processMessage, this.bearerToken, this.runOrchestrator, assistantId, gatewayOriginSecret);
740
748
  }
741
749
 
742
750
  if (endpoint === 'channels/delivery-ack' && req.method === 'POST') {