@vellumai/assistant 0.3.2 → 0.3.4

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 (109) hide show
  1. package/README.md +82 -21
  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__/call-orchestrator.test.ts +321 -0
  7. package/src/__tests__/channel-approval-routes.test.ts +1267 -93
  8. package/src/__tests__/channel-approval.test.ts +2 -0
  9. package/src/__tests__/channel-approvals.test.ts +51 -2
  10. package/src/__tests__/channel-delivery-store.test.ts +130 -1
  11. package/src/__tests__/channel-guardian.test.ts +371 -1
  12. package/src/__tests__/config-schema.test.ts +1 -1
  13. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  14. package/src/__tests__/daemon-lifecycle.test.ts +635 -0
  15. package/src/__tests__/daemon-server-session-init.test.ts +5 -0
  16. package/src/__tests__/gateway-only-enforcement.test.ts +106 -21
  17. package/src/__tests__/handlers-telegram-config.test.ts +82 -0
  18. package/src/__tests__/handlers-twilio-config.test.ts +738 -5
  19. package/src/__tests__/ingress-url-consistency.test.ts +64 -0
  20. package/src/__tests__/ipc-snapshot.test.ts +10 -0
  21. package/src/__tests__/run-orchestrator.test.ts +1 -1
  22. package/src/__tests__/secret-scanner.test.ts +223 -0
  23. package/src/__tests__/session-process-bridge.test.ts +2 -0
  24. package/src/__tests__/shell-parser-property.test.ts +357 -2
  25. package/src/__tests__/system-prompt.test.ts +25 -1
  26. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  27. package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
  28. package/src/__tests__/user-reference.test.ts +68 -0
  29. package/src/calls/call-orchestrator.ts +63 -11
  30. package/src/calls/twilio-config.ts +10 -1
  31. package/src/calls/twilio-rest.ts +70 -0
  32. package/src/cli/map.ts +6 -0
  33. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  34. package/src/commands/cc-command-registry.ts +14 -1
  35. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  36. package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
  37. package/src/config/bundled-skills/messaging/SKILL.md +4 -0
  38. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  39. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  40. package/src/config/defaults.ts +1 -1
  41. package/src/config/schema.ts +6 -3
  42. package/src/config/skills.ts +5 -32
  43. package/src/config/system-prompt.ts +16 -0
  44. package/src/config/user-reference.ts +29 -0
  45. package/src/config/vellum-skills/catalog.json +52 -0
  46. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  47. package/src/config/vellum-skills/twilio-setup/SKILL.md +49 -4
  48. package/src/daemon/auth-manager.ts +103 -0
  49. package/src/daemon/computer-use-session.ts +8 -1
  50. package/src/daemon/config-watcher.ts +253 -0
  51. package/src/daemon/handlers/config.ts +193 -17
  52. package/src/daemon/handlers/sessions.ts +5 -3
  53. package/src/daemon/handlers/skills.ts +60 -17
  54. package/src/daemon/ipc-contract-inventory.json +4 -0
  55. package/src/daemon/ipc-contract.ts +16 -0
  56. package/src/daemon/ipc-handler.ts +87 -0
  57. package/src/daemon/lifecycle.ts +16 -4
  58. package/src/daemon/ride-shotgun-handler.ts +11 -1
  59. package/src/daemon/server.ts +105 -502
  60. package/src/daemon/session-agent-loop.ts +9 -14
  61. package/src/daemon/session-process.ts +20 -3
  62. package/src/daemon/session-runtime-assembly.ts +60 -44
  63. package/src/daemon/session-slash.ts +50 -2
  64. package/src/daemon/session-surfaces.ts +17 -1
  65. package/src/daemon/session.ts +8 -1
  66. package/src/inbound/public-ingress-urls.ts +20 -3
  67. package/src/index.ts +1 -23
  68. package/src/memory/app-git-service.ts +24 -0
  69. package/src/memory/app-store.ts +0 -21
  70. package/src/memory/channel-delivery-store.ts +74 -3
  71. package/src/memory/channel-guardian-store.ts +54 -26
  72. package/src/memory/conversation-key-store.ts +20 -0
  73. package/src/memory/conversation-store.ts +14 -2
  74. package/src/memory/db-connection.ts +28 -0
  75. package/src/memory/db-init.ts +1019 -0
  76. package/src/memory/db.ts +2 -1995
  77. package/src/memory/embedding-backend.ts +79 -11
  78. package/src/memory/indexer.ts +2 -0
  79. package/src/memory/job-utils.ts +64 -4
  80. package/src/memory/jobs-worker.ts +7 -1
  81. package/src/memory/recall-cache.ts +107 -0
  82. package/src/memory/retriever.ts +30 -1
  83. package/src/memory/schema-migration.ts +984 -0
  84. package/src/memory/schema.ts +6 -0
  85. package/src/memory/search/types.ts +2 -0
  86. package/src/permissions/prompter.ts +14 -3
  87. package/src/permissions/trust-store.ts +7 -0
  88. package/src/runtime/channel-approvals.ts +17 -3
  89. package/src/runtime/gateway-client.ts +2 -1
  90. package/src/runtime/http-server.ts +28 -9
  91. package/src/runtime/routes/channel-routes.ts +279 -100
  92. package/src/runtime/routes/run-routes.ts +7 -1
  93. package/src/runtime/run-orchestrator.ts +8 -1
  94. package/src/security/secret-scanner.ts +218 -0
  95. package/src/skills/clawhub.ts +6 -2
  96. package/src/skills/frontmatter.ts +63 -0
  97. package/src/skills/slash-commands.ts +23 -0
  98. package/src/skills/vellum-catalog-remote.ts +107 -0
  99. package/src/subagent/manager.ts +4 -1
  100. package/src/subagent/types.ts +2 -0
  101. package/src/tools/browser/auto-navigate.ts +132 -24
  102. package/src/tools/browser/browser-manager.ts +67 -61
  103. package/src/tools/claude-code/claude-code.ts +55 -3
  104. package/src/tools/executor.ts +10 -2
  105. package/src/tools/skills/vellum-catalog.ts +75 -127
  106. package/src/tools/subagent/spawn.ts +2 -0
  107. package/src/tools/terminal/parser.ts +21 -5
  108. package/src/util/platform.ts +8 -1
  109. package/src/util/retry.ts +4 -4
