@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.
- package/ARCHITECTURE.md +70 -13
- package/README.md +6 -0
- package/docs/architecture/http-token-refresh.md +23 -1
- package/package.json +1 -1
- package/src/__tests__/access-request-decision.test.ts +4 -7
- package/src/__tests__/channel-guardian.test.ts +3 -1
- package/src/__tests__/checker.test.ts +79 -48
- package/src/__tests__/config-watcher.test.ts +11 -13
- package/src/__tests__/conversation-pairing.test.ts +103 -3
- package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
- package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
- package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
- package/src/__tests__/guardian-action-store.test.ts +182 -0
- package/src/__tests__/guardian-dispatch.test.ts +120 -0
- package/src/__tests__/ipc-snapshot.test.ts +21 -0
- package/src/__tests__/non-member-access-request.test.ts +1 -2
- package/src/__tests__/notification-broadcaster.test.ts +115 -4
- package/src/__tests__/notification-decision-strategy.test.ts +2 -1
- package/src/__tests__/notification-deep-link.test.ts +44 -1
- package/src/__tests__/notification-guardian-path.test.ts +157 -0
- package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
- package/src/__tests__/slack-channel-config.test.ts +3 -3
- package/src/__tests__/trust-store.test.ts +21 -21
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
- package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
- package/src/__tests__/trusted-contact-verification.test.ts +9 -9
- package/src/__tests__/update-bulletin-state.test.ts +1 -1
- package/src/__tests__/update-bulletin.test.ts +66 -3
- package/src/__tests__/update-template-contract.test.ts +6 -11
- package/src/__tests__/voice-session-bridge.test.ts +109 -9
- package/src/calls/call-controller.ts +129 -8
- package/src/calls/guardian-action-sweep.ts +1 -1
- package/src/calls/guardian-dispatch.ts +8 -0
- package/src/calls/voice-session-bridge.ts +4 -2
- package/src/cli/core-commands.ts +41 -1
- package/src/config/templates/UPDATES.md +5 -6
- package/src/config/update-bulletin-format.ts +2 -0
- package/src/config/update-bulletin-state.ts +1 -1
- package/src/config/update-bulletin-template-path.ts +6 -0
- package/src/config/update-bulletin.ts +21 -6
- package/src/daemon/config-watcher.ts +3 -2
- package/src/daemon/daemon-control.ts +64 -10
- package/src/daemon/handlers/config-slack-channel.ts +1 -1
- package/src/daemon/handlers/identity.ts +45 -25
- package/src/daemon/handlers/sessions.ts +1 -1
- package/src/daemon/ipc-contract/sessions.ts +1 -1
- package/src/daemon/ipc-contract/workspace.ts +12 -1
- package/src/daemon/ipc-contract-inventory.json +1 -0
- package/src/daemon/lifecycle.ts +8 -0
- package/src/daemon/server.ts +25 -3
- package/src/daemon/session-process.ts +438 -184
- package/src/daemon/tls-certs.ts +17 -12
- package/src/daemon/tool-side-effects.ts +1 -1
- package/src/memory/channel-delivery-store.ts +18 -20
- package/src/memory/channel-guardian-store.ts +39 -42
- package/src/memory/conversation-crud.ts +2 -2
- package/src/memory/conversation-queries.ts +2 -2
- package/src/memory/conversation-store.ts +24 -25
- package/src/memory/db-init.ts +9 -1
- package/src/memory/fts-reconciler.ts +41 -26
- package/src/memory/guardian-action-store.ts +57 -7
- package/src/memory/guardian-verification.ts +1 -0
- package/src/memory/jobs-worker.ts +2 -2
- package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
- package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
- package/src/memory/migrations/index.ts +4 -2
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +6 -1
- package/src/memory/search/semantic.ts +3 -3
- package/src/notifications/README.md +158 -17
- package/src/notifications/broadcaster.ts +68 -50
- package/src/notifications/conversation-pairing.ts +96 -18
- package/src/notifications/decision-engine.ts +6 -3
- package/src/notifications/deliveries-store.ts +12 -0
- package/src/notifications/emit-signal.ts +1 -0
- package/src/notifications/thread-candidates.ts +60 -25
- package/src/notifications/types.ts +2 -1
- package/src/permissions/checker.ts +1 -16
- package/src/permissions/defaults.ts +14 -4
- package/src/runtime/guardian-action-followup-executor.ts +1 -1
- package/src/runtime/http-server.ts +11 -11
- package/src/runtime/routes/access-request-decision.ts +1 -1
- package/src/runtime/routes/debug-routes.ts +4 -4
- package/src/runtime/routes/guardian-approval-interception.ts +4 -4
- package/src/runtime/routes/inbound-message-handler.ts +6 -6
- package/src/runtime/routes/integration-routes.ts +2 -2
- package/src/tools/permission-checker.ts +1 -2
- package/src/tools/secret-detection-handler.ts +1 -1
- package/src/tools/system/voice-config.ts +1 -1
- 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
|
-
|
|
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
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
?
|
|
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)",
|
|
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(
|
package/src/cli/core-commands.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
7
|
+
if (!raw) return [];
|
|
8
8
|
try {
|
|
9
9
|
const parsed = JSON.parse(raw);
|
|
10
10
|
if (!Array.isArray(parsed)) return [];
|
|
@@ -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 {
|
|
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
|
-
|
|
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 =
|
|
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(),
|