vellum 0.2.0 → 0.2.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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +28 -0
  3. package/src/__tests__/app-bundler.test.ts +12 -33
  4. package/src/__tests__/browser-skill-endstate.test.ts +1 -5
  5. package/src/__tests__/call-orchestrator.test.ts +328 -0
  6. package/src/__tests__/call-state.test.ts +133 -0
  7. package/src/__tests__/call-store.test.ts +476 -0
  8. package/src/__tests__/commit-message-enrichment-service.test.ts +409 -0
  9. package/src/__tests__/config-schema.test.ts +49 -0
  10. package/src/__tests__/doordash-session.test.ts +9 -0
  11. package/src/__tests__/ipc-snapshot.test.ts +34 -0
  12. package/src/__tests__/registry.test.ts +13 -8
  13. package/src/__tests__/run-orchestrator-assistant-events.test.ts +218 -0
  14. package/src/__tests__/run-orchestrator.test.ts +3 -3
  15. package/src/__tests__/runtime-attachment-metadata.test.ts +17 -19
  16. package/src/__tests__/runtime-runs-http.test.ts +1 -19
  17. package/src/__tests__/runtime-runs.test.ts +7 -7
  18. package/src/__tests__/session-queue.test.ts +50 -0
  19. package/src/__tests__/turn-commit.test.ts +56 -0
  20. package/src/__tests__/workspace-git-service.test.ts +217 -0
  21. package/src/__tests__/workspace-heartbeat-service.test.ts +129 -0
  22. package/src/bundler/app-bundler.ts +29 -12
  23. package/src/calls/call-constants.ts +10 -0
  24. package/src/calls/call-orchestrator.ts +364 -0
  25. package/src/calls/call-state.ts +64 -0
  26. package/src/calls/call-store.ts +229 -0
  27. package/src/calls/relay-server.ts +298 -0
  28. package/src/calls/twilio-config.ts +34 -0
  29. package/src/calls/twilio-provider.ts +169 -0
  30. package/src/calls/twilio-routes.ts +236 -0
  31. package/src/calls/types.ts +37 -0
  32. package/src/calls/voice-provider.ts +14 -0
  33. package/src/cli/doordash.ts +5 -24
  34. package/src/config/bundled-skills/doordash/SKILL.md +104 -0
  35. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
  36. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -1
  37. package/src/config/defaults.ts +11 -0
  38. package/src/config/schema.ts +57 -0
  39. package/src/config/system-prompt.ts +50 -1
  40. package/src/config/types.ts +1 -0
  41. package/src/daemon/handlers/config.ts +30 -0
  42. package/src/daemon/handlers/index.ts +6 -0
  43. package/src/daemon/handlers/work-items.ts +142 -2
  44. package/src/daemon/ipc-contract-inventory.json +12 -0
  45. package/src/daemon/ipc-contract.ts +52 -0
  46. package/src/daemon/lifecycle.ts +27 -5
  47. package/src/daemon/server.ts +10 -12
  48. package/src/daemon/session-tool-setup.ts +6 -0
  49. package/src/daemon/session.ts +40 -1
  50. package/src/index.ts +2 -0
  51. package/src/media/gemini-image-service.ts +1 -1
  52. package/src/memory/db.ts +266 -0
  53. package/src/memory/schema.ts +42 -0
  54. package/src/runtime/http-server.ts +189 -25
  55. package/src/runtime/http-types.ts +0 -2
  56. package/src/runtime/routes/attachment-routes.ts +6 -6
  57. package/src/runtime/routes/channel-routes.ts +16 -18
  58. package/src/runtime/routes/conversation-routes.ts +5 -9
  59. package/src/runtime/routes/run-routes.ts +4 -8
  60. package/src/runtime/run-orchestrator.ts +32 -5
  61. package/src/tools/calls/call-end.ts +117 -0
  62. package/src/tools/calls/call-start.ts +134 -0
  63. package/src/tools/calls/call-status.ts +97 -0
  64. package/src/tools/credentials/vault.ts +1 -1
  65. package/src/tools/registry.ts +2 -4
  66. package/src/tools/tasks/index.ts +2 -0
  67. package/src/tools/tasks/task-delete.ts +49 -8
  68. package/src/tools/tasks/task-run.ts +9 -1
  69. package/src/tools/tasks/work-item-enqueue.ts +93 -3
  70. package/src/tools/tasks/work-item-list.ts +10 -25
  71. package/src/tools/tasks/work-item-remove.ts +112 -0
  72. package/src/tools/tasks/work-item-update.ts +186 -0
  73. package/src/tools/tool-manifest.ts +39 -31
  74. package/src/tools/ui-surface/definitions.ts +3 -0
  75. package/src/work-items/work-item-store.ts +209 -0
  76. package/src/workspace/commit-message-enrichment-service.ts +260 -0
  77. package/src/workspace/commit-message-provider.ts +95 -0
  78. package/src/workspace/git-service.ts +187 -32
  79. package/src/workspace/heartbeat-service.ts +70 -13
  80. package/src/workspace/turn-commit.ts +39 -49