@@ -15,7 +15,7 @@ import { eq, and, lte, isNotNull } from 'drizzle-orm';
15
15
  import { v4 as uuid } from 'uuid';
16
16
  import { getDb } from './db.js';
17
17
  import { channelInboundEvents, conversations } from './schema.js';
18
- import { getOrCreateConversation } from './conversation-key-store.js';
18
+ import { getConversationByKey, getOrCreateConversation, setConversationKeyIfAbsent } from './conversation-key-store.js';
19
19
  import {
20
20
  classifyError,
21
21
  retryDelayForAttempt,
@@ -31,6 +31,7 @@ export interface InboundResult {
31
31
 
32
32
  export interface RecordInboundOptions {
33
33
  sourceMessageId?: string;
34
+ assistantId?: string;
34
35
  }
35
36
 
36
37
  /**
@@ -69,8 +70,30 @@ export function recordInbound(
69
70
  };
70
71
  }
71
72
 
72
- const conversationKey = `${sourceChannel}:${externalChatId}`;
73
- const mapping = getOrCreateConversation(conversationKey);
73
+ const assistantId = options?.assistantId;
74
+ const legacyKey = `${sourceChannel}:${externalChatId}`;
75
+ const scopedKey = assistantId ? `asst:${assistantId}:${sourceChannel}:${externalChatId}` : legacyKey;
76
+
77
+ // Resolve conversation mapping with assistant-scoped keying:
78
+ // 1. If scoped key exists, use it directly.
79
+ // 2. If assistantId is "self" and legacy key exists, reuse the legacy
80
+ // conversation and create a scoped alias to prevent future bleed.
81
+ // 3. Otherwise, create/get conversation from the scoped key.
82
+ let mapping: { conversationId: string; created: boolean };
83
+ const scopedMapping = assistantId ? getConversationByKey(scopedKey) : null;
84
+ if (scopedMapping) {
85
+ mapping = { conversationId: scopedMapping.conversationId, created: false };
86
+ } else if (assistantId === 'self') {
87
+ const legacyMapping = getConversationByKey(legacyKey);
88
+ if (legacyMapping) {
89
+ mapping = { conversationId: legacyMapping.conversationId, created: false };
90
+ setConversationKeyIfAbsent(scopedKey, legacyMapping.conversationId);
91
+ } else {
92
+ mapping = getOrCreateConversation(scopedKey);
93
+ }
94
+ } else {
95
+ mapping = getOrCreateConversation(scopedKey);
96
+ }
74
97
  const now = Date.now();
75
98
  const eventId = uuid();
76
99
 
@@ -316,6 +339,54 @@ export function getDeadLetterEvents(): Array<{
316
339
  .all();
317
340
  }
318
341
 
342
+ // ── Deliver-once guard for terminal reply idempotency ────────────────
343
+ //
344
+ // When both the main poll (processChannelMessageWithApprovals) and the
345
+ // post-decision poll (schedulePostDecisionDelivery) race to deliver the
346
+ // final assistant reply for the same run, this guard ensures only one
347
+ // of them actually sends the message. The guard is run-scoped so old
348
+ // assistant messages from previous runs are not affected.
349
+
350
+ const deliveredRuns = new Set<string>();
351
+
352
+ /** TTL for delivery claims — 10 minutes, well beyond the poll max-wait. */
353
+ const CLAIM_TTL_MS = 10 * 60 * 1000;
354
+
355
+ /**
356
+ * Atomically claim the right to deliver the final reply for a run.
357
+ * Returns `true` if this caller won the claim (and should proceed with
358
+ * delivery). Returns `false` if another caller already claimed it.
359
+ *
360
+ * This is an in-memory guard — sufficient because both racing pollers
361
+ * execute within the same process. The Set is never persisted; on restart
362
+ * there are no in-flight pollers to race.
363
+ *
364
+ * Claims are automatically evicted after CLAIM_TTL_MS to prevent
365
+ * unbounded Set growth over the lifetime of the process.
366
+ */
367
+ export function claimRunDelivery(runId: string): boolean {
368
+ if (deliveredRuns.has(runId)) return false;
369
+ deliveredRuns.add(runId);
370
+ setTimeout(() => deliveredRuns.delete(runId), CLAIM_TTL_MS);
371
+ return true;
372
+ }
373
+
374
+ /**
375
+ * Reset the deliver-once guard for a run. Used to release a claim when
376
+ * delivery fails (so the other racing poller can retry) and in tests
377
+ * for isolation between test cases.
378
+ */
379
+ export function resetRunDeliveryClaim(runId: string): void {
380
+ deliveredRuns.delete(runId);
381
+ }
382
+
383
+ /**
384
+ * Clear all delivery claims. Used in tests for full isolation.
385
+ */
386
+ export function resetAllRunDeliveryClaims(): void {
387
+ deliveredRuns.clear();
388
+ }
389
+
319
390
  /**
320
391
  * Reset dead-lettered events back to 'failed' so the sweep can retry
321
392
  * them. Resets attempt counter and sets an immediate retry_after.
@@ -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
@@ -0,0 +1,28 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { drizzle } from 'drizzle-orm/bun-sqlite';
3
+ import * as schema from './schema.js';
4
+ import { getDbPath, ensureDataDir, migrateToDataLayout, migrateToWorkspaceLayout } from '../util/platform.js';
5
+
6
+ let db: ReturnType<typeof drizzle<typeof schema>> | null = null;
7
+
8
+ export function getDb() {
9
+ if (!db) {
10
+ migrateToDataLayout();
11
+ migrateToWorkspaceLayout();
12
+ ensureDataDir();
13
+ const sqlite = new Database(getDbPath());
14
+ sqlite.exec('PRAGMA journal_mode=WAL');
15
+ sqlite.exec('PRAGMA foreign_keys = ON');
16
+ db = drizzle(sqlite, { schema });
17
+ }
18
+ return db;
19
+ }
20
+
21
+ /** Reset the db singleton. Used by tests to ensure isolation between test files. */
22
+ export function resetDb(): void {
23
+ if (db) {
24
+ const raw = (db as unknown as { $client: Database }).$client;
25
+ raw.close();
26
+ db = null;
27
+ }
28
+ }