@vellumai/assistant 0.3.7 → 0.3.9
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/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +20 -0
- package/src/__tests__/approval-routes-http.test.ts +704 -0
- package/src/__tests__/call-controller.test.ts +835 -0
- package/src/__tests__/call-state.test.ts +24 -24
- package/src/__tests__/ipc-snapshot.test.ts +14 -0
- package/src/__tests__/relay-server.test.ts +9 -9
- package/src/__tests__/run-orchestrator.test.ts +399 -3
- package/src/__tests__/runtime-runs.test.ts +12 -4
- package/src/__tests__/send-endpoint-busy.test.ts +284 -0
- package/src/__tests__/session-init.benchmark.test.ts +3 -3
- package/src/__tests__/subagent-manager-notify.test.ts +3 -3
- package/src/__tests__/voice-session-bridge.test.ts +869 -0
- package/src/calls/{call-orchestrator.ts → call-controller.ts} +156 -257
- package/src/calls/call-domain.ts +21 -21
- package/src/calls/call-state.ts +12 -12
- package/src/calls/guardian-dispatch.ts +43 -3
- package/src/calls/relay-server.ts +34 -39
- package/src/calls/twilio-routes.ts +3 -3
- package/src/calls/voice-session-bridge.ts +244 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +81 -14
- package/src/config/bundled-skills/media-processing/TOOLS.json +3 -3
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +3 -3
- package/src/config/defaults.ts +5 -0
- package/src/config/notifications-schema.ts +15 -0
- package/src/config/schema.ts +13 -0
- package/src/config/types.ts +1 -0
- package/src/daemon/daemon-control.ts +13 -12
- package/src/daemon/handlers/subagents.ts +10 -3
- package/src/daemon/ipc-contract/notifications.ts +9 -0
- package/src/daemon/ipc-contract-inventory.json +2 -0
- package/src/daemon/ipc-contract.ts +4 -1
- package/src/daemon/lifecycle.ts +100 -1
- package/src/daemon/server.ts +8 -0
- package/src/daemon/session-agent-loop.ts +4 -0
- package/src/daemon/session-process.ts +51 -0
- package/src/daemon/session-runtime-assembly.ts +32 -0
- package/src/daemon/session.ts +5 -0
- package/src/memory/db-init.ts +80 -0
- package/src/memory/guardian-action-store.ts +2 -2
- package/src/memory/migrations/016-memory-segments-indexes.ts +1 -0
- package/src/memory/migrations/019-notification-tables-schema-migration.ts +70 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +59 -1
- package/src/notifications/README.md +134 -0
- package/src/notifications/adapters/macos.ts +55 -0
- package/src/notifications/adapters/telegram.ts +65 -0
- package/src/notifications/broadcaster.ts +175 -0
- package/src/notifications/copy-composer.ts +118 -0
- package/src/notifications/decision-engine.ts +391 -0
- package/src/notifications/decisions-store.ts +158 -0
- package/src/notifications/deliveries-store.ts +130 -0
- package/src/notifications/destination-resolver.ts +54 -0
- package/src/notifications/deterministic-checks.ts +187 -0
- package/src/notifications/emit-signal.ts +191 -0
- package/src/notifications/events-store.ts +145 -0
- package/src/notifications/preference-extractor.ts +223 -0
- package/src/notifications/preference-summary.ts +110 -0
- package/src/notifications/preferences-store.ts +142 -0
- package/src/notifications/runtime-dispatch.ts +100 -0
- package/src/notifications/signal.ts +24 -0
- package/src/notifications/types.ts +75 -0
- package/src/runtime/http-server.ts +15 -0
- package/src/runtime/http-types.ts +22 -0
- package/src/runtime/pending-interactions.ts +73 -0
- package/src/runtime/routes/approval-routes.ts +179 -0
- package/src/runtime/routes/channel-inbound-routes.ts +39 -4
- package/src/runtime/routes/conversation-routes.ts +107 -1
- package/src/runtime/routes/run-routes.ts +1 -1
- package/src/runtime/run-orchestrator.ts +157 -2
- package/src/subagent/manager.ts +6 -6
- package/src/tools/browser/browser-manager.ts +1 -1
- package/src/tools/subagent/message.ts +9 -2
- package/src/__tests__/call-orchestrator.test.ts +0 -1496
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single entry point for all notification producers.
|
|
3
|
+
*
|
|
4
|
+
* emitNotificationSignal() creates a NotificationSignal, persists the event,
|
|
5
|
+
* and runs it through the decision engine + deterministic checks + dispatch
|
|
6
|
+
* pipeline.
|
|
7
|
+
*
|
|
8
|
+
* Designed for fire-and-forget usage: errors are logged but never propagated
|
|
9
|
+
* to the caller. The returned promise resolves even on failure.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { v4 as uuid } from 'uuid';
|
|
13
|
+
import { getConfig } from '../config/loader.js';
|
|
14
|
+
import { getLogger } from '../util/logger.js';
|
|
15
|
+
import { getActiveBinding } from '../memory/channel-guardian-store.js';
|
|
16
|
+
import { createEvent, updateEventDedupeKey } from './events-store.js';
|
|
17
|
+
import { evaluateSignal } from './decision-engine.js';
|
|
18
|
+
import { runDeterministicChecks, type DeterministicCheckContext } from './deterministic-checks.js';
|
|
19
|
+
import { dispatchDecision } from './runtime-dispatch.js';
|
|
20
|
+
import { NotificationBroadcaster } from './broadcaster.js';
|
|
21
|
+
import { MacOSAdapter, type BroadcastFn } from './adapters/macos.js';
|
|
22
|
+
import { TelegramAdapter } from './adapters/telegram.js';
|
|
23
|
+
import type { NotificationSignal, AttentionHints } from './signal.js';
|
|
24
|
+
import type { NotificationChannel } from './types.js';
|
|
25
|
+
|
|
26
|
+
const log = getLogger('emit-signal');
|
|
27
|
+
|
|
28
|
+
// ── Broadcaster singleton ──────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
let broadcasterInstance: NotificationBroadcaster | null = null;
|
|
31
|
+
let registeredBroadcastFn: BroadcastFn | null = null;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Register the IPC broadcast function so the macOS adapter can deliver
|
|
35
|
+
* notifications through the daemon's IPC socket. Must be called once
|
|
36
|
+
* during daemon startup (before any signals are emitted).
|
|
37
|
+
*/
|
|
38
|
+
export function registerBroadcastFn(fn: BroadcastFn): void {
|
|
39
|
+
registeredBroadcastFn = fn;
|
|
40
|
+
// Reset the broadcaster so it picks up the new broadcast function
|
|
41
|
+
broadcasterInstance = null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getBroadcaster(): NotificationBroadcaster {
|
|
45
|
+
if (!broadcasterInstance) {
|
|
46
|
+
const adapters = [
|
|
47
|
+
new TelegramAdapter(),
|
|
48
|
+
];
|
|
49
|
+
if (registeredBroadcastFn) {
|
|
50
|
+
adapters.unshift(new MacOSAdapter(registeredBroadcastFn));
|
|
51
|
+
}
|
|
52
|
+
broadcasterInstance = new NotificationBroadcaster(adapters);
|
|
53
|
+
}
|
|
54
|
+
return broadcasterInstance;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Connected channels resolution ──────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
function getConnectedChannels(assistantId: string): NotificationChannel[] {
|
|
60
|
+
// macOS is always considered connected (IPC socket is always available
|
|
61
|
+
// when the daemon is running).
|
|
62
|
+
const channels: NotificationChannel[] = ['macos'];
|
|
63
|
+
// Only report Telegram as connected when there is an active guardian
|
|
64
|
+
// binding for this assistant. Without a binding, the destination
|
|
65
|
+
// resolver will fail to resolve a chat ID and dispatch will silently
|
|
66
|
+
// drop the message — which is worse than the decision engine knowing
|
|
67
|
+
// up front that the channel is unavailable.
|
|
68
|
+
const telegramBinding = getActiveBinding(assistantId, 'telegram');
|
|
69
|
+
if (telegramBinding) {
|
|
70
|
+
channels.push('telegram');
|
|
71
|
+
}
|
|
72
|
+
return channels;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Public API ─────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export interface EmitSignalParams {
|
|
78
|
+
/** Free-form event name, e.g. 'reminder.fired', 'schedule.complete'. */
|
|
79
|
+
sourceEventName: string;
|
|
80
|
+
/** Source channel that produced the event. */
|
|
81
|
+
sourceChannel: string;
|
|
82
|
+
/** Session or conversation ID from the source context. */
|
|
83
|
+
sourceSessionId: string;
|
|
84
|
+
/** Logical assistant ID (defaults to 'self'). */
|
|
85
|
+
assistantId?: string;
|
|
86
|
+
/** Attention hints for the decision engine. */
|
|
87
|
+
attentionHints: AttentionHints;
|
|
88
|
+
/** Arbitrary context payload passed to the decision engine. */
|
|
89
|
+
contextPayload?: Record<string, unknown>;
|
|
90
|
+
/** Optional deduplication key. */
|
|
91
|
+
dedupeKey?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Emit a notification signal through the full pipeline:
|
|
96
|
+
* createEvent -> evaluateSignal -> runDeterministicChecks -> dispatchDecision.
|
|
97
|
+
*
|
|
98
|
+
* Fire-and-forget safe: all errors are caught and logged. The caller
|
|
99
|
+
* should not await this in critical paths unless it needs the result.
|
|
100
|
+
*/
|
|
101
|
+
export async function emitNotificationSignal(params: EmitSignalParams): Promise<void> {
|
|
102
|
+
const config = getConfig();
|
|
103
|
+
if (!config.notifications?.enabled) {
|
|
104
|
+
log.debug({ sourceEventName: params.sourceEventName }, 'Notification system disabled, skipping signal');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const signalId = uuid();
|
|
109
|
+
const assistantId = params.assistantId ?? 'self';
|
|
110
|
+
|
|
111
|
+
const signal: NotificationSignal = {
|
|
112
|
+
signalId,
|
|
113
|
+
assistantId,
|
|
114
|
+
createdAt: Date.now(),
|
|
115
|
+
sourceChannel: params.sourceChannel,
|
|
116
|
+
sourceSessionId: params.sourceSessionId,
|
|
117
|
+
sourceEventName: params.sourceEventName,
|
|
118
|
+
contextPayload: params.contextPayload ?? {},
|
|
119
|
+
attentionHints: params.attentionHints,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
// Step 1: Persist the event
|
|
124
|
+
const eventRow = createEvent({
|
|
125
|
+
id: signalId,
|
|
126
|
+
assistantId,
|
|
127
|
+
sourceEventName: params.sourceEventName,
|
|
128
|
+
sourceChannel: params.sourceChannel,
|
|
129
|
+
sourceSessionId: params.sourceSessionId,
|
|
130
|
+
attentionHints: params.attentionHints,
|
|
131
|
+
payload: params.contextPayload ?? {},
|
|
132
|
+
dedupeKey: params.dedupeKey,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (!eventRow) {
|
|
136
|
+
log.info({ signalId, dedupeKey: params.dedupeKey }, 'Signal deduplicated at event store level');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Step 2: Evaluate the signal through the decision engine
|
|
141
|
+
const connectedChannels = getConnectedChannels(assistantId);
|
|
142
|
+
const decision = await evaluateSignal(signal, connectedChannels);
|
|
143
|
+
|
|
144
|
+
// Persist model-generated dedupeKey back to the event row so future
|
|
145
|
+
// signals can deduplicate against it (the event was created with
|
|
146
|
+
// only the producer's dedupeKey, which may be null).
|
|
147
|
+
if (decision.dedupeKey && !params.dedupeKey) {
|
|
148
|
+
try {
|
|
149
|
+
updateEventDedupeKey(signalId, decision.dedupeKey);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
log.warn({ err, signalId }, 'Failed to persist decision dedupeKey to event row');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Step 3: Run deterministic pre-send checks
|
|
156
|
+
if (decision.shouldNotify) {
|
|
157
|
+
const checkContext: DeterministicCheckContext = {
|
|
158
|
+
connectedChannels,
|
|
159
|
+
};
|
|
160
|
+
const checkResult = await runDeterministicChecks(signal, decision, checkContext);
|
|
161
|
+
|
|
162
|
+
if (!checkResult.passed) {
|
|
163
|
+
log.info(
|
|
164
|
+
{ signalId, reason: checkResult.reason },
|
|
165
|
+
'Signal blocked by deterministic checks',
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Step 4: Dispatch through the broadcaster
|
|
172
|
+
const broadcaster = getBroadcaster();
|
|
173
|
+
const dispatchResult = await dispatchDecision(signal, decision, broadcaster);
|
|
174
|
+
|
|
175
|
+
log.info(
|
|
176
|
+
{
|
|
177
|
+
signalId,
|
|
178
|
+
sourceEventName: params.sourceEventName,
|
|
179
|
+
dispatched: dispatchResult.dispatched,
|
|
180
|
+
reason: dispatchResult.reason,
|
|
181
|
+
},
|
|
182
|
+
'Signal pipeline complete',
|
|
183
|
+
);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
186
|
+
log.error(
|
|
187
|
+
{ err: errMsg, signalId, sourceEventName: params.sourceEventName },
|
|
188
|
+
'Signal pipeline failed',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification event persistence.
|
|
3
|
+
*
|
|
4
|
+
* Each row represents a single notification signal that was emitted by
|
|
5
|
+
* the system. The event captures the source event name, attention hints,
|
|
6
|
+
* and context payload. Decision/delivery records are tracked separately.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { and, desc, eq } from 'drizzle-orm';
|
|
10
|
+
import { getDb } from '../memory/db.js';
|
|
11
|
+
import { notificationEvents } from '../memory/schema.js';
|
|
12
|
+
import type { AttentionHints } from './signal.js';
|
|
13
|
+
|
|
14
|
+
export interface NotificationEventRow {
|
|
15
|
+
id: string;
|
|
16
|
+
assistantId: string;
|
|
17
|
+
sourceEventName: string;
|
|
18
|
+
sourceChannel: string;
|
|
19
|
+
sourceSessionId: string;
|
|
20
|
+
attentionHintsJson: string;
|
|
21
|
+
payloadJson: string;
|
|
22
|
+
dedupeKey: string | null;
|
|
23
|
+
createdAt: number;
|
|
24
|
+
updatedAt: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function rowToEvent(row: typeof notificationEvents.$inferSelect): NotificationEventRow {
|
|
28
|
+
return {
|
|
29
|
+
id: row.id,
|
|
30
|
+
assistantId: row.assistantId,
|
|
31
|
+
sourceEventName: row.sourceEventName,
|
|
32
|
+
sourceChannel: row.sourceChannel,
|
|
33
|
+
sourceSessionId: row.sourceSessionId,
|
|
34
|
+
attentionHintsJson: row.attentionHintsJson,
|
|
35
|
+
payloadJson: row.payloadJson,
|
|
36
|
+
dedupeKey: row.dedupeKey,
|
|
37
|
+
createdAt: row.createdAt,
|
|
38
|
+
updatedAt: row.updatedAt,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CreateEventParams {
|
|
43
|
+
id: string;
|
|
44
|
+
assistantId: string;
|
|
45
|
+
sourceEventName: string;
|
|
46
|
+
sourceChannel: string;
|
|
47
|
+
sourceSessionId: string;
|
|
48
|
+
attentionHints: AttentionHints;
|
|
49
|
+
payload: Record<string, unknown>;
|
|
50
|
+
dedupeKey?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Create a new notification event. Returns null if a duplicate dedupe_key exists. */
|
|
54
|
+
export function createEvent(params: CreateEventParams): NotificationEventRow | null {
|
|
55
|
+
const db = getDb();
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
|
|
58
|
+
// Normalize empty strings to null so the falsy check below and the DB
|
|
59
|
+
// unique index stay in agreement (empty string is falsy in JS but would
|
|
60
|
+
// be stored as a non-null value in SQLite).
|
|
61
|
+
const normalizedDedupeKey = params.dedupeKey || null;
|
|
62
|
+
|
|
63
|
+
// If there's a dedupe key, check for duplicates first
|
|
64
|
+
if (normalizedDedupeKey) {
|
|
65
|
+
const existing = db
|
|
66
|
+
.select()
|
|
67
|
+
.from(notificationEvents)
|
|
68
|
+
.where(
|
|
69
|
+
and(
|
|
70
|
+
eq(notificationEvents.assistantId, params.assistantId),
|
|
71
|
+
eq(notificationEvents.dedupeKey, normalizedDedupeKey),
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
.get();
|
|
75
|
+
if (existing) return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const row = {
|
|
79
|
+
id: params.id,
|
|
80
|
+
assistantId: params.assistantId,
|
|
81
|
+
sourceEventName: params.sourceEventName,
|
|
82
|
+
sourceChannel: params.sourceChannel,
|
|
83
|
+
sourceSessionId: params.sourceSessionId,
|
|
84
|
+
attentionHintsJson: JSON.stringify(params.attentionHints),
|
|
85
|
+
payloadJson: JSON.stringify(params.payload),
|
|
86
|
+
dedupeKey: normalizedDedupeKey,
|
|
87
|
+
createdAt: now,
|
|
88
|
+
updatedAt: now,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
db.insert(notificationEvents).values(row).run();
|
|
92
|
+
|
|
93
|
+
return row;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Update the dedupeKey on an existing event (e.g. when the decision engine generates one). */
|
|
97
|
+
export function updateEventDedupeKey(eventId: string, dedupeKey: string): void {
|
|
98
|
+
const db = getDb();
|
|
99
|
+
db.update(notificationEvents)
|
|
100
|
+
.set({ dedupeKey, updatedAt: Date.now() })
|
|
101
|
+
.where(eq(notificationEvents.id, eventId))
|
|
102
|
+
.run();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Get a single notification event by ID. */
|
|
106
|
+
export function getEventById(id: string): NotificationEventRow | null {
|
|
107
|
+
const db = getDb();
|
|
108
|
+
const row = db
|
|
109
|
+
.select()
|
|
110
|
+
.from(notificationEvents)
|
|
111
|
+
.where(eq(notificationEvents.id, id))
|
|
112
|
+
.get();
|
|
113
|
+
if (!row) return null;
|
|
114
|
+
return rowToEvent(row);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ListEventsFilters {
|
|
118
|
+
sourceEventName?: string;
|
|
119
|
+
limit?: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** List notification events for an assistant with optional filters. */
|
|
123
|
+
export function listEvents(
|
|
124
|
+
assistantId: string,
|
|
125
|
+
filters?: ListEventsFilters,
|
|
126
|
+
): NotificationEventRow[] {
|
|
127
|
+
const db = getDb();
|
|
128
|
+
const conditions = [eq(notificationEvents.assistantId, assistantId)];
|
|
129
|
+
|
|
130
|
+
if (filters?.sourceEventName) {
|
|
131
|
+
conditions.push(eq(notificationEvents.sourceEventName, filters.sourceEventName));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const limit = filters?.limit ?? 50;
|
|
135
|
+
|
|
136
|
+
const rows = db
|
|
137
|
+
.select()
|
|
138
|
+
.from(notificationEvents)
|
|
139
|
+
.where(and(...conditions))
|
|
140
|
+
.orderBy(desc(notificationEvents.createdAt))
|
|
141
|
+
.limit(limit)
|
|
142
|
+
.all();
|
|
143
|
+
|
|
144
|
+
return rows.map(rowToEvent);
|
|
145
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preference extraction pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Detects notification-related user statements in conversation messages
|
|
5
|
+
* and extracts structured preference data. Uses a small/fast model
|
|
6
|
+
* (haiku) with a focused prompt for lightweight detection + extraction.
|
|
7
|
+
*
|
|
8
|
+
* Examples of statements it should detect:
|
|
9
|
+
* - "Use Telegram for urgent alerts"
|
|
10
|
+
* - "Don't notify me on desktop during work calls"
|
|
11
|
+
* - "Weeknights after 10pm: only critical notifications"
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getConfig } from '../config/loader.js';
|
|
15
|
+
import { getLogger } from '../util/logger.js';
|
|
16
|
+
import { getConfiguredProvider, createTimeout, extractToolUse, userMessage } from '../providers/provider-send-message.js';
|
|
17
|
+
import type { AppliesWhenConditions } from './preferences-store.js';
|
|
18
|
+
|
|
19
|
+
const log = getLogger('notification-preference-extractor');
|
|
20
|
+
|
|
21
|
+
const EXTRACTION_TIMEOUT_MS = 10_000;
|
|
22
|
+
|
|
23
|
+
// ── Extraction result ──────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface ExtractedPreference {
|
|
26
|
+
preferenceText: string;
|
|
27
|
+
appliesWhen: AppliesWhenConditions;
|
|
28
|
+
priority: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ExtractionResult {
|
|
32
|
+
detected: boolean;
|
|
33
|
+
preferences: ExtractedPreference[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── System prompt ──────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const SYSTEM_PROMPT = `You are a notification preference detector. Given a user message from a conversation, determine if it contains any notification preferences or routing instructions.
|
|
39
|
+
|
|
40
|
+
Notification preferences are statements about HOW, WHEN, or WHERE the user wants to receive notifications. Examples:
|
|
41
|
+
- "Use Telegram for urgent alerts"
|
|
42
|
+
- "Don't notify me on desktop during work calls"
|
|
43
|
+
- "Weeknights after 10pm: only critical notifications"
|
|
44
|
+
- "Send me everything on macOS"
|
|
45
|
+
- "Only bug me for high priority stuff"
|
|
46
|
+
- "Mute notifications between 11pm and 7am"
|
|
47
|
+
- "I prefer Telegram over desktop notifications"
|
|
48
|
+
|
|
49
|
+
If the message does NOT contain any notification preferences, respond with the tool setting detected=false and an empty preferences array.
|
|
50
|
+
|
|
51
|
+
If it DOES contain preferences, extract each one as a separate entry with:
|
|
52
|
+
- preferenceText: the natural language preference as stated
|
|
53
|
+
- appliesWhen: structured conditions (timeRange, channels, urgencyLevels, contexts)
|
|
54
|
+
- priority: 0 for general defaults, 1 for specific overrides, 2 for critical/urgent overrides
|
|
55
|
+
|
|
56
|
+
You MUST respond using the \`extract_notification_preferences\` tool.`;
|
|
57
|
+
|
|
58
|
+
// ── Tool definition ────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
const EXTRACTION_TOOL = {
|
|
61
|
+
name: 'extract_notification_preferences',
|
|
62
|
+
description: 'Extract notification preferences from a user message',
|
|
63
|
+
input_schema: {
|
|
64
|
+
type: 'object' as const,
|
|
65
|
+
properties: {
|
|
66
|
+
detected: {
|
|
67
|
+
type: 'boolean',
|
|
68
|
+
description: 'Whether the message contains notification preferences',
|
|
69
|
+
},
|
|
70
|
+
preferences: {
|
|
71
|
+
type: 'array',
|
|
72
|
+
description: 'Array of extracted preferences (empty if detected=false)',
|
|
73
|
+
items: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
properties: {
|
|
76
|
+
preferenceText: {
|
|
77
|
+
type: 'string',
|
|
78
|
+
description: 'The natural language preference as stated by the user',
|
|
79
|
+
},
|
|
80
|
+
appliesWhen: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
description: 'Structured conditions for when this preference applies',
|
|
83
|
+
properties: {
|
|
84
|
+
timeRange: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {
|
|
87
|
+
after: { type: 'string', description: 'Start time in HH:MM format' },
|
|
88
|
+
before: { type: 'string', description: 'End time in HH:MM format' },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
channels: {
|
|
92
|
+
type: 'array',
|
|
93
|
+
items: { type: 'string' },
|
|
94
|
+
description: 'Channels this preference applies to (e.g. telegram, macos)',
|
|
95
|
+
},
|
|
96
|
+
urgencyLevels: {
|
|
97
|
+
type: 'array',
|
|
98
|
+
items: { type: 'string' },
|
|
99
|
+
description: 'Urgency levels this preference applies to (e.g. low, medium, high, critical)',
|
|
100
|
+
},
|
|
101
|
+
contexts: {
|
|
102
|
+
type: 'array',
|
|
103
|
+
items: { type: 'string' },
|
|
104
|
+
description: 'Situational contexts (e.g. work_calls, meetings, sleeping)',
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
priority: {
|
|
109
|
+
type: 'number',
|
|
110
|
+
description: 'Priority for conflict resolution: 0=general default, 1=specific override, 2=critical override',
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
required: ['preferenceText', 'appliesWhen', 'priority'],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
required: ['detected', 'preferences'],
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// ── Core extraction function ───────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
export async function extractPreferences(message: string): Promise<ExtractionResult> {
|
|
124
|
+
const provider = getConfiguredProvider();
|
|
125
|
+
if (!provider) {
|
|
126
|
+
log.debug('No provider available for preference extraction');
|
|
127
|
+
return { detected: false, preferences: [] };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const config = getConfig();
|
|
131
|
+
const model = config.notifications.decisionModel;
|
|
132
|
+
|
|
133
|
+
const { signal, cleanup } = createTimeout(EXTRACTION_TIMEOUT_MS);
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const response = await provider.sendMessage(
|
|
137
|
+
[userMessage(message)],
|
|
138
|
+
[EXTRACTION_TOOL],
|
|
139
|
+
SYSTEM_PROMPT,
|
|
140
|
+
{
|
|
141
|
+
config: {
|
|
142
|
+
model,
|
|
143
|
+
max_tokens: 1024,
|
|
144
|
+
tool_choice: { type: 'tool' as const, name: 'extract_notification_preferences' },
|
|
145
|
+
},
|
|
146
|
+
signal,
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
cleanup();
|
|
150
|
+
|
|
151
|
+
const toolBlock = extractToolUse(response);
|
|
152
|
+
if (!toolBlock) {
|
|
153
|
+
log.debug('No tool_use block in preference extraction response');
|
|
154
|
+
return { detected: false, preferences: [] };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const input = toolBlock.input as Record<string, unknown>;
|
|
158
|
+
return validateExtractionOutput(input);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
161
|
+
log.debug({ err: errMsg }, 'Preference extraction failed');
|
|
162
|
+
return { detected: false, preferences: [] };
|
|
163
|
+
} finally {
|
|
164
|
+
cleanup();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Validation ─────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
function validateExtractionOutput(input: Record<string, unknown>): ExtractionResult {
|
|
171
|
+
if (typeof input.detected !== 'boolean') {
|
|
172
|
+
return { detected: false, preferences: [] };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!input.detected || !Array.isArray(input.preferences)) {
|
|
176
|
+
return { detected: false, preferences: [] };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const preferences: ExtractedPreference[] = [];
|
|
180
|
+
|
|
181
|
+
for (const raw of input.preferences) {
|
|
182
|
+
if (typeof raw !== 'object' || raw === null) continue;
|
|
183
|
+
const p = raw as Record<string, unknown>;
|
|
184
|
+
|
|
185
|
+
if (typeof p.preferenceText !== 'string' || !p.preferenceText.trim()) continue;
|
|
186
|
+
|
|
187
|
+
const appliesWhen: AppliesWhenConditions = {};
|
|
188
|
+
if (p.appliesWhen && typeof p.appliesWhen === 'object') {
|
|
189
|
+
const aw = p.appliesWhen as Record<string, unknown>;
|
|
190
|
+
if (aw.timeRange && typeof aw.timeRange === 'object') {
|
|
191
|
+
const tr = aw.timeRange as Record<string, unknown>;
|
|
192
|
+
appliesWhen.timeRange = {
|
|
193
|
+
after: typeof tr.after === 'string' ? tr.after : undefined,
|
|
194
|
+
before: typeof tr.before === 'string' ? tr.before : undefined,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (Array.isArray(aw.channels)) {
|
|
198
|
+
appliesWhen.channels = aw.channels.filter((c): c is string => typeof c === 'string');
|
|
199
|
+
}
|
|
200
|
+
if (Array.isArray(aw.urgencyLevels)) {
|
|
201
|
+
appliesWhen.urgencyLevels = aw.urgencyLevels.filter((u): u is string => typeof u === 'string');
|
|
202
|
+
}
|
|
203
|
+
if (Array.isArray(aw.contexts)) {
|
|
204
|
+
appliesWhen.contexts = aw.contexts.filter((c): c is string => typeof c === 'string');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const priority = typeof p.priority === 'number'
|
|
209
|
+
? Math.max(0, Math.min(2, Math.round(p.priority)))
|
|
210
|
+
: 0;
|
|
211
|
+
|
|
212
|
+
preferences.push({
|
|
213
|
+
preferenceText: p.preferenceText.trim(),
|
|
214
|
+
appliesWhen,
|
|
215
|
+
priority,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
detected: preferences.length > 0,
|
|
221
|
+
preferences,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preference summary retriever.
|
|
3
|
+
*
|
|
4
|
+
* Builds a compact "notification preference summary" string for inclusion
|
|
5
|
+
* in the decision engine's system prompt. Fetches all stored preferences
|
|
6
|
+
* for an assistant and merges them into a coherent block that the LLM
|
|
7
|
+
* can interpret when making routing decisions.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getLogger } from '../util/logger.js';
|
|
11
|
+
import { listPreferences } from './preferences-store.js';
|
|
12
|
+
import type { AppliesWhenConditions } from './preferences-store.js';
|
|
13
|
+
|
|
14
|
+
const log = getLogger('notification-preference-summary');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build a compact preference summary for inclusion in the decision engine
|
|
18
|
+
* system prompt. Returns null if no preferences are stored.
|
|
19
|
+
*/
|
|
20
|
+
export function getPreferenceSummary(assistantId: string): string | null {
|
|
21
|
+
const preferences = listPreferences(assistantId);
|
|
22
|
+
|
|
23
|
+
if (preferences.length === 0) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const lines: string[] = [
|
|
28
|
+
'The user has set the following notification preferences (ordered by priority, highest first):',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
for (const pref of preferences) {
|
|
32
|
+
const safeText = sanitizePreferenceText(pref.preferenceText);
|
|
33
|
+
const conditionStr = formatConditions(pref.appliesWhenJson);
|
|
34
|
+
const priorityLabel = pref.priority >= 2 ? 'CRITICAL' : pref.priority === 1 ? 'override' : 'default';
|
|
35
|
+
const prefix = `[${priorityLabel}]`;
|
|
36
|
+
|
|
37
|
+
if (conditionStr) {
|
|
38
|
+
lines.push(`${prefix} "${safeText}" (when: ${conditionStr})`);
|
|
39
|
+
} else {
|
|
40
|
+
lines.push(`${prefix} "${safeText}"`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
log.debug({ count: preferences.length }, 'Built preference summary');
|
|
45
|
+
|
|
46
|
+
return lines.join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Text sanitization ───────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Strip XML/HTML-like tags from preference text to prevent prompt injection.
|
|
53
|
+
* Replaces angle brackets with harmless unicode equivalents so user-authored
|
|
54
|
+
* text cannot break the `<user-preferences>` framing in the system prompt.
|
|
55
|
+
*/
|
|
56
|
+
function sanitizePreferenceText(text: string): string {
|
|
57
|
+
return text.replace(/</g, '\uFF1C').replace(/>/g, '\uFF1E');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Safely convert and sanitize a value to a string.
|
|
62
|
+
* Coerces to string first (if needed) then sanitizes with sanitizePreferenceText.
|
|
63
|
+
* This ensures all values are sanitized regardless of their original type,
|
|
64
|
+
* preventing prompt injection via coerced types.
|
|
65
|
+
*/
|
|
66
|
+
function safeString(value: unknown): string {
|
|
67
|
+
return sanitizePreferenceText(typeof value === 'string' ? value : String(value ?? ''));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Condition formatting ────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
function formatConditions(appliesWhenJson: string): string {
|
|
73
|
+
let conditions: AppliesWhenConditions;
|
|
74
|
+
try {
|
|
75
|
+
conditions = JSON.parse(appliesWhenJson);
|
|
76
|
+
} catch {
|
|
77
|
+
return '';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Skip empty condition objects
|
|
81
|
+
if (!conditions || typeof conditions !== 'object') return '';
|
|
82
|
+
|
|
83
|
+
const parts: string[] = [];
|
|
84
|
+
|
|
85
|
+
if (conditions.timeRange) {
|
|
86
|
+
const after = conditions.timeRange.after ? safeString(conditions.timeRange.after) : '';
|
|
87
|
+
const before = conditions.timeRange.before ? safeString(conditions.timeRange.before) : '';
|
|
88
|
+
if (after && before) {
|
|
89
|
+
parts.push(`${after}-${before}`);
|
|
90
|
+
} else if (after) {
|
|
91
|
+
parts.push(`after ${after}`);
|
|
92
|
+
} else if (before) {
|
|
93
|
+
parts.push(`before ${before}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (conditions.channels && conditions.channels.length > 0) {
|
|
98
|
+
parts.push(`channels: ${conditions.channels.map(safeString).join(', ')}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (conditions.urgencyLevels && conditions.urgencyLevels.length > 0) {
|
|
102
|
+
parts.push(`urgency: ${conditions.urgencyLevels.map(safeString).join(', ')}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (conditions.contexts && conditions.contexts.length > 0) {
|
|
106
|
+
parts.push(`context: ${conditions.contexts.map(safeString).join(', ')}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return parts.join('; ');
|
|
110
|
+
}
|