@@ -7,6 +7,9 @@ import {
7
7
  WorkspaceGitService,
8
8
  getWorkspaceGitService,
9
9
  _resetGitServiceRegistry,
10
+ _resetBreaker,
11
+ _getConsecutiveFailures,
12
+ isDeadlineExpired,
10
13
  } from '../workspace/git-service.js';
11
14
 
12
15
  describe('WorkspaceGitService', () => {
@@ -747,4 +750,218 @@ describe('WorkspaceGitService', () => {
747
750
  expect(status.untracked).toContain('README.md');
748
751
  });
749
752
  });
753
+
754
+ describe('deadline-aware commitIfDirty', () => {
755
+ test('deadline expired before lock acquisition skips commit quickly', async () => {
756
+ const service = new WorkspaceGitService(testDir);
757
+ await service.ensureInitialized();
758
+
759
+ // Create a file so the workspace is dirty
760
+ writeFileSync(join(testDir, 'test.txt'), 'content');
761
+
762
+ // Use a deadline that has already passed
763
+ const pastDeadline = Date.now() - 1000;
764
+ const result = await service.commitIfDirty(
765
+ () => ({ message: 'should not commit' }),
766
+ { deadlineMs: pastDeadline },
767
+ );
768
+
769
+ expect(result.committed).toBe(false);
770
+
771
+ // File should still be uncommitted
772
+ const status = await service.getStatus();
773
+ expect(status.clean).toBe(false);
774
+ expect(status.untracked).toContain('test.txt');
775
+ });
776
+
777
+ test('deadline far in the future allows commit to proceed', async () => {
778
+ const service = new WorkspaceGitService(testDir);
779
+ await service.ensureInitialized();
780
+
781
+ writeFileSync(join(testDir, 'test.txt'), 'content');
782
+
783
+ // Use a deadline far in the future
784
+ const futureDeadline = Date.now() + 60_000;
785
+ const result = await service.commitIfDirty(
786
+ () => ({ message: 'deadline commit' }),
787
+ { deadlineMs: futureDeadline },
788
+ );
789
+
790
+ expect(result.committed).toBe(true);
791
+
792
+ // Verify the commit was actually created
793
+ const log = execFileSync('git', ['log', '--oneline', '-n', '1'], {
794
+ cwd: testDir,
795
+ encoding: 'utf-8',
796
+ });
797
+ expect(log).toContain('deadline commit');
798
+ });
799
+
800
+ test('no deadline option allows commit as normal', async () => {
801
+ const service = new WorkspaceGitService(testDir);
802
+ await service.ensureInitialized();
803
+
804
+ writeFileSync(join(testDir, 'test.txt'), 'content');
805
+
806
+ // No deadline option at all
807
+ const result = await service.commitIfDirty(
808
+ () => ({ message: 'no deadline commit' }),
809
+ );
810
+
811
+ expect(result.committed).toBe(true);
812
+ });
813
+ });
814
+
815
+ describe('breaker re-check under lock', () => {
816
+ test('queued call that acquires lock after breaker opens skips commit', async () => {
817
+ const service = new WorkspaceGitService(testDir);
818
+ await service.ensureInitialized();
819
+
820
+ writeFileSync(join(testDir, 'test.txt'), 'content');
821
+
822
+ // Simulate a breaker that opened between the pre-lock check and lock acquisition.
823
+ // We do this by:
824
+ // 1. Starting a commitIfDirty call (which passes the pre-lock breaker check)
825
+ // 2. Forcing the breaker open while the call is in progress
826
+
827
+ // First, force the breaker open by setting internal state directly.
828
+ // Since commitIfDirty re-checks the breaker after acquiring the lock,
829
+ // a call that passes the pre-lock check but finds the breaker open
830
+ // after acquiring the lock should bail out.
831
+ const internal = service as unknown as {
832
+ consecutiveFailures: number;
833
+ nextAllowedAttemptMs: number;
834
+ };
835
+ internal.consecutiveFailures = 5;
836
+ internal.nextAllowedAttemptMs = Date.now() + 60_000; // far in the future
837
+
838
+ // With breaker open, commitIfDirty should skip (pre-lock check)
839
+ const result = await service.commitIfDirty(
840
+ () => ({ message: 'should not commit' }),
841
+ );
842
+ expect(result.committed).toBe(false);
843
+
844
+ // Reset breaker
845
+ _resetBreaker(service);
846
+
847
+ // Now the commit should proceed normally
848
+ const result2 = await service.commitIfDirty(
849
+ () => ({ message: 'after breaker reset' }),
850
+ );
851
+ expect(result2.committed).toBe(true);
852
+ });
853
+
854
+ test('breaker early return inside lock does not reset breaker via recordSuccess', async () => {
855
+ const service = new WorkspaceGitService(testDir);
856
+ await service.ensureInitialized();
857
+
858
+ writeFileSync(join(testDir, 'test.txt'), 'content');
859
+
860
+ // Force breaker open with known failure count
861
+ const internal = service as unknown as {
862
+ consecutiveFailures: number;
863
+ nextAllowedAttemptMs: number;
864
+ };
865
+ internal.consecutiveFailures = 5;
866
+ internal.nextAllowedAttemptMs = Date.now() + 60_000;
867
+
868
+ // Pre-lock check catches the open breaker and returns early.
869
+ // Verify that consecutiveFailures is NOT reset to 0.
870
+ const result = await service.commitIfDirty(
871
+ () => ({ message: 'should not commit' }),
872
+ );
873
+ expect(result.committed).toBe(false);
874
+ expect(_getConsecutiveFailures(service)).toBe(5);
875
+ });
876
+
877
+ test('deadline early return inside lock does not reset breaker via recordSuccess', async () => {
878
+ const service = new WorkspaceGitService(testDir);
879
+ await service.ensureInitialized();
880
+
881
+ writeFileSync(join(testDir, 'test.txt'), 'content');
882
+
883
+ // Set up prior failures (breaker closed but failures recorded)
884
+ const internal = service as unknown as {
885
+ consecutiveFailures: number;
886
+ nextAllowedAttemptMs: number;
887
+ };
888
+ internal.consecutiveFailures = 3;
889
+ // Set nextAllowedAttemptMs in the past so the breaker check passes
890
+ // but consecutiveFailures is non-zero
891
+ internal.nextAllowedAttemptMs = Date.now() - 1000;
892
+
893
+ // Use a deadline that has already passed — this triggers the pre-lock
894
+ // deadline fast-path. consecutiveFailures should NOT be reset.
895
+ const result = await service.commitIfDirty(
896
+ () => ({ message: 'should not commit' }),
897
+ { deadlineMs: Date.now() - 1000 },
898
+ );
899
+ expect(result.committed).toBe(false);
900
+ expect(_getConsecutiveFailures(service)).toBe(3);
901
+ });
902
+
903
+ test('successful git operation after failures resets breaker', async () => {
904
+ const service = new WorkspaceGitService(testDir);
905
+ await service.ensureInitialized();
906
+
907
+ writeFileSync(join(testDir, 'test.txt'), 'content');
908
+
909
+ // Set up prior failures with breaker closed (backoff expired)
910
+ const internal = service as unknown as {
911
+ consecutiveFailures: number;
912
+ nextAllowedAttemptMs: number;
913
+ };
914
+ internal.consecutiveFailures = 3;
915
+ internal.nextAllowedAttemptMs = Date.now() - 1000;
916
+
917
+ // Commit should succeed and reset the breaker
918
+ const result = await service.commitIfDirty(
919
+ () => ({ message: 'recovery commit' }),
920
+ );
921
+ expect(result.committed).toBe(true);
922
+ expect(_getConsecutiveFailures(service)).toBe(0);
923
+ });
924
+
925
+ test('bypassBreaker ignores breaker state', async () => {
926
+ const service = new WorkspaceGitService(testDir);
927
+ await service.ensureInitialized();
928
+
929
+ writeFileSync(join(testDir, 'test.txt'), 'content');
930
+
931
+ // Force breaker open
932
+ const internal = service as unknown as {
933
+ consecutiveFailures: number;
934
+ nextAllowedAttemptMs: number;
935
+ };
936
+ internal.consecutiveFailures = 5;
937
+ internal.nextAllowedAttemptMs = Date.now() + 60_000;
938
+
939
+ // With bypassBreaker, commit should succeed despite open breaker
940
+ const result = await service.commitIfDirty(
941
+ () => ({ message: 'bypass breaker commit' }),
942
+ { bypassBreaker: true },
943
+ );
944
+ expect(result.committed).toBe(true);
945
+ });
946
+ });
947
+
948
+ describe('isDeadlineExpired helper', () => {
949
+ test('returns false when deadlineMs is undefined', () => {
950
+ expect(isDeadlineExpired(undefined)).toBe(false);
951
+ });
952
+
953
+ test('returns false when deadline is in the future', () => {
954
+ expect(isDeadlineExpired(Date.now() + 60_000)).toBe(false);
955
+ });
956
+
957
+ test('returns true when deadline is in the past', () => {
958
+ expect(isDeadlineExpired(Date.now() - 1000)).toBe(true);
959
+ });
960
+
961
+ test('returns true when deadline equals current time', () => {
962
+ const now = Date.now();
963
+ // Use a deadline slightly in the past to avoid timing flakes
964
+ expect(isDeadlineExpired(now - 1)).toBe(true);
965
+ });
966
+ });
750
967
  });
