@vellumai/assistant 0.3.16 → 0.3.18

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 (90) hide show
  1. package/ARCHITECTURE.md +70 -13
  2. package/README.md +6 -0
  3. package/docs/architecture/http-token-refresh.md +23 -1
  4. package/package.json +1 -1
  5. package/src/__tests__/access-request-decision.test.ts +4 -7
  6. package/src/__tests__/channel-guardian.test.ts +3 -1
  7. package/src/__tests__/checker.test.ts +79 -48
  8. package/src/__tests__/config-watcher.test.ts +11 -13
  9. package/src/__tests__/conversation-pairing.test.ts +103 -3
  10. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
  11. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
  12. package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
  13. package/src/__tests__/guardian-action-store.test.ts +182 -0
  14. package/src/__tests__/guardian-dispatch.test.ts +120 -0
  15. package/src/__tests__/ipc-snapshot.test.ts +21 -0
  16. package/src/__tests__/non-member-access-request.test.ts +1 -2
  17. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  18. package/src/__tests__/notification-decision-strategy.test.ts +2 -1
  19. package/src/__tests__/notification-deep-link.test.ts +44 -1
  20. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  21. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  22. package/src/__tests__/slack-channel-config.test.ts +3 -3
  23. package/src/__tests__/trust-store.test.ts +21 -21
  24. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
  25. package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
  26. package/src/__tests__/trusted-contact-verification.test.ts +9 -9
  27. package/src/__tests__/update-bulletin-state.test.ts +1 -1
  28. package/src/__tests__/update-bulletin.test.ts +66 -3
  29. package/src/__tests__/update-template-contract.test.ts +6 -11
  30. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  31. package/src/calls/call-controller.ts +129 -8
  32. package/src/calls/guardian-action-sweep.ts +1 -1
  33. package/src/calls/guardian-dispatch.ts +8 -0
  34. package/src/calls/voice-session-bridge.ts +4 -2
  35. package/src/cli/core-commands.ts +41 -1
  36. package/src/config/templates/UPDATES.md +5 -6
  37. package/src/config/update-bulletin-format.ts +2 -0
  38. package/src/config/update-bulletin-state.ts +1 -1
  39. package/src/config/update-bulletin-template-path.ts +6 -0
  40. package/src/config/update-bulletin.ts +21 -6
  41. package/src/daemon/config-watcher.ts +3 -2
  42. package/src/daemon/daemon-control.ts +64 -10
  43. package/src/daemon/handlers/config-slack-channel.ts +1 -1
  44. package/src/daemon/handlers/identity.ts +45 -25
  45. package/src/daemon/handlers/sessions.ts +1 -1
  46. package/src/daemon/ipc-contract/sessions.ts +1 -1
  47. package/src/daemon/ipc-contract/workspace.ts +12 -1
  48. package/src/daemon/ipc-contract-inventory.json +1 -0
  49. package/src/daemon/lifecycle.ts +8 -0
  50. package/src/daemon/server.ts +25 -3
  51. package/src/daemon/session-process.ts +438 -184
  52. package/src/daemon/tls-certs.ts +17 -12
  53. package/src/daemon/tool-side-effects.ts +1 -1
  54. package/src/memory/channel-delivery-store.ts +18 -20
  55. package/src/memory/channel-guardian-store.ts +39 -42
  56. package/src/memory/conversation-crud.ts +2 -2
  57. package/src/memory/conversation-queries.ts +2 -2
  58. package/src/memory/conversation-store.ts +24 -25
  59. package/src/memory/db-init.ts +9 -1
  60. package/src/memory/fts-reconciler.ts +41 -26
  61. package/src/memory/guardian-action-store.ts +57 -7
  62. package/src/memory/guardian-verification.ts +1 -0
  63. package/src/memory/jobs-worker.ts +2 -2
  64. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  65. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  66. package/src/memory/migrations/index.ts +4 -2
  67. package/src/memory/schema-migration.ts +1 -0
  68. package/src/memory/schema.ts +6 -1
  69. package/src/memory/search/semantic.ts +3 -3
  70. package/src/notifications/README.md +158 -17
  71. package/src/notifications/broadcaster.ts +68 -50
  72. package/src/notifications/conversation-pairing.ts +96 -18
  73. package/src/notifications/decision-engine.ts +6 -3
  74. package/src/notifications/deliveries-store.ts +12 -0
  75. package/src/notifications/emit-signal.ts +1 -0
  76. package/src/notifications/thread-candidates.ts +60 -25
  77. package/src/notifications/types.ts +2 -1
  78. package/src/permissions/checker.ts +1 -16
  79. package/src/permissions/defaults.ts +14 -4
  80. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  81. package/src/runtime/http-server.ts +11 -11
  82. package/src/runtime/routes/access-request-decision.ts +1 -1
  83. package/src/runtime/routes/debug-routes.ts +4 -4
  84. package/src/runtime/routes/guardian-approval-interception.ts +4 -4
  85. package/src/runtime/routes/inbound-message-handler.ts +6 -6
  86. package/src/runtime/routes/integration-routes.ts +2 -2
  87. package/src/tools/permission-checker.ts +1 -2
  88. package/src/tools/secret-detection-handler.ts +1 -1
  89. package/src/tools/system/voice-config.ts +1 -1
  90. package/src/version.ts +29 -2
