@vellumai/assistant 0.3.13 → 0.3.15
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/Dockerfile +1 -1
- 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 +582 -0
- package/src/__tests__/guardian-outbound-http.test.ts +8 -8
- 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 +185 -0
- package/src/__tests__/recording-handler.test.ts +191 -31
- package/src/__tests__/recording-intent-fallback.test.ts +180 -0
- package/src/__tests__/recording-intent-handler.test.ts +597 -74
- package/src/__tests__/recording-intent.test.ts +738 -342
- 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/config-channels.ts +6 -6
- package/src/daemon/handlers/index.ts +1 -1
- package/src/daemon/handlers/misc.ts +258 -102
- package/src/daemon/handlers/recording.ts +417 -5
- package/src/daemon/handlers/sessions.ts +142 -68
- 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/memory/conversation-attention-store.ts +5 -5
- 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/runtime/guardian-outbound-actions.ts +9 -9
- package/src/runtime/http-server.ts +7 -7
- package/src/runtime/routes/conversation-attention-routes.ts +3 -3
- package/src/runtime/routes/integration-routes.ts +5 -5
- 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
|
@@ -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 {
|
|
@@ -18,7 +18,7 @@ import type { ModelIntent } from '../providers/types.js';
|
|
|
18
18
|
import { getLogger } from '../util/logger.js';
|
|
19
19
|
import { createDecision } from './decisions-store.js';
|
|
20
20
|
import { getPreferenceSummary } from './preference-summary.js';
|
|
21
|
-
import type { NotificationSignal } from './signal.js';
|
|
21
|
+
import type { NotificationSignal, RoutingIntent } from './signal.js';
|
|
22
22
|
import type { NotificationChannel, NotificationDecision, RenderedChannelCopy } from './types.js';
|
|
23
23
|
|
|
24
24
|
const log = getLogger('notification-decision-engine');
|
|
@@ -56,6 +56,12 @@ function buildSystemPrompt(
|
|
|
56
56
|
`- For low-urgency background events, suppress unless they match user preferences.`,
|
|
57
57
|
`- Generate a stable dedupeKey derived from the signal context so duplicate signals can be suppressed.`,
|
|
58
58
|
``,
|
|
59
|
+
`Routing intent (when present in the signal):`,
|
|
60
|
+
`- \`all_channels\`: The source explicitly requests notification on ALL connected channels.`,
|
|
61
|
+
`- \`multi_channel\`: The source prefers 2+ channels when 2+ are connected.`,
|
|
62
|
+
`- \`single_channel\`: Default routing behavior — use your best judgment (no override).`,
|
|
63
|
+
`When a routing intent is present, respect it in your channel selection. A post-decision guard will enforce the intent.`,
|
|
64
|
+
``,
|
|
59
65
|
`Copy guidelines (three distinct outputs):`,
|
|
60
66
|
`- \`title\` and \`body\` are for native notification popups (e.g. vellum desktop/mobile) — keep them short and glanceable (title ≤ 8 words, body ≤ 2 sentences).`,
|
|
61
67
|
`- \`deliveryText\` is the channel-native message for chat channels (e.g. telegram). It must read naturally as a standalone message.`,
|
|
@@ -91,6 +97,14 @@ function buildUserPrompt(signal: NotificationSignal): string {
|
|
|
91
97
|
parts.push(`Deadline: ${new Date(signal.attentionHints.deadlineAt).toISOString()}`);
|
|
92
98
|
}
|
|
93
99
|
|
|
100
|
+
if (signal.routingIntent && signal.routingIntent !== 'single_channel') {
|
|
101
|
+
parts.push(`Routing intent: ${signal.routingIntent}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (signal.routingHints && Object.keys(signal.routingHints).length > 0) {
|
|
105
|
+
parts.push(`Routing hints: ${JSON.stringify(signal.routingHints)}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
94
108
|
const payloadStr = JSON.stringify(signal.contextPayload);
|
|
95
109
|
if (payloadStr.length > 2) {
|
|
96
110
|
parts.push(``, `Context payload:`, payloadStr);
|
|
@@ -377,6 +391,61 @@ async function classifyWithLLM(
|
|
|
377
391
|
}
|
|
378
392
|
}
|
|
379
393
|
|
|
394
|
+
// ── Post-decision routing intent enforcement ───────────────────────────
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Enforce routing intent policy on a decision after the LLM has produced it.
|
|
398
|
+
* This is a fire-time guard: it overrides channel selection to match the
|
|
399
|
+
* routing intent specified by the signal source (e.g. a reminder).
|
|
400
|
+
*
|
|
401
|
+
* - `all_channels`: force selected channels to all connected channels.
|
|
402
|
+
* - `multi_channel`: ensure at least 2 channels when 2+ are connected.
|
|
403
|
+
* - `single_channel`: no override (default behavior).
|
|
404
|
+
*/
|
|
405
|
+
export function enforceRoutingIntent(
|
|
406
|
+
decision: NotificationDecision,
|
|
407
|
+
routingIntent: RoutingIntent | undefined,
|
|
408
|
+
connectedChannels: NotificationChannel[],
|
|
409
|
+
): NotificationDecision {
|
|
410
|
+
if (!routingIntent || routingIntent === 'single_channel') {
|
|
411
|
+
return decision;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (!decision.shouldNotify) {
|
|
415
|
+
return decision;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (routingIntent === 'all_channels') {
|
|
419
|
+
// Force all connected channels
|
|
420
|
+
if (connectedChannels.length > 0) {
|
|
421
|
+
const enforced = { ...decision };
|
|
422
|
+
enforced.selectedChannels = [...connectedChannels];
|
|
423
|
+
enforced.reasoningSummary = `${decision.reasoningSummary} [routing_intent=all_channels enforced: ${connectedChannels.join(', ')}]`;
|
|
424
|
+
log.info(
|
|
425
|
+
{ routingIntent, connectedChannels, originalChannels: decision.selectedChannels },
|
|
426
|
+
'Routing intent enforcement: all_channels → forced all connected channels',
|
|
427
|
+
);
|
|
428
|
+
return enforced;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (routingIntent === 'multi_channel') {
|
|
433
|
+
// Ensure at least 2 channels when 2+ are connected
|
|
434
|
+
if (connectedChannels.length >= 2 && decision.selectedChannels.length < 2) {
|
|
435
|
+
const enforced = { ...decision };
|
|
436
|
+
enforced.selectedChannels = [...connectedChannels];
|
|
437
|
+
enforced.reasoningSummary = `${decision.reasoningSummary} [routing_intent=multi_channel enforced: expanded to ${connectedChannels.join(', ')}]`;
|
|
438
|
+
log.info(
|
|
439
|
+
{ routingIntent, connectedChannels, originalChannels: decision.selectedChannels },
|
|
440
|
+
'Routing intent enforcement: multi_channel → expanded to all connected channels',
|
|
441
|
+
);
|
|
442
|
+
return enforced;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return decision;
|
|
447
|
+
}
|
|
448
|
+
|
|
380
449
|
// ── Persistence ────────────────────────────────────────────────────────
|
|
381
450
|
|
|
382
451
|
function persistDecision(signal: NotificationSignal, decision: NotificationDecision): string | undefined {
|
|
@@ -79,6 +79,30 @@ export function createDecision(params: CreateDecisionParams): NotificationDecisi
|
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
export interface UpdateDecisionParams {
|
|
83
|
+
selectedChannels?: string[];
|
|
84
|
+
reasoningSummary?: string;
|
|
85
|
+
validationResults?: Record<string, unknown>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Update an existing decision row (e.g. after routing intent enforcement). */
|
|
89
|
+
export function updateDecision(id: string, params: UpdateDecisionParams): void {
|
|
90
|
+
const db = getDb();
|
|
91
|
+
const updates: Record<string, unknown> = {};
|
|
92
|
+
if (params.selectedChannels !== undefined) {
|
|
93
|
+
updates.selectedChannels = JSON.stringify(params.selectedChannels);
|
|
94
|
+
}
|
|
95
|
+
if (params.reasoningSummary !== undefined) {
|
|
96
|
+
updates.reasoningSummary = params.reasoningSummary;
|
|
97
|
+
}
|
|
98
|
+
if (params.validationResults !== undefined) {
|
|
99
|
+
updates.validationResults = JSON.stringify(params.validationResults);
|
|
100
|
+
}
|
|
101
|
+
if (Object.keys(updates).length === 0) return;
|
|
102
|
+
|
|
103
|
+
db.update(notificationDecisions).set(updates).where(eq(notificationDecisions.id, id)).run();
|
|
104
|
+
}
|
|
105
|
+
|
|
82
106
|
/** Fetch a single decision by ID. */
|
|
83
107
|
export function getDecisionById(id: string): NotificationDecisionRow | null {
|
|
84
108
|
const db = getDb();
|
|
@@ -38,7 +38,8 @@ export function resolveDestinations(
|
|
|
38
38
|
result.set('vellum', { channel: 'vellum' });
|
|
39
39
|
break;
|
|
40
40
|
}
|
|
41
|
-
case 'telegram':
|
|
41
|
+
case 'telegram':
|
|
42
|
+
case 'sms': {
|
|
42
43
|
const binding = getActiveBinding(assistantId, channel);
|
|
43
44
|
if (binding) {
|
|
44
45
|
result.set(channel as NotificationChannel, {
|
|
@@ -15,13 +15,15 @@ import { getDeliverableChannels } from '../channels/config.js';
|
|
|
15
15
|
import { getActiveBinding } from '../memory/channel-guardian-store.js';
|
|
16
16
|
import { getLogger } from '../util/logger.js';
|
|
17
17
|
import { type BroadcastFn, VellumAdapter } from './adapters/macos.js';
|
|
18
|
+
import { SmsAdapter } from './adapters/sms.js';
|
|
18
19
|
import { TelegramAdapter } from './adapters/telegram.js';
|
|
19
20
|
import { NotificationBroadcaster,type ThreadCreatedInfo } from './broadcaster.js';
|
|
20
|
-
import { evaluateSignal } from './decision-engine.js';
|
|
21
|
+
import { enforceRoutingIntent, evaluateSignal } from './decision-engine.js';
|
|
22
|
+
import { updateDecision } from './decisions-store.js';
|
|
21
23
|
import { type DeterministicCheckContext, runDeterministicChecks } from './deterministic-checks.js';
|
|
22
24
|
import { createEvent, updateEventDedupeKey } from './events-store.js';
|
|
23
25
|
import { dispatchDecision } from './runtime-dispatch.js';
|
|
24
|
-
import type { AttentionHints, NotificationSignal } from './signal.js';
|
|
26
|
+
import type { AttentionHints, NotificationSignal, RoutingIntent } from './signal.js';
|
|
25
27
|
import type { NotificationChannel, NotificationDeliveryResult } from './types.js';
|
|
26
28
|
|
|
27
29
|
const log = getLogger('emit-signal');
|
|
@@ -46,6 +48,7 @@ function getBroadcaster(): NotificationBroadcaster {
|
|
|
46
48
|
if (!broadcasterInstance) {
|
|
47
49
|
const adapters = [
|
|
48
50
|
new TelegramAdapter(),
|
|
51
|
+
new SmsAdapter(),
|
|
49
52
|
];
|
|
50
53
|
if (registeredBroadcastFn) {
|
|
51
54
|
adapters.unshift(new VellumAdapter(registeredBroadcastFn));
|
|
@@ -90,6 +93,7 @@ function getConnectedChannels(assistantId: string): NotificationChannel[] {
|
|
|
90
93
|
channels.push(channel);
|
|
91
94
|
break;
|
|
92
95
|
case 'telegram':
|
|
96
|
+
case 'sms':
|
|
93
97
|
// Only report binding-based channels as connected when there is
|
|
94
98
|
// an active guardian binding for this assistant. Without a
|
|
95
99
|
// binding, the destination resolver will fail to resolve a
|
|
@@ -125,6 +129,10 @@ export interface EmitSignalParams {
|
|
|
125
129
|
attentionHints: AttentionHints;
|
|
126
130
|
/** Arbitrary context payload passed to the decision engine. */
|
|
127
131
|
contextPayload?: Record<string, unknown>;
|
|
132
|
+
/** Routing intent from the source (e.g. reminder). Controls post-decision channel enforcement. */
|
|
133
|
+
routingIntent?: RoutingIntent;
|
|
134
|
+
/** Free-form hints from the source for the decision engine. */
|
|
135
|
+
routingHints?: Record<string, unknown>;
|
|
128
136
|
/** Optional deduplication key. */
|
|
129
137
|
dedupeKey?: string;
|
|
130
138
|
/**
|
|
@@ -167,6 +175,8 @@ export async function emitNotificationSignal(params: EmitSignalParams): Promise<
|
|
|
167
175
|
sourceEventName: params.sourceEventName,
|
|
168
176
|
contextPayload: params.contextPayload ?? {},
|
|
169
177
|
attentionHints: params.attentionHints,
|
|
178
|
+
routingIntent: params.routingIntent,
|
|
179
|
+
routingHints: params.routingHints,
|
|
170
180
|
};
|
|
171
181
|
|
|
172
182
|
try {
|
|
@@ -195,7 +205,29 @@ export async function emitNotificationSignal(params: EmitSignalParams): Promise<
|
|
|
195
205
|
|
|
196
206
|
// Step 2: Evaluate the signal through the decision engine
|
|
197
207
|
const connectedChannels = getConnectedChannels(assistantId);
|
|
198
|
-
|
|
208
|
+
let decision = await evaluateSignal(signal, connectedChannels);
|
|
209
|
+
|
|
210
|
+
// Step 2.5: Enforce routing intent policy (fire-time guard)
|
|
211
|
+
const preEnforcementDecision = decision;
|
|
212
|
+
decision = enforceRoutingIntent(decision, signal.routingIntent, connectedChannels);
|
|
213
|
+
|
|
214
|
+
// Re-persist the decision if routing intent enforcement changed it,
|
|
215
|
+
// so the stored decision row matches what is actually dispatched.
|
|
216
|
+
if (decision !== preEnforcementDecision && decision.persistedDecisionId) {
|
|
217
|
+
try {
|
|
218
|
+
updateDecision(decision.persistedDecisionId, {
|
|
219
|
+
selectedChannels: decision.selectedChannels,
|
|
220
|
+
reasoningSummary: decision.reasoningSummary,
|
|
221
|
+
validationResults: {
|
|
222
|
+
dedupeKey: decision.dedupeKey,
|
|
223
|
+
channelCount: decision.selectedChannels.length,
|
|
224
|
+
hasCopy: Object.keys(decision.renderedCopy).length > 0,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
} catch (err) {
|
|
228
|
+
log.warn({ err, signalId }, 'Failed to re-persist decision after routing intent enforcement');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
199
231
|
|
|
200
232
|
// Persist model-generated dedupeKey back to the event row so future
|
|
201
233
|
// signals can deduplicate against it (the event was created with
|
|
@@ -12,6 +12,8 @@ export interface AttentionHints {
|
|
|
12
12
|
visibleInSourceNow: boolean;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export type RoutingIntent = 'single_channel' | 'multi_channel' | 'all_channels';
|
|
16
|
+
|
|
15
17
|
export interface NotificationSignal {
|
|
16
18
|
signalId: string;
|
|
17
19
|
assistantId: string;
|
|
@@ -21,4 +23,8 @@ export interface NotificationSignal {
|
|
|
21
23
|
sourceEventName: string; // free-form: 'reminder_fired', 'schedule_complete', 'guardian_question', etc.
|
|
22
24
|
contextPayload: Record<string, unknown>;
|
|
23
25
|
attentionHints: AttentionHints;
|
|
26
|
+
/** Routing intent from the source (e.g. reminder). Controls post-decision channel enforcement. */
|
|
27
|
+
routingIntent?: RoutingIntent;
|
|
28
|
+
/** Free-form hints from the source for the decision engine (e.g. preferred channels). */
|
|
29
|
+
routingHints?: Record<string, unknown>;
|
|
24
30
|
}
|
|
@@ -54,6 +54,9 @@ export interface ChannelDeliveryPayload {
|
|
|
54
54
|
/** Delivery audit record ID — passed through to the client for ack correlation. */
|
|
55
55
|
deliveryId?: string;
|
|
56
56
|
sourceEventName: string;
|
|
57
|
+
/** Originating assistant — used by channel adapters that need assistant-specific
|
|
58
|
+
* routing (e.g. SMS outbound number selection via the gateway). */
|
|
59
|
+
assistantId?: string;
|
|
57
60
|
copy: RenderedChannelCopy;
|
|
58
61
|
deepLinkTarget?: Record<string, unknown>;
|
|
59
62
|
}
|
|
@@ -7,8 +7,16 @@
|
|
|
7
7
|
* IPC handler (config-channels.ts) and the HTTP route layer (integration-routes.ts).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { randomBytes
|
|
10
|
+
import { createHash,randomBytes } from 'node:crypto';
|
|
11
11
|
|
|
12
|
+
import { startGuardianVerificationCall } from '../calls/call-domain.js';
|
|
13
|
+
import type { ChannelId } from '../channels/types.js';
|
|
14
|
+
import { getGatewayInternalBaseUrl } from '../config/env.js';
|
|
15
|
+
import { sendMessage as sendSms } from '../messaging/providers/sms/client.js';
|
|
16
|
+
import { getCredentialMetadata } from '../tools/credentials/metadata-store.js';
|
|
17
|
+
import { getLogger } from '../util/logger.js';
|
|
18
|
+
import { normalizePhoneNumber } from '../util/phone.js';
|
|
19
|
+
import { normalizeAssistantId, readHttpToken } from '../util/platform.js';
|
|
12
20
|
import {
|
|
13
21
|
countRecentSendsToDestination,
|
|
14
22
|
createOutboundSession,
|
|
@@ -22,14 +30,6 @@ import {
|
|
|
22
30
|
composeVerificationTelegram,
|
|
23
31
|
GUARDIAN_VERIFY_TEMPLATE_KEYS,
|
|
24
32
|
} from './guardian-verification-templates.js';
|
|
25
|
-
import { startGuardianVerificationCall } from '../calls/call-domain.js';
|
|
26
|
-
import { sendMessage as sendSms } from '../messaging/providers/sms/client.js';
|
|
27
|
-
import { getGatewayInternalBaseUrl } from '../config/env.js';
|
|
28
|
-
import { getCredentialMetadata } from '../tools/credentials/metadata-store.js';
|
|
29
|
-
import { normalizePhoneNumber } from '../util/phone.js';
|
|
30
|
-
import { normalizeAssistantId, readHttpToken } from '../util/platform.js';
|
|
31
|
-
import { getLogger } from '../util/logger.js';
|
|
32
|
-
import type { ChannelId } from '../channels/types.js';
|
|
33
33
|
|
|
34
34
|
const log = getLogger('guardian-outbound-actions');
|
|
35
35
|
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
} from '../config/env.js';
|
|
30
30
|
import type { ServerMessage } from '../daemon/ipc-contract.js';
|
|
31
31
|
import { PairingStore } from '../daemon/pairing-store.js';
|
|
32
|
-
import { type Confidence,
|
|
32
|
+
import { type Confidence, getAttentionStateByConversationIds, recordConversationSeenSignal,type SignalType } from '../memory/conversation-attention-store.js';
|
|
33
33
|
import * as conversationStore from '../memory/conversation-store.js';
|
|
34
34
|
import * as externalConversationStore from '../memory/external-conversation-store.js';
|
|
35
35
|
import { consumeCallback, consumeCallbackError } from '../security/oauth-callback-registry.js';
|
|
@@ -549,12 +549,12 @@ export class RuntimeHttpServer {
|
|
|
549
549
|
const originChannel = parseChannelId(c.originChannel);
|
|
550
550
|
const attn = attentionStates.get(c.id);
|
|
551
551
|
const assistantAttention = attn ? {
|
|
552
|
-
hasUnseenLatestAssistantMessage: attn.latestAssistantMessageAt
|
|
553
|
-
(attn.lastSeenAssistantMessageAt
|
|
554
|
-
...(attn.latestAssistantMessageAt
|
|
555
|
-
...(attn.lastSeenAssistantMessageAt
|
|
556
|
-
...(attn.lastSeenConfidence
|
|
557
|
-
...(attn.lastSeenSignalType
|
|
552
|
+
hasUnseenLatestAssistantMessage: attn.latestAssistantMessageAt != null &&
|
|
553
|
+
(attn.lastSeenAssistantMessageAt == null || attn.lastSeenAssistantMessageAt < attn.latestAssistantMessageAt),
|
|
554
|
+
...(attn.latestAssistantMessageAt != null ? { latestAssistantMessageAt: attn.latestAssistantMessageAt } : {}),
|
|
555
|
+
...(attn.lastSeenAssistantMessageAt != null ? { lastSeenAssistantMessageAt: attn.lastSeenAssistantMessageAt } : {}),
|
|
556
|
+
...(attn.lastSeenConfidence != null ? { lastSeenConfidence: attn.lastSeenConfidence } : {}),
|
|
557
|
+
...(attn.lastSeenSignalType != null ? { lastSeenSignalType: attn.lastSeenSignalType } : {}),
|
|
558
558
|
} : undefined;
|
|
559
559
|
return {
|
|
560
560
|
id: c.id,
|
|
@@ -61,9 +61,9 @@ export function handleListConversationAttention(url: URL): Response {
|
|
|
61
61
|
const results = pageStates.map((attn) => {
|
|
62
62
|
const conv = conversationMap.get(attn.conversationId);
|
|
63
63
|
const convSource = conv?.source ?? 'user';
|
|
64
|
-
const hasUnseen = attn.latestAssistantMessageAt
|
|
65
|
-
(attn.lastSeenAssistantMessageAt
|
|
66
|
-
const state: 'seen' | 'unseen' | 'no_assistant_message' = attn.latestAssistantMessageAt
|
|
64
|
+
const hasUnseen = attn.latestAssistantMessageAt != null &&
|
|
65
|
+
(attn.lastSeenAssistantMessageAt == null || attn.lastSeenAssistantMessageAt < attn.latestAssistantMessageAt);
|
|
66
|
+
const state: 'seen' | 'unseen' | 'no_assistant_message' = attn.latestAssistantMessageAt == null
|
|
67
67
|
? 'no_assistant_message'
|
|
68
68
|
: hasUnseen ? 'unseen' : 'seen';
|
|
69
69
|
|
|
@@ -21,11 +21,6 @@ import {
|
|
|
21
21
|
createGuardianChallenge,
|
|
22
22
|
getGuardianStatus,
|
|
23
23
|
} from '../../daemon/handlers/config-channels.js';
|
|
24
|
-
import {
|
|
25
|
-
startOutbound,
|
|
26
|
-
resendOutbound,
|
|
27
|
-
cancelOutbound,
|
|
28
|
-
} from '../guardian-outbound-actions.js';
|
|
29
24
|
import {
|
|
30
25
|
clearTelegramConfig,
|
|
31
26
|
getTelegramConfig,
|
|
@@ -33,6 +28,11 @@ import {
|
|
|
33
28
|
setTelegramConfig,
|
|
34
29
|
setupTelegram,
|
|
35
30
|
} from '../../daemon/handlers/config-telegram.js';
|
|
31
|
+
import {
|
|
32
|
+
cancelOutbound,
|
|
33
|
+
resendOutbound,
|
|
34
|
+
startOutbound,
|
|
35
|
+
} from '../guardian-outbound-actions.js';
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* GET /v1/integrations/telegram/config
|
|
@@ -2,7 +2,7 @@ import { createConversation } from '../memory/conversation-store.js';
|
|
|
2
2
|
import { GENERATING_TITLE, queueGenerateConversationTitle } from '../memory/conversation-title-service.js';
|
|
3
3
|
import { invalidateAssistantInferredItemsForConversation } from '../memory/task-memory-cleanup.js';
|
|
4
4
|
import { runSequencesOnce } from '../sequence/engine.js';
|
|
5
|
-
import { claimDueReminders, completeReminder, failReminder, setReminderConversationId } from '../tools/reminder/reminder-store.js';
|
|
5
|
+
import { claimDueReminders, completeReminder, failReminder, type RoutingIntent,setReminderConversationId } from '../tools/reminder/reminder-store.js';
|
|
6
6
|
import { getLogger } from '../util/logger.js';
|
|
7
7
|
import { runWatchersOnce, type WatcherEscalator,type WatcherNotifier } from '../watcher/engine.js';
|
|
8
8
|
import { hasSetConstructs } from './recurrence-engine.js';
|
|
@@ -19,7 +19,13 @@ export type ScheduleMessageProcessor = (
|
|
|
19
19
|
message: string,
|
|
20
20
|
) => Promise<unknown>;
|
|
21
21
|
|
|
22
|
-
export type ReminderNotifier = (reminder: {
|
|
22
|
+
export type ReminderNotifier = (reminder: {
|
|
23
|
+
id: string;
|
|
24
|
+
label: string;
|
|
25
|
+
message: string;
|
|
26
|
+
routingIntent: RoutingIntent;
|
|
27
|
+
routingHints: Record<string, unknown>;
|
|
28
|
+
}) => void;
|
|
23
29
|
|
|
24
30
|
export type ScheduleNotifier = (schedule: { id: string; name: string }) => void;
|
|
25
31
|
|
|
@@ -165,7 +171,13 @@ async function runScheduleOnce(
|
|
|
165
171
|
} else {
|
|
166
172
|
try {
|
|
167
173
|
log.info({ reminderId: reminder.id, label: reminder.label }, 'Firing reminder notification');
|
|
168
|
-
notifyReminder({
|
|
174
|
+
notifyReminder({
|
|
175
|
+
id: reminder.id,
|
|
176
|
+
label: reminder.label,
|
|
177
|
+
message: reminder.message,
|
|
178
|
+
routingIntent: reminder.routingIntent,
|
|
179
|
+
routingHints: reminder.routingHints,
|
|
180
|
+
});
|
|
169
181
|
completeReminder(reminder.id);
|
|
170
182
|
} catch (err) {
|
|
171
183
|
log.warn({ err, reminderId: reminder.id }, 'Reminder notification failed, reverting to pending');
|
package/src/tools/executor.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { pathExists, safeStatSync } from '../util/fs.js';
|
|
|
16
16
|
import { getLogger } from '../util/logger.js';
|
|
17
17
|
import { resolveExecutionTarget } from './execution-target.js';
|
|
18
18
|
import { executeWithTimeout,safeTimeoutMs } from './execution-timeout.js';
|
|
19
|
+
import { enforceGuardianOnlyPolicy } from './guardian-control-plane-policy.js';
|
|
19
20
|
import { buildPolicyContext } from './policy-context.js';
|
|
20
21
|
import { getAllTools,getTool } from './registry.js';
|
|
21
22
|
import { applyEdit } from './shared/filesystem/edit-engine.js';
|
|
@@ -111,6 +112,34 @@ export class ToolExecutor {
|
|
|
111
112
|
return { content: 'This tool is blocked by parental control settings.', isError: true };
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
// Reject tool invocations targeting guardian control-plane endpoints from non-guardian actors.
|
|
116
|
+
const guardianCheck = enforceGuardianOnlyPolicy(name, input, context.guardianActorRole);
|
|
117
|
+
if (guardianCheck.denied) {
|
|
118
|
+
log.warn({
|
|
119
|
+
toolName: name,
|
|
120
|
+
sessionId: context.sessionId,
|
|
121
|
+
conversationId: context.conversationId,
|
|
122
|
+
actorRole: context.guardianActorRole,
|
|
123
|
+
reason: 'guardian_only_policy',
|
|
124
|
+
}, 'Guardian-only policy blocked tool invocation');
|
|
125
|
+
const durationMs = Date.now() - startTime;
|
|
126
|
+
emitLifecycleEvent(context, {
|
|
127
|
+
type: 'permission_denied',
|
|
128
|
+
toolName: name,
|
|
129
|
+
executionTarget,
|
|
130
|
+
input,
|
|
131
|
+
workingDir: context.workingDir,
|
|
132
|
+
sessionId: context.sessionId,
|
|
133
|
+
conversationId: context.conversationId,
|
|
134
|
+
requestId: context.requestId,
|
|
135
|
+
riskLevel,
|
|
136
|
+
decision: 'deny',
|
|
137
|
+
reason: guardianCheck.reason!,
|
|
138
|
+
durationMs,
|
|
139
|
+
});
|
|
140
|
+
return { content: guardianCheck.reason!, isError: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
114
143
|
// Gate tools not active for the current turn
|
|
115
144
|
if (context.allowedToolNames && !context.allowedToolNames.has(name)) {
|
|
116
145
|
const msg = `Tool "${name}" is not currently active. Load the skill that provides this tool first.`;
|