@@ -11,6 +11,7 @@ import {
11
11
  HeartbeatService,
12
12
  _resetHeartbeatState,
13
13
  } from '../workspace/heartbeat-service.js';
14
+ import type { CommitMessageProvider, CommitContext, CommitMessageResult } from '../workspace/commit-message-provider.js';
14
15
 
15
16
  describe('HeartbeatService', () => {
16
17
  let testDir: string;
@@ -344,4 +345,132 @@ describe('HeartbeatService', () => {
344
345
  await heartbeat.stop(); // Idempotent
345
346
  });
346
347
  });
348
+
349
+ describe('custom commit message provider', () => {
350
+ test('heartbeat commit uses custom provider message', async () => {
351
+ writeFileSync(join(testDir, 'file.txt'), 'content');
352
+
353
+ const customProvider: CommitMessageProvider = {
354
+ buildImmediateMessage(ctx: CommitContext): CommitMessageResult {
355
+ return {
356
+ message: `CUSTOM-HEARTBEAT: ${ctx.changedFiles.length} files via ${ctx.trigger}`,
357
+ metadata: { customProvider: true, trigger: ctx.trigger },
358
+ };
359
+ },
360
+ };
361
+
362
+ let currentTime = 1000000;
363
+ const heartbeat = new HeartbeatService({
364
+ ageThresholdMs: 5 * 60 * 1000,
365
+ fileThreshold: 100,
366
+ getServices: () => services,
367
+ now: () => currentTime,
368
+ commitMessageProvider: customProvider,
369
+ });
370
+
371
+ // First check registers dirty state
372
+ await heartbeat.check();
373
+ // Advance time past threshold
374
+ currentTime += 6 * 60 * 1000;
375
+ // Second check commits
376
+ const result = await heartbeat.check();
377
+ expect(result.committed).toBe(1);
378
+
379
+ const commitMsg = execFileSync('git', ['log', '-1', '--pretty=%B'], {
380
+ cwd: testDir,
381
+ encoding: 'utf-8',
382
+ });
383
+ expect(commitMsg).toContain('CUSTOM-HEARTBEAT:');
384
+ expect(commitMsg).toContain('via heartbeat');
385
+ expect(commitMsg).toContain('customProvider: true');
386
+ });
387
+
388
+ test('shutdown commit uses custom provider message', async () => {
389
+ writeFileSync(join(testDir, 'unsaved.txt'), 'uncommitted content');
390
+
391
+ const customProvider: CommitMessageProvider = {
392
+ buildImmediateMessage(ctx: CommitContext): CommitMessageResult {
393
+ return {
394
+ message: `CUSTOM-SHUTDOWN: saving ${ctx.changedFiles.length} files`,
395
+ metadata: { shutdownProvider: true },
396
+ };
397
+ },
398
+ };
399
+
400
+ const heartbeat = new HeartbeatService({
401
+ getServices: () => services,
402
+ commitMessageProvider: customProvider,
403
+ });
404
+
405
+ const result = await heartbeat.commitAllPending();
406
+ expect(result.committed).toBe(1);
407
+
408
+ const commitMsg = execFileSync('git', ['log', '-1', '--pretty=%B'], {
409
+ cwd: testDir,
410
+ encoding: 'utf-8',
411
+ });
412
+ expect(commitMsg).toContain('CUSTOM-SHUTDOWN: saving');
413
+ expect(commitMsg).toContain('shutdownProvider: true');
414
+ });
415
+
416
+ test('custom provider receives correct context fields for heartbeat trigger', async () => {
417
+ writeFileSync(join(testDir, 'a.txt'), 'a');
418
+ writeFileSync(join(testDir, 'b.txt'), 'b');
419
+
420
+ let capturedCtx: CommitContext | null = null;
421
+ const customProvider: CommitMessageProvider = {
422
+ buildImmediateMessage(ctx: CommitContext): CommitMessageResult {
423
+ capturedCtx = ctx;
424
+ return { message: 'capture-context' };
425
+ },
426
+ };
427
+
428
+ let currentTime = 1000000;
429
+ const heartbeat = new HeartbeatService({
430
+ ageThresholdMs: 5 * 60 * 1000,
431
+ fileThreshold: 100,
432
+ getServices: () => services,
433
+ now: () => currentTime,
434
+ commitMessageProvider: customProvider,
435
+ });
436
+
437
+ // Register dirty state
438
+ await heartbeat.check();
439
+ // Advance past threshold
440
+ currentTime += 6 * 60 * 1000;
441
+ await heartbeat.check();
442
+
443
+ expect(capturedCtx).not.toBeNull();
444
+ expect(capturedCtx!.trigger).toBe('heartbeat');
445
+ expect(capturedCtx!.workspaceDir).toBe(testDir);
446
+ expect(capturedCtx!.changedFiles).toContain('a.txt');
447
+ expect(capturedCtx!.changedFiles).toContain('b.txt');
448
+ expect(capturedCtx!.timestampMs).toBe(currentTime);
449
+ expect(capturedCtx!.reason).toBeDefined();
450
+ });
451
+
452
+ test('custom provider receives correct context fields for shutdown trigger', async () => {
453
+ writeFileSync(join(testDir, 'shutdown-file.txt'), 'data');
454
+
455
+ let capturedCtx: CommitContext | null = null;
456
+ const customProvider: CommitMessageProvider = {
457
+ buildImmediateMessage(ctx: CommitContext): CommitMessageResult {
458
+ capturedCtx = ctx;
459
+ return { message: 'capture-shutdown-context' };
460
+ },
461
+ };
462
+
463
+ const heartbeat = new HeartbeatService({
464
+ getServices: () => services,
465
+ commitMessageProvider: customProvider,
466
+ });
467
+
468
+ await heartbeat.commitAllPending();
469
+
470
+ expect(capturedCtx).not.toBeNull();
471
+ expect(capturedCtx!.trigger).toBe('shutdown');
472
+ expect(capturedCtx!.workspaceDir).toBe(testDir);
473
+ expect(capturedCtx!.changedFiles).toContain('shutdown-file.txt');
474
+ });
475
+ });
347
476
  });
