@vellumai/assistant 0.3.13 → 0.3.14
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 +17 -3
- package/README.md +2 -0
- package/docs/architecture/scheduling.md +81 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +22 -0
- package/src/__tests__/channel-policy.test.ts +19 -0
- package/src/__tests__/guardian-control-plane-policy.test.ts +584 -0
- package/src/__tests__/intent-routing.test.ts +22 -0
- package/src/__tests__/ipc-snapshot.test.ts +10 -0
- package/src/__tests__/notification-routing-intent.test.ts +186 -0
- package/src/__tests__/recording-handler.test.ts +191 -31
- package/src/__tests__/recording-intent-fallback.test.ts +181 -0
- package/src/__tests__/recording-intent-handler.test.ts +593 -73
- package/src/__tests__/recording-intent.test.ts +739 -343
- package/src/__tests__/recording-state-machine.test.ts +1109 -0
- package/src/__tests__/reminder-store.test.ts +20 -18
- package/src/__tests__/reminder.test.ts +2 -1
- package/src/channels/config.ts +1 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +1 -11
- package/src/config/bundled-skills/screen-recording/SKILL.md +91 -12
- package/src/config/system-prompt.ts +5 -0
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
- package/src/daemon/handlers/misc.ts +258 -102
- package/src/daemon/handlers/recording.ts +417 -5
- package/src/daemon/handlers/sessions.ts +136 -62
- package/src/daemon/ipc-contract/computer-use.ts +23 -3
- package/src/daemon/ipc-contract/messages.ts +3 -1
- package/src/daemon/ipc-contract/shared.ts +6 -0
- package/src/daemon/ipc-contract-inventory.json +2 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/recording-executor.ts +180 -0
- package/src/daemon/recording-intent-fallback.ts +132 -0
- package/src/daemon/recording-intent.ts +306 -15
- package/src/daemon/session-tool-setup.ts +4 -0
- package/src/notifications/README.md +69 -1
- package/src/notifications/adapters/sms.ts +80 -0
- package/src/notifications/broadcaster.ts +1 -0
- package/src/notifications/copy-composer.ts +3 -3
- package/src/notifications/decision-engine.ts +70 -1
- package/src/notifications/decisions-store.ts +24 -0
- package/src/notifications/destination-resolver.ts +2 -1
- package/src/notifications/emit-signal.ts +35 -3
- package/src/notifications/signal.ts +6 -0
- package/src/notifications/types.ts +3 -0
- package/src/schedule/scheduler.ts +15 -3
- package/src/tools/executor.ts +29 -0
- package/src/tools/guardian-control-plane-policy.ts +141 -0
- package/src/tools/types.ts +2 -0
|
@@ -1,8 +1,26 @@
|
|
|
1
|
-
// Recording intent
|
|
2
|
-
//
|
|
1
|
+
// Recording intent resolution for standalone screen recording routing.
|
|
2
|
+
// Exports `resolveRecordingIntent` as the single public entry point for
|
|
3
|
+
// text-based intent detection. Handlers use this (or structured
|
|
4
|
+
// `commandIntent` payloads) to intercept recording-related prompts
|
|
3
5
|
// before they reach the classifier or create a CU session.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
+
//
|
|
7
|
+
// Internal helpers (detect/strip/classify) are kept as private utilities
|
|
8
|
+
// consumed only by `resolveRecordingIntent`.
|
|
9
|
+
|
|
10
|
+
type RecordingIntentClass = 'start_only' | 'stop_only' | 'mixed' | 'none';
|
|
11
|
+
|
|
12
|
+
export type RecordingIntentResult =
|
|
13
|
+
| { kind: 'none' }
|
|
14
|
+
| { kind: 'start_only' }
|
|
15
|
+
| { kind: 'stop_only' }
|
|
16
|
+
| { kind: 'start_with_remainder'; remainder: string }
|
|
17
|
+
| { kind: 'stop_with_remainder'; remainder: string }
|
|
18
|
+
| { kind: 'start_and_stop_only' }
|
|
19
|
+
| { kind: 'start_and_stop_with_remainder'; remainder: string }
|
|
20
|
+
| { kind: 'restart_only' }
|
|
21
|
+
| { kind: 'restart_with_remainder'; remainder: string }
|
|
22
|
+
| { kind: 'pause_only' }
|
|
23
|
+
| { kind: 'resume_only' };
|
|
6
24
|
|
|
7
25
|
// ─── Start recording patterns ────────────────────────────────────────────────
|
|
8
26
|
|
|
@@ -25,6 +43,29 @@ const STOP_RECORDING_PATTERNS: RegExp[] = [
|
|
|
25
43
|
/\bhalt\s+(the\s+)?recording\b/i,
|
|
26
44
|
];
|
|
27
45
|
|
|
46
|
+
// ─── Restart recording patterns (compound: stop + start a new one) ──────────
|
|
47
|
+
|
|
48
|
+
const RESTART_RECORDING_PATTERNS: RegExp[] = [
|
|
49
|
+
/\brestart\s+(the\s+)?recording\b/i,
|
|
50
|
+
/\bredo\s+(the\s+)?recording\b/i,
|
|
51
|
+
/\bstop\s+(the\s+)?recording\s+and\s+(start|begin)\s+(a\s+)?(new|fresh|another)\s+(recording|one)\b/i,
|
|
52
|
+
/\bstop\s+(the\s+)?recording\s+and\s+(start|begin)\s+(a\s+)?(new|fresh|another)[.!?\s]*$/i,
|
|
53
|
+
/\bstop\s+and\s+restart\s+(the\s+)?recording\b/i,
|
|
54
|
+
/\bstop\s+recording\s+and\s+start\s+(a\s+)?(new|another|fresh)\s+(recording|one)\b/i,
|
|
55
|
+
/\bstop\s+recording\s+and\s+start\s+(a\s+)?(new|another|fresh)[.!?\s]*$/i,
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// ─── Pause/resume recording patterns ────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
const PAUSE_RECORDING_PATTERNS: RegExp[] = [
|
|
61
|
+
/\bpause\s+(the\s+)?recording\b/i,
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const RESUME_RECORDING_PATTERNS: RegExp[] = [
|
|
65
|
+
/\bresume\s+(the\s+)?recording\b/i,
|
|
66
|
+
/\bunpause\s+(the\s+)?recording\b/i,
|
|
67
|
+
];
|
|
68
|
+
|
|
28
69
|
// ─── Stop-recording clause removal for mixed-intent prompts ─────────────────
|
|
29
70
|
|
|
30
71
|
const STOP_RECORDING_CLAUSE_PATTERNS: RegExp[] = [
|
|
@@ -47,17 +88,41 @@ const RECORDING_CLAUSE_PATTERNS: RegExp[] = [
|
|
|
47
88
|
/\brecord\s+(my\s+|the\s+)?screen\s+while\b/i,
|
|
48
89
|
];
|
|
49
90
|
|
|
91
|
+
// ─── Restart clause removal ─────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
const RESTART_RECORDING_CLAUSE_PATTERNS: RegExp[] = [
|
|
94
|
+
// Longer compound patterns first — avoids partial matches by shorter patterns
|
|
95
|
+
/\bstop\s+(the\s+)?recording\s+and\s+(start|begin)\s+(a\s+)?(new|fresh|another)\s+(recording|one)\b/i,
|
|
96
|
+
/\bstop\s+(the\s+)?recording\s+and\s+(start|begin)\s+(a\s+)?(new|fresh|another)[.!?\s]*$/i,
|
|
97
|
+
/\bstop\s+and\s+restart\s+(the\s+)?recording\b/i,
|
|
98
|
+
/\bstop\s+recording\s+and\s+start\s+(a\s+)?(new|another|fresh)\s+(recording|one)\b/i,
|
|
99
|
+
/\bstop\s+recording\s+and\s+start\s+(a\s+)?(new|another|fresh)[.!?\s]*$/i,
|
|
100
|
+
/\b(and\s+)?(also\s+)?restart\s+(the\s+)?recording\b/i,
|
|
101
|
+
/\b(and\s+)?(also\s+)?redo\s+(the\s+)?recording\b/i,
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
// ─── Pause/resume clause removal ────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
const PAUSE_RECORDING_CLAUSE_PATTERNS: RegExp[] = [
|
|
107
|
+
/\b(and\s+)?(also\s+)?pause\s+(the\s+)?recording\b/i,
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
const RESUME_RECORDING_CLAUSE_PATTERNS: RegExp[] = [
|
|
111
|
+
/\b(and\s+)?(also\s+)?resume\s+(the\s+)?recording\b/i,
|
|
112
|
+
/\b(and\s+)?(also\s+)?unpause\s+(the\s+)?recording\b/i,
|
|
113
|
+
];
|
|
114
|
+
|
|
50
115
|
/** Common polite/filler words stripped before checking intent-only status. */
|
|
51
116
|
const FILLER_PATTERN =
|
|
52
117
|
/\b(please|pls|plz|can\s+you|could\s+you|would\s+you|now|right\s+now|thanks|thank\s+you|thx|ty|for\s+me|ok(ay)?|hey|hi|just)\b/gi;
|
|
53
118
|
|
|
54
|
-
// ───
|
|
119
|
+
// ─── Internal helpers ────────────────────────────────────────────────────────
|
|
55
120
|
|
|
56
121
|
/**
|
|
57
122
|
* Returns true if the user's message includes any recording-related phrases.
|
|
58
123
|
* Does not distinguish between recording-only and mixed-intent prompts.
|
|
59
124
|
*/
|
|
60
|
-
|
|
125
|
+
function detectRecordingIntent(taskText: string): boolean {
|
|
61
126
|
return START_RECORDING_PATTERNS.some((p) => p.test(taskText));
|
|
62
127
|
}
|
|
63
128
|
|
|
@@ -67,7 +132,7 @@ export function detectRecordingIntent(taskText: string): boolean {
|
|
|
67
132
|
* "record my screen while I work" -> false (has CU task component)
|
|
68
133
|
* "open Chrome and record my screen" -> false (has CU task component)
|
|
69
134
|
*/
|
|
70
|
-
|
|
135
|
+
function isRecordingOnly(taskText: string): boolean {
|
|
71
136
|
if (!detectRecordingIntent(taskText)) return false;
|
|
72
137
|
|
|
73
138
|
// Strip the recording clause and check if anything substantive remains
|
|
@@ -84,16 +149,31 @@ export function isRecordingOnly(taskText: string): boolean {
|
|
|
84
149
|
* Requires explicit "stop/end/finish/halt recording" phrasing --
|
|
85
150
|
* bare "stop", "end it", or "quit" are too ambiguous and will not match.
|
|
86
151
|
*/
|
|
87
|
-
|
|
152
|
+
function detectStopRecordingIntent(taskText: string): boolean {
|
|
88
153
|
return STOP_RECORDING_PATTERNS.some((p) => p.test(taskText));
|
|
89
154
|
}
|
|
90
155
|
|
|
156
|
+
/** Returns true if any restart compound pattern matches. */
|
|
157
|
+
function detectRestartRecordingIntent(taskText: string): boolean {
|
|
158
|
+
return RESTART_RECORDING_PATTERNS.some((p) => p.test(taskText));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Returns true if any pause pattern matches. */
|
|
162
|
+
function detectPauseRecordingIntent(taskText: string): boolean {
|
|
163
|
+
return PAUSE_RECORDING_PATTERNS.some((p) => p.test(taskText));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Returns true if any resume pattern matches. */
|
|
167
|
+
function detectResumeRecordingIntent(taskText: string): boolean {
|
|
168
|
+
return RESUME_RECORDING_PATTERNS.some((p) => p.test(taskText));
|
|
169
|
+
}
|
|
170
|
+
|
|
91
171
|
/**
|
|
92
172
|
* Removes recording-related clauses from a task, returning the cleaned text.
|
|
93
173
|
* Used when a recording intent is embedded in a broader CU task so the
|
|
94
174
|
* recording portion can be handled separately while the task continues.
|
|
95
175
|
*/
|
|
96
|
-
|
|
176
|
+
function stripRecordingIntent(taskText: string): string {
|
|
97
177
|
let result = taskText;
|
|
98
178
|
for (const pattern of RECORDING_CLAUSE_PATTERNS) {
|
|
99
179
|
result = result.replace(pattern, '');
|
|
@@ -106,7 +186,7 @@ export function stripRecordingIntent(taskText: string): string {
|
|
|
106
186
|
* Removes stop-recording clauses from a message, returning the cleaned text.
|
|
107
187
|
* Analogous to stripRecordingIntent but for stop-recording phrases.
|
|
108
188
|
*/
|
|
109
|
-
|
|
189
|
+
function stripStopRecordingIntent(taskText: string): string {
|
|
110
190
|
let result = taskText;
|
|
111
191
|
for (const pattern of STOP_RECORDING_CLAUSE_PATTERNS) {
|
|
112
192
|
result = result.replace(pattern, '');
|
|
@@ -114,6 +194,33 @@ export function stripStopRecordingIntent(taskText: string): string {
|
|
|
114
194
|
return result.replace(/\s{2,}/g, ' ').trim();
|
|
115
195
|
}
|
|
116
196
|
|
|
197
|
+
/** Removes restart-recording clauses from text. */
|
|
198
|
+
function stripRestartRecordingIntent(taskText: string): string {
|
|
199
|
+
let result = taskText;
|
|
200
|
+
for (const pattern of RESTART_RECORDING_CLAUSE_PATTERNS) {
|
|
201
|
+
result = result.replace(pattern, '');
|
|
202
|
+
}
|
|
203
|
+
return result.replace(/\s{2,}/g, ' ').trim();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Removes pause-recording clauses from text. */
|
|
207
|
+
function stripPauseRecordingIntent(taskText: string): string {
|
|
208
|
+
let result = taskText;
|
|
209
|
+
for (const pattern of PAUSE_RECORDING_CLAUSE_PATTERNS) {
|
|
210
|
+
result = result.replace(pattern, '');
|
|
211
|
+
}
|
|
212
|
+
return result.replace(/\s{2,}/g, ' ').trim();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Removes resume-recording clauses from text. */
|
|
216
|
+
function stripResumeRecordingIntent(taskText: string): string {
|
|
217
|
+
let result = taskText;
|
|
218
|
+
for (const pattern of RESUME_RECORDING_CLAUSE_PATTERNS) {
|
|
219
|
+
result = result.replace(pattern, '');
|
|
220
|
+
}
|
|
221
|
+
return result.replace(/\s{2,}/g, ' ').trim();
|
|
222
|
+
}
|
|
223
|
+
|
|
117
224
|
/**
|
|
118
225
|
* Returns true if the prompt is purely about stopping recording with no
|
|
119
226
|
* additional task. Analogous to isRecordingOnly but for stop-recording.
|
|
@@ -121,7 +228,7 @@ export function stripStopRecordingIntent(taskText: string): string {
|
|
|
121
228
|
* "how do I stop recording?" -> false (has additional context)
|
|
122
229
|
* "stop recording and close the browser" -> false (has CU task component)
|
|
123
230
|
*/
|
|
124
|
-
|
|
231
|
+
function isStopRecordingOnly(taskText: string): boolean {
|
|
125
232
|
if (!detectStopRecordingIntent(taskText)) return false;
|
|
126
233
|
|
|
127
234
|
const stripped = stripStopRecordingIntent(taskText);
|
|
@@ -130,6 +237,30 @@ export function isStopRecordingOnly(taskText: string): boolean {
|
|
|
130
237
|
return withoutFillers.replace(/[.,;!?\s]+/g, '').length === 0;
|
|
131
238
|
}
|
|
132
239
|
|
|
240
|
+
/** Returns true if the text is purely a restart command (no additional task). */
|
|
241
|
+
function isRestartRecordingOnly(taskText: string): boolean {
|
|
242
|
+
if (!detectRestartRecordingIntent(taskText)) return false;
|
|
243
|
+
const stripped = stripRestartRecordingIntent(taskText);
|
|
244
|
+
const withoutFillers = stripped.replace(FILLER_PATTERN, '');
|
|
245
|
+
return withoutFillers.replace(/[.,;!?\s]+/g, '').length === 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Returns true if the text is purely a pause command (no additional task). */
|
|
249
|
+
function isPauseRecordingOnly(taskText: string): boolean {
|
|
250
|
+
if (!detectPauseRecordingIntent(taskText)) return false;
|
|
251
|
+
const stripped = stripPauseRecordingIntent(taskText);
|
|
252
|
+
const withoutFillers = stripped.replace(FILLER_PATTERN, '');
|
|
253
|
+
return withoutFillers.replace(/[.,;!?\s]+/g, '').length === 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Returns true if the text is purely a resume command (no additional task). */
|
|
257
|
+
function isResumeRecordingOnly(taskText: string): boolean {
|
|
258
|
+
if (!detectResumeRecordingIntent(taskText)) return false;
|
|
259
|
+
const stripped = stripResumeRecordingIntent(taskText);
|
|
260
|
+
const withoutFillers = stripped.replace(FILLER_PATTERN, '');
|
|
261
|
+
return withoutFillers.replace(/[.,;!?\s]+/g, '').length === 0;
|
|
262
|
+
}
|
|
263
|
+
|
|
133
264
|
// ─── Dynamic name normalization ─────────────────────────────────────────────
|
|
134
265
|
|
|
135
266
|
/**
|
|
@@ -159,7 +290,7 @@ export function stripDynamicNames(text: string, dynamicNames: string[]): string
|
|
|
159
290
|
* punctuation, and dynamic assistant names. Used to determine whether
|
|
160
291
|
* remaining text after stripping recording clauses needs further processing.
|
|
161
292
|
*/
|
|
162
|
-
|
|
293
|
+
function hasSubstantiveContent(text: string, dynamicNames?: string[]): boolean {
|
|
163
294
|
let cleaned = text;
|
|
164
295
|
if (dynamicNames && dynamicNames.length > 0) {
|
|
165
296
|
cleaned = stripDynamicNames(cleaned, dynamicNames);
|
|
@@ -175,22 +306,68 @@ export function hasSubstantiveContent(text: string, dynamicNames?: string[]): bo
|
|
|
175
306
|
* triggering recording side effects in the mixed handler. */
|
|
176
307
|
const WH_INTERROGATIVE = /^\s*(how|what|why|when|where|who|which)\b/i;
|
|
177
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Indirect informational patterns that indicate the user is asking *about*
|
|
311
|
+
* recording rather than commanding a recording action. These catch prompts
|
|
312
|
+
* where the WH-word is buried after polite filler or an informational verb:
|
|
313
|
+
*
|
|
314
|
+
* - "can you tell me how to stop recording?"
|
|
315
|
+
* - "explain how to stop the recording"
|
|
316
|
+
* - "is there a way to stop recording?"
|
|
317
|
+
* - "I'd like to know how to pause the recording"
|
|
318
|
+
* - "do you know how to start recording?"
|
|
319
|
+
*
|
|
320
|
+
* Critical: these must NOT match polite imperatives like "can you stop
|
|
321
|
+
* recording?" — the key distinction is the intermediary informational
|
|
322
|
+
* verb/phrase (tell/explain/describe/show + how, "is there a way", etc.).
|
|
323
|
+
*/
|
|
324
|
+
const INDIRECT_INFORMATIONAL_PATTERNS: RegExp[] = [
|
|
325
|
+
// "tell me how...", "can you explain how...", "show me how..."
|
|
326
|
+
/^\s*(can\s+you\s+|could\s+you\s+|would\s+you\s+)?(tell|explain|describe|show)\s+(me\s+)?how\b/i,
|
|
327
|
+
// "is there a way to...", "are there any ways to..."
|
|
328
|
+
/^\s*(is\s+there|are\s+there)\s+(a\s+|any\s+)?(ways?|methods?|options?|means)\s+to\b/i,
|
|
329
|
+
// "I'd like to know...", "I want to know..."
|
|
330
|
+
/^\s*(i('d|\s+would)\s+like\s+to\s+know|i\s+want\s+to\s+know)\b/i,
|
|
331
|
+
// "do you know how to...", "can I learn how to..."
|
|
332
|
+
/^\s*(do\s+you\s+know\s+how|can\s+i\s+learn(\s+how)?)\s+to\b/i,
|
|
333
|
+
// Bare informational verbs at start: "explain how...", "tell me how..."
|
|
334
|
+
/^\s*(explain|describe)\s+(to\s+me\s+)?how\b/i,
|
|
335
|
+
// "tell me about..." (informational, not imperative)
|
|
336
|
+
/^\s*(tell|explain|describe|show)\s+(me\s+)?(about\s+)?(how|what|why|when|where)\b/i,
|
|
337
|
+
];
|
|
338
|
+
|
|
178
339
|
/**
|
|
179
340
|
* Returns true if the text appears to be a question about recording rather
|
|
180
341
|
* than an imperative command that includes recording.
|
|
181
342
|
*
|
|
182
343
|
* "how do I stop recording?" → true (question — don't trigger side effects)
|
|
344
|
+
* "can you tell me how to stop recording?" → true (informational — don't trigger)
|
|
345
|
+
* "explain how to stop the recording" → true (informational — don't trigger)
|
|
346
|
+
* "is there a way to stop recording?" → true (capability question — don't trigger)
|
|
183
347
|
* "open Chrome and record my screen" → false (command — trigger recording)
|
|
184
348
|
* "can you record my screen?" → false (polite imperative — trigger recording)
|
|
349
|
+
* "can you stop recording?" → false (polite imperative — trigger stop)
|
|
185
350
|
*/
|
|
186
|
-
|
|
351
|
+
function isInterrogative(text: string, dynamicNames?: string[]): boolean {
|
|
187
352
|
let cleaned = text;
|
|
188
353
|
if (dynamicNames && dynamicNames.length > 0) {
|
|
189
354
|
cleaned = stripDynamicNames(cleaned, dynamicNames);
|
|
190
355
|
}
|
|
191
356
|
// Strip polite prefixes that don't change interrogative status
|
|
192
357
|
cleaned = cleaned.replace(/^\s*(hey|hi|hello|please|pls|plz)[,\s]+/i, '');
|
|
193
|
-
|
|
358
|
+
|
|
359
|
+
// Direct WH-questions (how/what/why/when/where/who/which)
|
|
360
|
+
if (WH_INTERROGATIVE.test(cleaned)) {
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Indirect informational patterns — checked on cleaned text after
|
|
365
|
+
// stripping polite prefixes, so "please tell me how..." is caught
|
|
366
|
+
if (INDIRECT_INFORMATIONAL_PATTERNS.some((p) => p.test(cleaned))) {
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return false;
|
|
194
371
|
}
|
|
195
372
|
|
|
196
373
|
// ─── Unified classification ─────────────────────────────────────────────────
|
|
@@ -206,7 +383,7 @@ export function isInterrogative(text: string, dynamicNames?: string[]): boolean
|
|
|
206
383
|
* If `dynamicNames` are provided, they are stripped from the beginning of the
|
|
207
384
|
* text before classification (e.g., "Nova, record my screen" -> "record my screen").
|
|
208
385
|
*/
|
|
209
|
-
|
|
386
|
+
function classifyRecordingIntent(
|
|
210
387
|
taskText: string,
|
|
211
388
|
dynamicNames?: string[],
|
|
212
389
|
): RecordingIntentClass {
|
|
@@ -231,3 +408,117 @@ export function classifyRecordingIntent(
|
|
|
231
408
|
|
|
232
409
|
return 'none';
|
|
233
410
|
}
|
|
411
|
+
|
|
412
|
+
// ─── Structured intent resolver ─────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Resolves recording intent from user text into a structured result that
|
|
416
|
+
* distinguishes pure recording commands from commands with remaining task text.
|
|
417
|
+
*
|
|
418
|
+
* Pipeline:
|
|
419
|
+
* 1. Strip dynamic assistant names (leading vocative)
|
|
420
|
+
* 2. Strip leading polite wrappers
|
|
421
|
+
* 3. Interrogative gate — questions return `none`
|
|
422
|
+
* 3.5. Restart compound detection (before independent start/stop)
|
|
423
|
+
* 3.6. Pause/resume detection
|
|
424
|
+
* 4. Detect start/stop patterns (start takes precedence when both present)
|
|
425
|
+
* 5. Determine if recording-only or has a remainder, stripping from the
|
|
426
|
+
* ORIGINAL text to preserve the user's exact phrasing
|
|
427
|
+
*/
|
|
428
|
+
export function resolveRecordingIntent(
|
|
429
|
+
text: string,
|
|
430
|
+
dynamicNames?: string[],
|
|
431
|
+
): RecordingIntentResult {
|
|
432
|
+
// Step 1: Strip dynamic assistant names for normalization
|
|
433
|
+
let normalized =
|
|
434
|
+
dynamicNames && dynamicNames.length > 0
|
|
435
|
+
? stripDynamicNames(text, dynamicNames)
|
|
436
|
+
: text;
|
|
437
|
+
|
|
438
|
+
// Step 2: Strip leading polite wrappers for normalization
|
|
439
|
+
normalized = normalized.replace(/^\s*(hey|hi|hello|please|pls|plz)[,\s]+/i, '');
|
|
440
|
+
|
|
441
|
+
// Step 3: Interrogative gate — questions (WH-words and indirect
|
|
442
|
+
// informational patterns) are not commands
|
|
443
|
+
if (isInterrogative(normalized, dynamicNames)) {
|
|
444
|
+
return { kind: 'none' };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Step 3.5: Restart compound detection — check BEFORE independent start/stop
|
|
448
|
+
// so "stop recording and start a new one" is recognized as restart, not
|
|
449
|
+
// as separate stop + start patterns.
|
|
450
|
+
if (detectRestartRecordingIntent(normalized)) {
|
|
451
|
+
if (isRestartRecordingOnly(normalized)) {
|
|
452
|
+
return { kind: 'restart_only' };
|
|
453
|
+
}
|
|
454
|
+
// Strip from the ORIGINAL text to preserve user's exact phrasing
|
|
455
|
+
const remainder = stripRestartRecordingIntent(text);
|
|
456
|
+
if (hasSubstantiveContent(remainder, dynamicNames)) {
|
|
457
|
+
return { kind: 'restart_with_remainder', remainder };
|
|
458
|
+
}
|
|
459
|
+
return { kind: 'restart_only' };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Step 3.6: Pause/resume detection — check before start/stop
|
|
463
|
+
if (detectPauseRecordingIntent(normalized)) {
|
|
464
|
+
if (isPauseRecordingOnly(normalized)) {
|
|
465
|
+
return { kind: 'pause_only' };
|
|
466
|
+
}
|
|
467
|
+
// Pause with additional text falls through to normal processing
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (detectResumeRecordingIntent(normalized)) {
|
|
471
|
+
if (isResumeRecordingOnly(normalized)) {
|
|
472
|
+
return { kind: 'resume_only' };
|
|
473
|
+
}
|
|
474
|
+
// Resume with additional text falls through to normal processing
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Step 4: Detect start and stop patterns on the normalized text
|
|
478
|
+
const hasStart = detectRecordingIntent(normalized);
|
|
479
|
+
const hasStop = detectStopRecordingIntent(normalized);
|
|
480
|
+
|
|
481
|
+
// Step 5: Resolve
|
|
482
|
+
if (hasStart) {
|
|
483
|
+
if (hasStop) {
|
|
484
|
+
// Both start and stop detected — use combined variants
|
|
485
|
+
if (isRecordingOnly(normalized)) {
|
|
486
|
+
// Check if stop-only after stripping start patterns
|
|
487
|
+
const withoutStart = stripRecordingIntent(normalized);
|
|
488
|
+
if (isStopRecordingOnly(withoutStart)) {
|
|
489
|
+
return { kind: 'start_and_stop_only' };
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
let remainder = stripRecordingIntent(text);
|
|
493
|
+
remainder = stripStopRecordingIntent(remainder);
|
|
494
|
+
if (hasSubstantiveContent(remainder, dynamicNames)) {
|
|
495
|
+
return { kind: 'start_and_stop_with_remainder', remainder };
|
|
496
|
+
}
|
|
497
|
+
return { kind: 'start_and_stop_only' };
|
|
498
|
+
}
|
|
499
|
+
// Only start detected
|
|
500
|
+
if (isRecordingOnly(normalized)) {
|
|
501
|
+
return { kind: 'start_only' };
|
|
502
|
+
}
|
|
503
|
+
// Strip from the ORIGINAL text to preserve user's exact phrasing
|
|
504
|
+
const remainder = stripRecordingIntent(text);
|
|
505
|
+
if (hasSubstantiveContent(remainder, dynamicNames)) {
|
|
506
|
+
return { kind: 'start_with_remainder', remainder };
|
|
507
|
+
}
|
|
508
|
+
return { kind: 'start_only' };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (hasStop) {
|
|
512
|
+
if (isStopRecordingOnly(normalized)) {
|
|
513
|
+
return { kind: 'stop_only' };
|
|
514
|
+
}
|
|
515
|
+
// Strip from the ORIGINAL text to preserve user's exact phrasing
|
|
516
|
+
const remainder = stripStopRecordingIntent(text);
|
|
517
|
+
if (hasSubstantiveContent(remainder, dynamicNames)) {
|
|
518
|
+
return { kind: 'stop_with_remainder', remainder };
|
|
519
|
+
}
|
|
520
|
+
return { kind: 'stop_only' };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return { kind: 'none' };
|
|
524
|
+
}
|
|
@@ -26,6 +26,7 @@ import type { ProxyApprovalCallback, ProxyApprovalRequest } from '../tools/netwo
|
|
|
26
26
|
import { getAllToolDefinitions } from '../tools/registry.js';
|
|
27
27
|
import { allUiSurfaceTools } from '../tools/ui-surface/definitions.js';
|
|
28
28
|
import { projectSkillTools, type SkillProjectionCache } from './session-skill-tools.js';
|
|
29
|
+
import type { GuardianRuntimeContext } from './session-runtime-assembly.js';
|
|
29
30
|
import type { SurfaceSessionContext } from './session-surfaces.js';
|
|
30
31
|
import {
|
|
31
32
|
surfaceProxyResolver,
|
|
@@ -55,6 +56,8 @@ export interface ToolSetupContext extends SurfaceSessionContext {
|
|
|
55
56
|
headlessLock?: boolean;
|
|
56
57
|
/** When set, this session is executing a task run. Used to retrieve ephemeral permission rules. */
|
|
57
58
|
taskRunId?: string;
|
|
59
|
+
/** Guardian runtime context for the session — actorRole is propagated into ToolContext for control-plane policy enforcement. */
|
|
60
|
+
guardianContext?: GuardianRuntimeContext;
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
// ── buildToolDefinitions ─────────────────────────────────────────────
|
|
@@ -105,6 +108,7 @@ export function createToolExecutor(
|
|
|
105
108
|
assistantId: ctx.assistantId,
|
|
106
109
|
requestId: ctx.currentRequestId,
|
|
107
110
|
taskRunId: ctx.taskRunId,
|
|
111
|
+
guardianActorRole: ctx.guardianContext?.actorRole,
|
|
108
112
|
onOutput,
|
|
109
113
|
signal: ctx.abortController?.signal,
|
|
110
114
|
sandboxOverride: ctx.sandboxOverride,
|
|
@@ -154,9 +154,66 @@ The macOS/iOS client listens for this event and surfaces the thread in the sideb
|
|
|
154
154
|
|
|
155
155
|
`emitNotificationSignal()` accepts an optional `onThreadCreated` callback. This lets producers run domain side effects (for example, creating cross-channel guardian delivery rows) as soon as vellum pairing occurs, without introducing a second thread-creation path.
|
|
156
156
|
|
|
157
|
+
## Reminder Routing Metadata and Trigger-Time Enforcement
|
|
158
|
+
|
|
159
|
+
Reminders carry optional routing metadata that controls how notifications fan out across channels when the reminder fires. This enables a single reminder to produce multi-channel delivery without requiring the user to create duplicate reminders per channel.
|
|
160
|
+
|
|
161
|
+
### Routing Intent Model
|
|
162
|
+
|
|
163
|
+
The `routing_intent` field on each reminder row specifies the desired channel coverage:
|
|
164
|
+
|
|
165
|
+
| Intent | Behavior | When to use |
|
|
166
|
+
|--------|----------|-------------|
|
|
167
|
+
| `single_channel` | Default LLM-driven routing (no override) | Standard reminders where the decision engine picks the best channel |
|
|
168
|
+
| `multi_channel` | Ensures delivery on 2+ channels when 2+ are connected | Important reminders the user wants on both desktop and phone |
|
|
169
|
+
| `all_channels` | Forces delivery on every connected channel | Critical reminders that must reach the user everywhere |
|
|
170
|
+
|
|
171
|
+
The default is `single_channel`, preserving backward compatibility. Routing intent is persisted in the `reminders` table (`routing_intent` column) and carried through the notification signal as `routingIntent`.
|
|
172
|
+
|
|
173
|
+
### Routing Hints
|
|
174
|
+
|
|
175
|
+
The `routing_hints` field is free-form JSON metadata passed alongside the routing intent. It flows through the signal as `routingHints` and is included in the decision engine prompt, allowing producers to communicate channel preferences or contextual hints without requiring schema changes.
|
|
176
|
+
|
|
177
|
+
### Trigger-Time Enforcement Flow
|
|
178
|
+
|
|
179
|
+
When a reminder fires, the routing metadata flows through the notification pipeline with a post-decision enforcement step:
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
Reminder fires (scheduler)
|
|
183
|
+
→ emitNotificationSignal({ routingIntent, routingHints })
|
|
184
|
+
→ Decision Engine (LLM selects channels)
|
|
185
|
+
→ enforceRoutingIntent() (post-decision guard)
|
|
186
|
+
→ Deterministic Checks
|
|
187
|
+
→ Broadcaster → Adapters → Delivery
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The `enforceRoutingIntent()` function in `decision-engine.ts` runs after the LLM produces its channel selection but before deterministic checks. It overrides the decision's `selectedChannels` based on the routing intent:
|
|
191
|
+
|
|
192
|
+
- **`all_channels`**: Replaces `selectedChannels` with all connected channels (from `getConnectedChannels()`).
|
|
193
|
+
- **`multi_channel`**: If the LLM selected fewer than 2 channels but 2+ are connected, expands `selectedChannels` to all connected channels.
|
|
194
|
+
- **`single_channel`**: No override -- the LLM's selection stands.
|
|
195
|
+
|
|
196
|
+
When enforcement changes the decision, the updated channel selection is re-persisted to the `notification_decisions` table so the stored decision matches what was actually dispatched. The `reasoningSummary` is annotated with the enforcement action (e.g. `[routing_intent=all_channels enforced: vellum, telegram, sms]`).
|
|
197
|
+
|
|
198
|
+
### Single-Reminder Fanout
|
|
199
|
+
|
|
200
|
+
A key design principle: **one reminder produces one notification signal that fans out to multiple channels**. The user never needs to create separate reminders for each channel. The routing intent metadata on the single reminder controls the fanout behavior, and the notification pipeline handles per-channel copy rendering, conversation pairing, and delivery through the existing adapter infrastructure.
|
|
201
|
+
|
|
202
|
+
### Data Flow
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
reminders table (routing_intent, routing_hints_json)
|
|
206
|
+
→ scheduler.ts: claimDueReminders() reads routing metadata
|
|
207
|
+
→ lifecycle.ts: notifyReminder({ routingIntent, routingHints })
|
|
208
|
+
→ emitNotificationSignal({ routingIntent, routingHints })
|
|
209
|
+
→ signal.ts: NotificationSignal.routingIntent / routingHints
|
|
210
|
+
→ decision-engine.ts: evaluateSignal() → enforceRoutingIntent()
|
|
211
|
+
→ broadcaster.ts: fan-out to selected channel adapters
|
|
212
|
+
```
|
|
213
|
+
|
|
157
214
|
## Channel Delivery Architecture
|
|
158
215
|
|
|
159
|
-
The notification system delivers to
|
|
216
|
+
The notification system delivers to three channel types:
|
|
160
217
|
|
|
161
218
|
### Vellum (always connected)
|
|
162
219
|
|
|
@@ -172,12 +229,19 @@ The macOS/iOS client posts a native `UNUserNotificationCenter` notification from
|
|
|
172
229
|
|
|
173
230
|
HTTP POST to the gateway's `/deliver/telegram` endpoint. The `TelegramAdapter` sends channel-native text (`deliveryText` when present) to the guardian's chat ID (resolved from the active guardian binding), with deterministic fallbacks when model copy is unavailable.
|
|
174
231
|
|
|
232
|
+
### SMS (when guardian binding exists)
|
|
233
|
+
|
|
234
|
+
HTTP POST to the gateway's `/deliver/sms` endpoint. The `SmsAdapter` follows the same pattern as the Telegram adapter: it resolves a phone number from the active guardian binding and sends text via the gateway, which forwards to the Twilio Messages API. The adapter resolves message text via a priority chain: `deliveryText` > `body` > `title` > humanized event name. The `assistantId` is threaded through the `ChannelDeliveryPayload` so the gateway can resolve the correct outbound phone number for multi-assistant deployments.
|
|
235
|
+
|
|
236
|
+
SMS delivery is text-only (no MMS). Graceful degradation: when the gateway is unreachable or SMS is not configured, the adapter returns a failed `DeliveryResult` without throwing, so the broadcaster continues delivering to other channels.
|
|
237
|
+
|
|
175
238
|
### Channel Connectivity
|
|
176
239
|
|
|
177
240
|
Connected channels are resolved at signal emission time by `getConnectedChannels()` in `emit-signal.ts`:
|
|
178
241
|
|
|
179
242
|
- **Vellum** is always considered connected (IPC socket is always available when the daemon is running)
|
|
180
243
|
- **Telegram** is considered connected only when an active guardian binding exists for the assistant (checked via `getActiveBinding()`)
|
|
244
|
+
- **SMS** is considered connected only when an active guardian binding exists for the assistant (same check as Telegram)
|
|
181
245
|
|
|
182
246
|
## Conversation Materialization
|
|
183
247
|
|
|
@@ -211,6 +275,7 @@ For notification flows that create conversations, the conversation must be creat
|
|
|
211
275
|
| `destination-resolver.ts` | Resolves per-channel endpoints (vellum IPC, Telegram chat ID) |
|
|
212
276
|
| `adapters/macos.ts` | Vellum adapter -- broadcasts `notification_intent` via IPC with deep-link metadata |
|
|
213
277
|
| `adapters/telegram.ts` | Telegram adapter -- POSTs to gateway `/deliver/telegram` |
|
|
278
|
+
| `adapters/sms.ts` | SMS adapter -- POSTs to gateway `/deliver/sms` via Twilio Messages API |
|
|
214
279
|
| `preference-extractor.ts` | Detects notification preferences in conversation messages |
|
|
215
280
|
| `preference-summary.ts` | Builds preference context string for the decision engine prompt |
|
|
216
281
|
| `preferences-store.ts` | CRUD for `notification_preferences` table |
|
|
@@ -237,6 +302,9 @@ await emitNotificationSignal({
|
|
|
237
302
|
visibleInSourceNow: false,
|
|
238
303
|
},
|
|
239
304
|
contextPayload: { /* arbitrary data for the decision engine */ },
|
|
305
|
+
// Optional: control multi-channel fanout behavior
|
|
306
|
+
routingIntent: 'multi_channel', // 'single_channel' | 'multi_channel' | 'all_channels'
|
|
307
|
+
routingHints: { preferredChannels: ['telegram', 'sms'] },
|
|
240
308
|
});
|
|
241
309
|
```
|
|
242
310
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMS channel adapter — delivers notifications to phone numbers
|
|
3
|
+
* via the gateway's SMS delivery endpoint (`/deliver/sms`).
|
|
4
|
+
*
|
|
5
|
+
* Follows the same delivery pattern as the Telegram adapter: POST to
|
|
6
|
+
* the gateway's `/deliver/sms` endpoint with a phone number (as chatId)
|
|
7
|
+
* and text payload. The gateway forwards the message to the Twilio
|
|
8
|
+
* Messages API.
|
|
9
|
+
*
|
|
10
|
+
* Graceful degradation: when the gateway is unreachable or SMS is not
|
|
11
|
+
* configured, the adapter returns a failed DeliveryResult without throwing,
|
|
12
|
+
* so the broadcaster continues delivering to other channels.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { getGatewayInternalBaseUrl } from '../../config/env.js';
|
|
16
|
+
import { deliverChannelReply } from '../../runtime/gateway-client.js';
|
|
17
|
+
import { getLogger } from '../../util/logger.js';
|
|
18
|
+
import { readHttpToken } from '../../util/platform.js';
|
|
19
|
+
import { nonEmpty } from '../copy-composer.js';
|
|
20
|
+
import type {
|
|
21
|
+
ChannelAdapter,
|
|
22
|
+
ChannelDeliveryPayload,
|
|
23
|
+
ChannelDestination,
|
|
24
|
+
DeliveryResult,
|
|
25
|
+
NotificationChannel,
|
|
26
|
+
} from '../types.js';
|
|
27
|
+
|
|
28
|
+
const log = getLogger('notif-adapter-sms');
|
|
29
|
+
|
|
30
|
+
function resolveSmsMessageText(payload: ChannelDeliveryPayload): string {
|
|
31
|
+
const deliveryText = nonEmpty(payload.copy.deliveryText);
|
|
32
|
+
if (deliveryText) return deliveryText;
|
|
33
|
+
|
|
34
|
+
const body = nonEmpty(payload.copy.body);
|
|
35
|
+
if (body) return body;
|
|
36
|
+
|
|
37
|
+
const title = nonEmpty(payload.copy.title);
|
|
38
|
+
if (title) return title;
|
|
39
|
+
|
|
40
|
+
return payload.sourceEventName.replace(/[._]/g, ' ');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class SmsAdapter implements ChannelAdapter {
|
|
44
|
+
readonly channel: NotificationChannel = 'sms';
|
|
45
|
+
|
|
46
|
+
async send(payload: ChannelDeliveryPayload, destination: ChannelDestination): Promise<DeliveryResult> {
|
|
47
|
+
const phoneNumber = destination.endpoint;
|
|
48
|
+
if (!phoneNumber) {
|
|
49
|
+
log.warn({ sourceEventName: payload.sourceEventName }, 'SMS destination has no phone number — skipping');
|
|
50
|
+
return { success: false, error: 'No phone number configured for SMS destination' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const gatewayBase = getGatewayInternalBaseUrl();
|
|
54
|
+
const deliverUrl = `${gatewayBase}/deliver/sms`;
|
|
55
|
+
|
|
56
|
+
const messageText = resolveSmsMessageText(payload);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await deliverChannelReply(
|
|
60
|
+
deliverUrl,
|
|
61
|
+
{ chatId: phoneNumber, text: messageText, assistantId: payload.assistantId },
|
|
62
|
+
readHttpToken() ?? undefined,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
log.info(
|
|
66
|
+
{ sourceEventName: payload.sourceEventName, phoneNumber },
|
|
67
|
+
'SMS notification delivered',
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return { success: true };
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
73
|
+
log.error(
|
|
74
|
+
{ err, sourceEventName: payload.sourceEventName, phoneNumber },
|
|
75
|
+
'Failed to deliver SMS notification',
|
|
76
|
+
);
|
|
77
|
+
return { success: false, error: message };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -115,14 +115,14 @@ function applyChannelDefaults(
|
|
|
115
115
|
): RenderedChannelCopy {
|
|
116
116
|
const copy: RenderedChannelCopy = { ...baseCopy };
|
|
117
117
|
|
|
118
|
-
if (channel === 'telegram') {
|
|
119
|
-
copy.deliveryText =
|
|
118
|
+
if (channel === 'telegram' || channel === 'sms') {
|
|
119
|
+
copy.deliveryText = buildChatSurfaceFallbackDeliveryText(baseCopy, signal);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
return copy;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
function
|
|
125
|
+
function buildChatSurfaceFallbackDeliveryText(
|
|
126
126
|
baseCopy: RenderedChannelCopy,
|
|
127
127
|
signal: NotificationSignal,
|
|
128
128
|
): string {
|