@@ -8,6 +8,18 @@ import type { ServerMessage } from '../daemon/ipc-protocol.js';
8
8
  import type { Session } from '../daemon/session.js';
9
9
 
10
10
  const testDir = mkdtempSync(join(tmpdir(), 'voice-bridge-test-'));
11
+ let mockedConfig: {
12
+ secretDetection: { enabled: boolean };
13
+ calls: { disclosure: { enabled: boolean; text: string } };
14
+ } = {
15
+ secretDetection: { enabled: false },
16
+ calls: {
17
+ disclosure: {
18
+ enabled: false,
19
+ text: '',
20
+ },
21
+ },
22
+ };
11
23
 
12
24
  mock.module('../util/platform.js', () => ({
13
25
  getRootDir: () => testDir,
@@ -29,15 +41,7 @@ mock.module('../util/logger.js', () => ({
29
41
  }));
30
42
 
31
43
  mock.module('../config/loader.js', () => ({
32
- getConfig: () => ({
33
- secretDetection: { enabled: false },
34
- calls: {
35
- disclosure: {
36
- enabled: false,
37
- text: '',
38
- },
39
- },
40
- }),
44
+ getConfig: () => mockedConfig,
41
45
  }));
42
46
 
43
47
  import { setVoiceBridgeDeps, startVoiceTurn } from '../calls/voice-session-bridge.js';
@@ -85,6 +89,15 @@ function injectDeps(sessionFactory: () => Session): void {
85
89
 
86
90
  describe('voice-session-bridge', () => {
87
91
  beforeEach(() => {
92
+ mockedConfig = {
93
+ secretDetection: { enabled: false },
94
+ calls: {
95
+ disclosure: {
96
+ enabled: false,
97
+ text: '',
98
+ },
99
+ },
100
+ };
88
101
  const db = getDb();
89
102
  db.run('DELETE FROM messages');
90
103
  db.run('DELETE FROM conversations');
@@ -415,6 +428,93 @@ describe('voice-session-bridge', () => {
415
428
  expect(capturedGuardianContext).toEqual(guardianCtx);
416
429
  });
417
430
 
431
+ test('inbound non-guardian opener prompt uses pickup framing instead of outbound phrasing', async () => {
432
+ const conversation = createConversation('voice bridge inbound opener framing test');
433
+ const events: ServerMessage[] = [
434
+ { type: 'message_complete', sessionId: conversation.id },
435
+ ];
436
+
437
+ let capturedPrompt: string | null = null;
438
+ const session = {
439
+ ...makeStreamingSession(events),
440
+ setVoiceCallControlPrompt: (prompt: string | null) => {
441
+ if (prompt != null) capturedPrompt = prompt;
442
+ },
443
+ } as unknown as Session;
444
+
445
+ injectDeps(() => session);
446
+
447
+ await startVoiceTurn({
448
+ conversationId: conversation.id,
449
+ content: 'Hello there',
450
+ isInbound: true,
451
+ guardianContext: {
452
+ sourceChannel: 'voice',
453
+ actorRole: 'non-guardian',
454
+ },
455
+ onTextDelta: () => {},
456
+ onComplete: () => {},
457
+ onError: () => {},
458
+ });
459
+
460
+ await new Promise((r) => setTimeout(r, 50));
461
+ if (!capturedPrompt) throw new Error('Expected voice call control prompt to be set');
462
+ const prompt: string = capturedPrompt;
463
+
464
+ expect(prompt).toContain('this is an inbound call you are answering (not a call you initiated)');
465
+ expect(prompt).toContain('Introduce yourself once at the start using your assistant name if you know it');
466
+ expect(prompt).toContain('If your assistant name is not known, skip the name and just identify yourself as the guardian\'s assistant.');
467
+ expect(prompt).toContain('Do NOT say "I\'m calling" or "I\'m calling on behalf of".');
468
+ });
469
+
470
+ test('inbound disclosure guidance is rewritten for pickup context', async () => {
471
+ mockedConfig = {
472
+ secretDetection: { enabled: false },
473
+ calls: {
474
+ disclosure: {
475
+ enabled: true,
476
+ text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent.',
477
+ },
478
+ },
479
+ };
480
+
481
+ const conversation = createConversation('voice bridge inbound disclosure rewrite test');
482
+ const events: ServerMessage[] = [
483
+ { type: 'message_complete', sessionId: conversation.id },
484
+ ];
485
+
486
+ let capturedPrompt: string | null = null;
487
+ const session = {
488
+ ...makeStreamingSession(events),
489
+ setVoiceCallControlPrompt: (prompt: string | null) => {
490
+ if (prompt != null) capturedPrompt = prompt;
491
+ },
492
+ } as unknown as Session;
493
+
494
+ injectDeps(() => session);
495
+
496
+ await startVoiceTurn({
497
+ conversationId: conversation.id,
498
+ content: 'Hi',
499
+ isInbound: true,
500
+ guardianContext: {
501
+ sourceChannel: 'voice',
502
+ actorRole: 'non-guardian',
503
+ },
504
+ onTextDelta: () => {},
505
+ onComplete: () => {},
506
+ onError: () => {},
507
+ });
508
+
509
+ await new Promise((r) => setTimeout(r, 50));
510
+ if (!capturedPrompt) throw new Error('Expected voice call control prompt to be set');
511
+ const prompt: string = capturedPrompt;
512
+
513
+ expect(prompt).toContain('At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent.');
514
+ expect(prompt).toContain('rewrite any disclosure naturally for pickup context');
515
+ expect(prompt).toContain('Do NOT say "I\'m calling", "I called you", or "I\'m calling on behalf of".');
516
+ });
517
+
418
518
  test('auto-denies confirmation requests for non-guardian voice turns', async () => {
419
519
  const conversation = createConversation('voice bridge auto-deny non-guardian test');
420
520
 
@@ -8,6 +8,7 @@
8
8
  * barge-in, state machine, guardian verification).
9
9
  */
10
10
 
11
+ import { getGatewayInternalBaseUrl } from '../config/env.js';
11
12
  import type { ServerMessage } from '../daemon/ipc-contract.js';
12
13
  import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
13
14
  import {
@@ -16,6 +17,7 @@ import {
16
17
  markTimedOutWithReason,
17
18
  } from '../memory/guardian-action-store.js';
18
19
  import { getLogger } from '../util/logger.js';
20
+ import { readHttpToken } from '../util/platform.js';
19
21
  import { getMaxCallDurationMs, getUserConsultationTimeoutMs, SILENCE_TIMEOUT_MS } from './call-constants.js';
20
22
  import { persistCallCompletionMessage } from './call-conversation-messages.js';
21
23
  import { addPointerMessage, formatDuration } from './call-pointer-messages.js';
@@ -27,10 +29,8 @@ import {
27
29
  recordCallEvent,
28
30
  updateCallSession,
29
31
  } from './call-store.js';
30
- import { getGatewayInternalBaseUrl } from '../config/env.js';
31
- import { readHttpToken } from '../util/platform.js';
32
- import { dispatchGuardianQuestion } from './guardian-dispatch.js';
33
32
  import { sendGuardianExpiryNotices } from './guardian-action-sweep.js';
33
+ import { dispatchGuardianQuestion } from './guardian-dispatch.js';
34
34
  import type { RelayConnection } from './relay-server.js';
35
35
  import type { PromptSpeakerContext } from './speaker-identification.js';
36
36
  import { startVoiceTurn, type VoiceTurnHandle } from './voice-session-bridge.js';
@@ -41,6 +41,104 @@ type ControllerState = 'idle' | 'processing' | 'waiting_on_user' | 'speaking';
41
41
 
42
42
  const ASK_GUARDIAN_CAPTURE_REGEX = /\[ASK_GUARDIAN:\s*(.+?)\]/;
43
43
  const ASK_GUARDIAN_MARKER_REGEX = /\[ASK_GUARDIAN:\s*.+?\]/g;
44
+
45
+ // Flexible prefix for ASK_GUARDIAN_APPROVAL — tolerates variable whitespace
46
+ // after the colon so the marker is recognized even if the model omits the
47
+ // space or inserts a newline.
48
+ const ASK_GUARDIAN_APPROVAL_PREFIX_RE = /\[ASK_GUARDIAN_APPROVAL:\s*/;
49
+
50
+ /**
51
+ * Extract a balanced JSON object from text that starts with an
52
+ * ASK_GUARDIAN_APPROVAL prefix. Uses brace counting with string-literal
53
+ * awareness so that `}` or `}]` inside JSON string values does not
54
+ * terminate the match prematurely.
55
+ *
56
+ * Returns the extracted JSON string, the full marker text
57
+ * (prefix + JSON + "]"), and the start index — or null when:
58
+ * - no prefix is found,
59
+ * - braces are unbalanced (still streaming), or
60
+ * - the closing `]` has not yet arrived (prevents stripping
61
+ * the marker body while the bracket leaks into TTS in a later delta).
62
+ */
63
+ function extractBalancedJson(
64
+ text: string,
65
+ ): { json: string; fullMatch: string; startIndex: number } | null {
66
+ const prefixMatch = ASK_GUARDIAN_APPROVAL_PREFIX_RE.exec(text);
67
+ if (!prefixMatch) return null;
68
+
69
+ const prefixIdx = prefixMatch.index;
70
+ const jsonStart = prefixIdx + prefixMatch[0].length;
71
+ if (jsonStart >= text.length || text[jsonStart] !== '{') return null;
72
+
73
+ let depth = 0;
74
+ let inString = false;
75
+ let escape = false;
76
+
77
+ for (let i = jsonStart; i < text.length; i++) {
78
+ const ch = text[i];
79
+
80
+ if (escape) {
81
+ escape = false;
82
+ continue;
83
+ }
84
+
85
+ if (ch === '\\' && inString) {
86
+ escape = true;
87
+ continue;
88
+ }
89
+
90
+ if (ch === '"') {
91
+ inString = !inString;
92
+ continue;
93
+ }
94
+
95
+ if (inString) continue;
96
+
97
+ if (ch === '{') {
98
+ depth++;
99
+ } else if (ch === '}') {
100
+ depth--;
101
+ if (depth === 0) {
102
+ const jsonEnd = i + 1;
103
+ const json = text.slice(jsonStart, jsonEnd);
104
+ // Skip any whitespace between the closing '}' and the expected ']'.
105
+ // Models sometimes emit formatted markers with spaces or newlines
106
+ // before the bracket (e.g. `{ ... }\n]` or `{ ... } ]`).
107
+ let bracketIdx = jsonEnd;
108
+ while (bracketIdx < text.length && /\s/.test(text[bracketIdx])) {
109
+ bracketIdx++;
110
+ }
111
+ // Require the closing ']' to be present before considering this
112
+ // a complete match. If it hasn't arrived yet (streaming), return
113
+ // null so the caller keeps buffering.
114
+ if (bracketIdx >= text.length || text[bracketIdx] !== ']') {
115
+ return null;
116
+ }
117
+ const fullMatchEnd = bracketIdx + 1;
118
+ const fullMatch = text.slice(prefixIdx, fullMatchEnd);
119
+ return { json, fullMatch, startIndex: prefixIdx };
120
+ }
121
+ }
122
+ }
123
+
124
+ return null; // Unbalanced braces — still streaming
125
+ }
126
+
127
+ /**
128
+ * Strip all balanced ASK_GUARDIAN_APPROVAL markers from text, handling
129
+ * nested braces, string literals, and flexible whitespace correctly.
130
+ * Only strips complete markers (prefix + balanced JSON + closing `]`).
131
+ */
132
+ function stripGuardianApprovalMarkers(text: string): string {
133
+ let result = text;
134
+ for (;;) {
135
+ const match = extractBalancedJson(result);
136
+ if (!match) break;
137
+ result = result.slice(0, match.startIndex) + result.slice(match.startIndex + match.fullMatch.length);
138
+ }
139
+ return result;
140
+ }
141
+
44
142
  const USER_ANSWERED_MARKER_REGEX = /\[USER_ANSWERED:\s*.+?\]/g;
45
143
  const USER_INSTRUCTION_MARKER_REGEX = /\[USER_INSTRUCTION:\s*.+?\]/g;
46
144
  const CALL_OPENING_MARKER_REGEX = /\[CALL_OPENING\]/g;
@@ -53,7 +151,8 @@ const CALL_OPENING_ACK_MARKER = '[CALL_OPENING_ACK]';
53
151
  const END_CALL_MARKER = '[END_CALL]';
54
152
 
55
153
  function stripInternalSpeechMarkers(text: string): string {
56
- return text
154
+ let result = stripGuardianApprovalMarkers(text);
155
+ result = result
57
156
  .replace(ASK_GUARDIAN_MARKER_REGEX, '')
58
157
  .replace(USER_ANSWERED_MARKER_REGEX, '')
59
158
  .replace(USER_INSTRUCTION_MARKER_REGEX, '')
@@ -62,6 +161,7 @@ function stripInternalSpeechMarkers(text: string): string {
62
161
  .replace(END_CALL_MARKER_REGEX, '')
63
162
  .replace(GUARDIAN_TIMEOUT_MARKER_REGEX, '')
64
163
  .replace(GUARDIAN_UNAVAILABLE_MARKER_REGEX, '');
164
+ return result;
65
165
  }
66
166
 
67
167
  export class CallController {
@@ -414,6 +514,7 @@ export class CallController {
414
514
  // bracketed text (e.g. "[A]", "[note]") doesn't stall TTS.
415
515
  const afterBracket = ttsBuffer;
416
516
  const couldBeControl =
517
+ '[ASK_GUARDIAN_APPROVAL:'.startsWith(afterBracket) ||
417
518
  '[ASK_GUARDIAN:'.startsWith(afterBracket) ||
418
519
  '[USER_ANSWERED:'.startsWith(afterBracket) ||
419
520
  '[USER_INSTRUCTION:'.startsWith(afterBracket) ||
@@ -422,6 +523,7 @@ export class CallController {
422
523
  '[END_CALL]'.startsWith(afterBracket) ||
423
524
  '[GUARDIAN_TIMEOUT]'.startsWith(afterBracket) ||
424
525
  '[GUARDIAN_UNAVAILABLE]'.startsWith(afterBracket) ||
526
+ afterBracket.startsWith('[ASK_GUARDIAN_APPROVAL:') ||
425
527
  afterBracket.startsWith('[ASK_GUARDIAN:') ||
426
528
  afterBracket.startsWith('[USER_ANSWERED:') ||
427
529
  afterBracket.startsWith('[USER_INSTRUCTION:') ||
@@ -528,11 +630,30 @@ export class CallController {
528
630
  }
529
631
  }
530
632
 
531
- // Check for ASK_GUARDIAN pattern
532
- const askMatch = responseText.match(ASK_GUARDIAN_CAPTURE_REGEX);
533
- if (askMatch) {
534
- const questionText = askMatch[1];
633
+ // Check for structured tool-approval ASK_GUARDIAN_APPROVAL first,
634
+ // then informational ASK_GUARDIAN. Uses brace-balanced extraction so
635
+ // `}]` inside JSON string values does not truncate the payload or
636
+ // leak partial JSON into TTS output.
637
+ const approvalMatch = extractBalancedJson(responseText);
638
+ let approvalQuestion: string | null = null;
639
+ if (approvalMatch) {
640
+ try {
641
+ const parsed = JSON.parse(approvalMatch.json) as { question?: string };
642
+ if (parsed.question) {
643
+ approvalQuestion = parsed.question;
644
+ }
645
+ } catch {
646
+ log.warn({ callSessionId: this.callSessionId }, 'Failed to parse ASK_GUARDIAN_APPROVAL JSON payload');
647
+ }
648
+ }
649
+
650
+ const askMatch = approvalQuestion
651
+ ? null // structured approval takes precedence
652
+ : responseText.match(ASK_GUARDIAN_CAPTURE_REGEX);
653
+
654
+ const questionText = approvalQuestion ?? (askMatch ? askMatch[1] : null);
535
655
 
656
+ if (questionText) {
536
657
  if (this.isCallerGuardian()) {
537
658
  // Caller IS the guardian — don't dispatch cross-channel.
538
659
  // Queue an instruction so the next turn asks them directly.
@@ -17,8 +17,8 @@ import {
17
17
  getExpiredGuardianActionRequests,
18
18
  } from '../memory/guardian-action-store.js';
19
19
  import { deliverChannelReply } from '../runtime/gateway-client.js';
20
- import type { GuardianActionCopyGenerator } from '../runtime/http-types.js';
21
20
  import { composeGuardianActionMessageGenerative } from '../runtime/guardian-action-message-composer.js';
21
+ import type { GuardianActionCopyGenerator } from '../runtime/http-types.js';
22
22
  import { getLogger } from '../util/logger.js';
23
23
  import { expirePendingQuestions } from './call-store.js';
24
24
 
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { getActiveBinding } from '../memory/channel-guardian-store.js';
11
11
  import {
12
+ countPendingRequestsByCallSessionId,
12
13
  createGuardianActionDelivery,
13
14
  createGuardianActionRequest,
14
15
  updateDeliveryStatus,
@@ -69,6 +70,12 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
69
70
  'Created guardian action request',
70
71
  );
71
72
 
73
+ // Count how many guardian requests are already pending for this call.
74
+ // This count is a candidate-affinity hint: the decision engine uses it
75
+ // to prefer reusing an existing thread when multiple questions arise
76
+ // in the same call session.
77
+ const activeGuardianRequestCount = countPendingRequestsByCallSessionId(callSessionId);
78
+
72
79
  // Route through the canonical notification pipeline. The paired vellum
73
80
  // conversation from this pipeline is the canonical guardian thread.
74
81
  let vellumDeliveryId: string | null = null;
@@ -90,6 +97,7 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
90
97
  callSessionId,
91
98
  questionText: pendingQuestion.questionText,
92
99
  pendingQuestionId: pendingQuestion.id,
100
+ activeGuardianRequestCount,
93
101
  },
94
102
  dedupeKey: `guardian:${request.id}`,
95
103
  onThreadCreated: (info) => {
@@ -129,7 +129,9 @@ function buildVoiceCallControlPrompt(opts: {
129
129
  const disclosureEnabled = config.calls?.disclosure?.enabled === true;
130
130
  const disclosureText = config.calls?.disclosure?.text?.trim();
131
131
  const disclosureRule = disclosureEnabled && disclosureText
132
- ? `0. ${disclosureText}`
132
+ ? opts.isInbound
133
+ ? `0. ${disclosureText} This is an inbound call you are answering, so rewrite any disclosure naturally for pickup context. Do NOT say "I'm calling", "I called you", or "I'm calling on behalf of".`
134
+ : `0. ${disclosureText}`
133
135
  : '0. Begin the conversation naturally.';
134
136
 
135
137
  const lines: string[] = ['<voice_call_control>'];
@@ -174,7 +176,7 @@ function buildVoiceCallControlPrompt(opts: {
174
176
  );
175
177
  } else {
176
178
  lines.push(
177
- '7. If the latest user turn is "(call connected — deliver opening greeting)", greet the caller warmly and ask how you can help. Vary the wording; do not use a fixed template.',
179
+ '7. If the latest user turn is "(call connected — deliver opening greeting)", this is an inbound call you are answering (not a call you initiated). Greet the caller warmly and ask how you can help. Introduce yourself once at the start using your assistant name if you know it (for example: "Hey there, this is Ava, Sam\'s assistant. How can I help?"). If your assistant name is not known, skip the name and just identify yourself as the guardian\'s assistant. Do NOT say "I\'m calling" or "I\'m calling on behalf of". Vary the wording; do not use a fixed template.',
178
180
  );
179
181
  }
180
182
  lines.push(
@@ -109,7 +109,7 @@ export function registerDevCommand(program: Command): void {
109
109
  .command('dev')
110
110
  .description('Run the daemon in dev mode with auto-restart on file changes')
111
111
  .action(async () => {
112
- const status = await getDaemonStatus();
112
+ let status = await getDaemonStatus();
113
113
  if (status.running) {
114
114
  log.info('Stopping existing daemon...');
115
115
  const stopResult = await stopDaemon();
@@ -117,6 +117,46 @@ export function registerDevCommand(program: Command): void {
117
117
  log.error('Failed to stop existing daemon — process survived SIGKILL');
118
118
  process.exit(1);
119
119
  }
120
+ } else if (status.pid) {
121
+ // PID file references a live process but the socket is unresponsive.
122
+ // This can happen during the daemon startup window before the socket
123
+ // is bound. Wait briefly for it to come up before replacing.
124
+ log.info('Daemon process alive but socket unresponsive — waiting for startup...');
125
+ const maxWait = 5000;
126
+ const interval = 500;
127
+ let waited = 0;
128
+ let resolved = false;
129
+ while (waited < maxWait) {
130
+ await new Promise((r) => setTimeout(r, interval));
131
+ waited += interval;
132
+ status = await getDaemonStatus();
133
+ if (status.running) {
134
+ // Socket came up — stop the daemon normally.
135
+ log.info('Daemon became responsive, stopping it...');
136
+ const stopResult = await stopDaemon();
137
+ if (!stopResult.stopped && stopResult.reason === 'stop_failed') {
138
+ log.error('Failed to stop existing daemon — process survived SIGKILL');
139
+ process.exit(1);
140
+ }
141
+ resolved = true;
142
+ break;
143
+ }
144
+ if (!status.pid) {
145
+ // Process exited on its own — PID file already cleaned up.
146
+ resolved = true;
147
+ break;
148
+ }
149
+ }
150
+ if (!resolved) {
151
+ // Still alive but unresponsive after waiting — stop it via stopDaemon()
152
+ // which handles SIGTERM → SIGKILL escalation and PID file cleanup.
153
+ log.info('Daemon still unresponsive after wait — stopping it...');
154
+ const stopResult = await stopDaemon();
155
+ if (!stopResult.stopped && stopResult.reason === 'stop_failed') {
156
+ log.error('Failed to stop existing daemon — process survived SIGKILL');
157
+ process.exit(1);
158
+ }
159
+ }
120
160
  }
121
161
 
122
162
  const mainPath = `${import.meta.dirname}/../daemon/main.ts`;
@@ -1,7 +1,5 @@
1
1
  _ Lines starting with _ are comments — they won't appear in the system prompt
2
-
3
- # UPDATES.md
4
-
2
+ _
5
3
  _ This file contains release update notes for the assistant.
6
4
  _ Each release block is wrapped with HTML comment markers:
7
5
  _ <!-- vellum-update-release:<version> -->
@@ -11,6 +9,7 @@ _
11
9
  _ Format is freeform markdown. Write notes that help the assistant
12
10
  _ understand what changed and how it affects behavior, capabilities,
13
11
  _ or available tools. Focus on what matters to the user experience.
14
-
15
- ## What's New
16
-
12
+ _
13
+ _ To add release notes, replace this content with real markdown
14
+ _ describing what changed. The sync will only materialize a bulletin
15
+ _ when non-comment content is present.
@@ -44,7 +44,9 @@ export function appendReleaseBlock(
44
44
  /** Extracts all version strings from release markers found in `content`. */
45
45
  export function extractReleaseIds(content: string): string[] {
46
46
  const ids: string[] = [];
47
+ MARKER_REGEX.lastIndex = 0;
47
48
  let match: RegExpExecArray | null;
49
+ // eslint-disable-next-line no-restricted-syntax -- RegExp.exec returns null
48
50
  while ((match = MARKER_REGEX.exec(content)) !== null) {
49
51
  ids.push(match[1]);
50
52
  }
@@ -4,7 +4,7 @@ const ACTIVE_RELEASES_KEY = 'updates:active_releases';
4
4
  const COMPLETED_RELEASES_KEY = 'updates:completed_releases';
5
5
 
6
6
  function parseReleaseArray(raw: string | null): string[] {
7
- if (raw === null) return [];
7
+ if (!raw) return [];
8
8
  try {
9
9
  const parsed = JSON.parse(raw);
10
10
  if (!Array.isArray(parsed)) return [];
@@ -0,0 +1,6 @@
1
+ import { join } from 'node:path';
2
+
3
+ /** Returns the path to the bundled UPDATES.md template. Extracted for testability. */
4
+ export function getTemplatePath(): string {
5
+ return join(import.meta.dirname ?? __dirname, 'templates', 'UPDATES.md');
6
+ }
@@ -1,6 +1,7 @@
1
- import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, lstatSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
2
 
3
+ import { getWorkspacePromptPath } from '../util/platform.js';
4
+ import { APP_VERSION } from '../version.js';
4
5
  import { stripCommentLines } from './system-prompt.js';
5
6
  import { appendReleaseBlock, hasReleaseBlock } from './update-bulletin-format.js';
6
7
  import {
@@ -10,8 +11,7 @@ import {
10
11
  markReleasesCompleted,
11
12
  setActiveReleases,
12
13
  } from './update-bulletin-state.js';
13
- import { APP_VERSION } from '../version.js';
14
- import { getWorkspacePromptPath } from '../util/platform.js';
14
+ import { getTemplatePath } from './update-bulletin-template-path.js';
15
15
 
16
16
  /**
17
17
  * Writes content to a file via a temp-file + rename to prevent partial/truncated
@@ -21,7 +21,22 @@ function atomicWriteFileSync(filePath: string, content: string): void {
21
21
  const tmpPath = `${filePath}.tmp.${process.pid}`;
22
22
  try {
23
23
  writeFileSync(tmpPath, content, 'utf-8');
24
- renameSync(tmpPath, filePath);
24
+ // Resolve symlinks so we rename to the real target, preserving the link.
25
+ // If the symlink is dangling (target doesn't exist), fall back to writing
26
+ // through the symlink path directly — realpathSync throws ENOENT for dangling links.
27
+ let targetPath = filePath;
28
+ try {
29
+ if (lstatSync(filePath, { throwIfNoEntry: false })?.isSymbolicLink()) {
30
+ targetPath = realpathSync(filePath);
31
+ }
32
+ } catch (err: unknown) {
33
+ // Dangling symlink — fall back to writing through the symlink path.
34
+ // Only swallow ENOENT (dangling target); re-throw ELOOP, EACCES, I/O faults, etc.
35
+ if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code !== 'ENOENT') {
36
+ throw err;
37
+ }
38
+ }
39
+ renameSync(tmpPath, targetPath);
25
40
  } catch (err) {
26
41
  try {
27
42
  unlinkSync(tmpPath);
@@ -57,7 +72,7 @@ export function syncUpdateBulletinOnStartup(): void {
57
72
  }
58
73
 
59
74
  // --- Template materialization ---
60
- const templatePath = join(import.meta.dirname ?? __dirname, 'templates', 'UPDATES.md');
75
+ const templatePath = getTemplatePath();
61
76
  if (!existsSync(templatePath)) return;
62
77
 
63
78
  const rawTemplate = readFileSync(templatePath, 'utf-8');
@@ -90,8 +90,9 @@ export class ConfigWatcher {
90
90
  /**
91
91
  * Start all file watchers. `onSessionEvict` is called when watched
92
92
  * files change and sessions need to be evicted for reload.
93
+ * `onIdentityChanged` is called when IDENTITY.md changes on disk.
93
94
  */
94
- start(onSessionEvict: () => void): void {
95
+ start(onSessionEvict: () => void, onIdentityChanged?: () => void): void {
95
96
  const workspaceDir = getWorkspaceDir();
96
97
  const protectedDir = join(getRootDir(), 'protected');
97
98
 
@@ -106,7 +107,7 @@ export class ConfigWatcher {
106
107
  }
107
108
  },
108
109
  'SOUL.md': () => onSessionEvict(),
109
- 'IDENTITY.md': () => onSessionEvict(),
110
+ 'IDENTITY.md': () => { onSessionEvict(); onIdentityChanged?.(); },
110
111
  'USER.md': () => onSessionEvict(),
111
112
  'LOOKS.md': () => onSessionEvict(),
112
113
  'UPDATES.md': () => onSessionEvict(),