@@ -35,15 +35,25 @@ interface FetchedAsset {
35
35
 
36
36
  /**
37
37
  * Extract all remote (http/https) URLs from HTML content.
38
- * Looks in src=, href=, and CSS url() references.
38
+ * Looks in src=, href= on asset elements (not <a> tags), and CSS url() references.
39
39
  */
40
40
  export function extractRemoteUrls(html: string): string[] {
41
41
  const urls = new Set<string>();
42
42
 
43
- // Match src="..." and href="..." attributes (double or single quotes, or unquoted)
44
- const attrRe = /\b(?:src|href)\s*=\s*(?:"([^"]*?)"|'([^']*?)'|([^\s>]+))/gi;
43
+ // Match src="..." attributes on any element
44
+ const srcRe = /\bsrc\s*=\s*(?:"([^"]*?)"|'([^']*?)'|([^\s>]+))/gi;
45
45
  let m: RegExpExecArray | null;
46
- while ((m = attrRe.exec(html)) !== null) {
46
+ while ((m = srcRe.exec(html)) !== null) {
47
+ const url = m[1] ?? m[2] ?? m[3];
48
+ if (url && /^https?:\/\//i.test(url)) {
49
+ urls.add(url);
50
+ }
51
+ }
52
+
53
+ // Match href="..." only on <link> elements (stylesheets, icons, preloads), not <a> tags.
54
+ // Captures <link ...href="..."> where href appears anywhere within the tag.
55
+ const linkRe = /<link\b[^>]*?\bhref\s*=\s*(?:"([^"]*?)"|'([^']*?)'|([^\s>]+))[^>]*?\/?>/gi;
56
+ while ((m = linkRe.exec(html)) !== null) {
47
57
  const url = m[1] ?? m[2] ?? m[3];
48
58
  if (url && /^https?:\/\//i.test(url)) {
49
59
  urls.add(url);
@@ -104,13 +114,17 @@ export async function materializeAssets(
104
114
  try {
105
115
  const controller = new AbortController();
106
116
  const timeout = setTimeout(() => controller.abort(), ASSET_FETCH_TIMEOUT_MS);
107
- const resp = await fetch(url, { signal: controller.signal });
108
- clearTimeout(timeout);
109
- if (!resp.ok) {
110
- bundlerLog.warn({ url, status: resp.status }, 'Failed to fetch asset, keeping original URL');
111
- return;
117
+ let buf: Buffer;
118
+ try {
119
+ const resp = await fetch(url, { signal: controller.signal });
120
+ if (!resp.ok) {
121
+ bundlerLog.warn({ url, status: resp.status }, 'Failed to fetch asset, keeping original URL');
122
+ return;
123
+ }
124
+ buf = Buffer.from(await resp.arrayBuffer());
125
+ } finally {
126
+ clearTimeout(timeout);
112
127
  }
113
- const buf = Buffer.from(await resp.arrayBuffer());
114
128
  const filename = assetFilename(url);
115
129
  const archivePath = `assets/${filename}`;
116
130
  assets.push({ archivePath, data: buf });
@@ -121,9 +135,12 @@ export async function materializeAssets(
121
135
  }),
122
136
  );
123
137
 
124
- // Rewrite URLs in HTML — replace each occurrence of the original URL with the local path
138
+ // Rewrite URLs in HTML — replace each occurrence of the original URL with the local path.
139
+ // Sort by length descending so longer URLs are replaced first, preventing prefix collisions
140
+ // (e.g. "https://cdn/x" replacing part of "https://cdn/x/y.png").
141
+ const sortedEntries = [...urlMap.entries()].sort((a, b) => b[0].length - a[0].length);
125
142
  let rewrittenHtml = html;
126
- for (const [originalUrl, localPath] of urlMap) {
143
+ for (const [originalUrl, localPath] of sortedEntries) {
127
144
  // Escape regex special chars in the URL
128
145
  const escaped = originalUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
129
146
  rewrittenHtml = rewrittenHtml.replace(new RegExp(escaped, 'g'), localPath);
@@ -0,0 +1,10 @@
1
+ // Emergency/high-risk numbers that should never be called
2
+ export const DENIED_NUMBERS = new Set([
3
+ '911', '112', '999', '000', '110', '119', // Emergency
4
+ '+1911', '+1112',
5
+ ]);
6
+
7
+ // Call limits
8
+ export const MAX_CALL_DURATION_MS = 12 * 60 * 1000; // 12 minutes
9
+ export const USER_CONSULTATION_TIMEOUT_MS = 90 * 1000; // 90 seconds
10
+ export const SILENCE_TIMEOUT_MS = 30 * 1000; // 30 seconds