@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
|
@@ -261,6 +261,26 @@ export function injectActiveSurfaceContext(message: Message, ctx: ActiveSurfaceC
|
|
|
261
261
|
};
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Append voice call-control protocol instructions to the last user
|
|
266
|
+
* message so the model knows how to emit control markers during voice
|
|
267
|
+
* turns routed through the session pipeline.
|
|
268
|
+
*/
|
|
269
|
+
export function injectVoiceCallControlContext(message: Message, prompt: string): Message {
|
|
270
|
+
return {
|
|
271
|
+
...message,
|
|
272
|
+
content: [
|
|
273
|
+
...message.content,
|
|
274
|
+
{ type: 'text', text: prompt },
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Strip `<voice_call_control>` blocks injected by `injectVoiceCallControlContext`. */
|
|
280
|
+
export function stripVoiceCallControlContext(messages: Message[]): Message[] {
|
|
281
|
+
return stripUserTextBlocksByPrefix(messages, ['<voice_call_control>']);
|
|
282
|
+
}
|
|
283
|
+
|
|
264
284
|
/**
|
|
265
285
|
* Prepend channel capability context to the last user message so the
|
|
266
286
|
* model knows what the current channel can and cannot do.
|
|
@@ -514,6 +534,7 @@ const RUNTIME_INJECTION_PREFIXES = [
|
|
|
514
534
|
'<channel_command_context>',
|
|
515
535
|
'<channel_turn_context>',
|
|
516
536
|
'<guardian_context>',
|
|
537
|
+
'<voice_call_control>',
|
|
517
538
|
'<workspace_top_level>',
|
|
518
539
|
TEMPORAL_INJECTED_PREFIX,
|
|
519
540
|
'<active_workspace>',
|
|
@@ -558,10 +579,21 @@ export function applyRuntimeInjections(
|
|
|
558
579
|
channelTurnContext?: ChannelTurnContextParams | null;
|
|
559
580
|
guardianContext?: GuardianRuntimeContext | null;
|
|
560
581
|
temporalContext?: string | null;
|
|
582
|
+
voiceCallControlPrompt?: string | null;
|
|
561
583
|
},
|
|
562
584
|
): Message[] {
|
|
563
585
|
let result = runMessages;
|
|
564
586
|
|
|
587
|
+
if (options.voiceCallControlPrompt) {
|
|
588
|
+
const userTail = result[result.length - 1];
|
|
589
|
+
if (userTail && userTail.role === 'user') {
|
|
590
|
+
result = [
|
|
591
|
+
...result.slice(0, -1),
|
|
592
|
+
injectVoiceCallControlContext(userTail, options.voiceCallControlPrompt),
|
|
593
|
+
];
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
565
597
|
if (options.softConflictInstruction) {
|
|
566
598
|
const userTail = result[result.length - 1];
|
|
567
599
|
if (userTail && userTail.role === 'user') {
|
package/src/daemon/session.ts
CHANGED
|
@@ -130,6 +130,7 @@ export class Session {
|
|
|
130
130
|
/** @internal */ currentPage?: string;
|
|
131
131
|
/** @internal */ channelCapabilities?: ChannelCapabilities;
|
|
132
132
|
/** @internal */ guardianContext?: GuardianRuntimeContext;
|
|
133
|
+
/** @internal */ voiceCallControlPrompt?: string;
|
|
133
134
|
/** @internal */ assistantId?: string;
|
|
134
135
|
/** @internal */ commandIntent?: { type: string; payload?: string; languageCode?: string };
|
|
135
136
|
/** @internal */ pendingSurfaceActions = new Map<string, { surfaceType: SurfaceType }>();
|
|
@@ -344,6 +345,10 @@ export class Session {
|
|
|
344
345
|
this.guardianContext = ctx ?? undefined;
|
|
345
346
|
}
|
|
346
347
|
|
|
348
|
+
setVoiceCallControlPrompt(prompt: string | null): void {
|
|
349
|
+
this.voiceCallControlPrompt = prompt ?? undefined;
|
|
350
|
+
}
|
|
351
|
+
|
|
347
352
|
setAssistantId(assistantId: string | null): void {
|
|
348
353
|
this.assistantId = assistantId ?? undefined;
|
|
349
354
|
}
|
package/src/memory/db-init.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
migrateGuardianActionTables,
|
|
17
17
|
migrateBackfillInboxThreadStateFromBindings,
|
|
18
18
|
migrateDropActiveSearchIndex,
|
|
19
|
+
migrateNotificationTablesSchema,
|
|
19
20
|
migrateMemorySegmentsIndexes,
|
|
20
21
|
migrateMemoryItemsIndexes,
|
|
21
22
|
migrateRemainingTableIndexes,
|
|
@@ -1276,5 +1277,84 @@ export function initializeDb(): void {
|
|
|
1276
1277
|
|
|
1277
1278
|
migrateRemainingTableIndexes(database);
|
|
1278
1279
|
|
|
1280
|
+
// ── Notification System ──────────────────────────────────────────────
|
|
1281
|
+
|
|
1282
|
+
// Migration: drop legacy enum-based notification tables if old schema detected.
|
|
1283
|
+
// Guarded behind a one-time check for the old `notification_type` column.
|
|
1284
|
+
migrateNotificationTablesSchema(database);
|
|
1285
|
+
|
|
1286
|
+
database.run(/*sql*/ `
|
|
1287
|
+
CREATE TABLE IF NOT EXISTS notification_preferences (
|
|
1288
|
+
id TEXT PRIMARY KEY,
|
|
1289
|
+
assistant_id TEXT NOT NULL,
|
|
1290
|
+
preference_text TEXT NOT NULL,
|
|
1291
|
+
applies_when_json TEXT NOT NULL DEFAULT '{}',
|
|
1292
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
1293
|
+
created_at INTEGER NOT NULL,
|
|
1294
|
+
updated_at INTEGER NOT NULL
|
|
1295
|
+
)
|
|
1296
|
+
`);
|
|
1297
|
+
|
|
1298
|
+
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_preferences_assistant_id ON notification_preferences(assistant_id)`);
|
|
1299
|
+
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_preferences_assistant_priority ON notification_preferences(assistant_id, priority DESC)`);
|
|
1300
|
+
|
|
1301
|
+
database.run(/*sql*/ `
|
|
1302
|
+
CREATE TABLE IF NOT EXISTS notification_events (
|
|
1303
|
+
id TEXT PRIMARY KEY,
|
|
1304
|
+
assistant_id TEXT NOT NULL,
|
|
1305
|
+
source_event_name TEXT NOT NULL,
|
|
1306
|
+
source_channel TEXT NOT NULL,
|
|
1307
|
+
source_session_id TEXT NOT NULL,
|
|
1308
|
+
attention_hints_json TEXT NOT NULL DEFAULT '{}',
|
|
1309
|
+
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
1310
|
+
dedupe_key TEXT,
|
|
1311
|
+
created_at INTEGER NOT NULL,
|
|
1312
|
+
updated_at INTEGER NOT NULL
|
|
1313
|
+
)
|
|
1314
|
+
`);
|
|
1315
|
+
|
|
1316
|
+
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_events_assistant_event_created ON notification_events(assistant_id, source_event_name, created_at)`);
|
|
1317
|
+
database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_events_dedupe ON notification_events(assistant_id, dedupe_key) WHERE dedupe_key IS NOT NULL`);
|
|
1318
|
+
|
|
1319
|
+
database.run(/*sql*/ `
|
|
1320
|
+
CREATE TABLE IF NOT EXISTS notification_decisions (
|
|
1321
|
+
id TEXT PRIMARY KEY,
|
|
1322
|
+
notification_event_id TEXT NOT NULL REFERENCES notification_events(id) ON DELETE CASCADE,
|
|
1323
|
+
should_notify INTEGER NOT NULL,
|
|
1324
|
+
selected_channels TEXT NOT NULL DEFAULT '[]',
|
|
1325
|
+
reasoning_summary TEXT NOT NULL,
|
|
1326
|
+
confidence REAL NOT NULL,
|
|
1327
|
+
fallback_used INTEGER NOT NULL DEFAULT 0,
|
|
1328
|
+
prompt_version TEXT,
|
|
1329
|
+
validation_results TEXT,
|
|
1330
|
+
created_at INTEGER NOT NULL
|
|
1331
|
+
)
|
|
1332
|
+
`);
|
|
1333
|
+
|
|
1334
|
+
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_decisions_event_id ON notification_decisions(notification_event_id)`);
|
|
1335
|
+
|
|
1336
|
+
database.run(/*sql*/ `
|
|
1337
|
+
CREATE TABLE IF NOT EXISTS notification_deliveries (
|
|
1338
|
+
id TEXT PRIMARY KEY,
|
|
1339
|
+
notification_decision_id TEXT NOT NULL REFERENCES notification_decisions(id) ON DELETE CASCADE,
|
|
1340
|
+
assistant_id TEXT NOT NULL,
|
|
1341
|
+
channel TEXT NOT NULL,
|
|
1342
|
+
destination TEXT NOT NULL,
|
|
1343
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
1344
|
+
attempt INTEGER NOT NULL DEFAULT 1,
|
|
1345
|
+
rendered_title TEXT,
|
|
1346
|
+
rendered_body TEXT,
|
|
1347
|
+
error_code TEXT,
|
|
1348
|
+
error_message TEXT,
|
|
1349
|
+
sent_at INTEGER,
|
|
1350
|
+
created_at INTEGER NOT NULL,
|
|
1351
|
+
updated_at INTEGER NOT NULL
|
|
1352
|
+
)
|
|
1353
|
+
`);
|
|
1354
|
+
|
|
1355
|
+
database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_deliveries_unique ON notification_deliveries(notification_decision_id, channel, destination, attempt)`);
|
|
1356
|
+
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_decision_id ON notification_deliveries(notification_decision_id)`);
|
|
1357
|
+
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_assistant_status ON notification_deliveries(assistant_id, status)`)
|
|
1358
|
+
|
|
1279
1359
|
validateMigrationState(database);
|
|
1280
1360
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* answer resolves the request and all other deliveries are marked answered.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { and, eq,
|
|
10
|
+
import { and, eq, inArray, lt } from 'drizzle-orm';
|
|
11
11
|
import { v4 as uuid } from 'uuid';
|
|
12
12
|
import { getDb, rawChanges } from './db.js';
|
|
13
13
|
import {
|
|
@@ -337,7 +337,7 @@ export function createGuardianActionDelivery(params: {
|
|
|
337
337
|
}
|
|
338
338
|
|
|
339
339
|
/**
|
|
340
|
-
* Look up
|
|
340
|
+
* Look up sent deliveries for a specific destination.
|
|
341
341
|
* Used by inbound message routing to match incoming answers to deliveries.
|
|
342
342
|
*/
|
|
343
343
|
export function getPendingDeliveriesByDestination(
|
|
@@ -8,4 +8,5 @@ import type { DrizzleDb } from '../db-connection.js';
|
|
|
8
8
|
*/
|
|
9
9
|
export function migrateMemorySegmentsIndexes(database: DrizzleDb): void {
|
|
10
10
|
database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_memory_segments_scope_id ON memory_segments(scope_id)`);
|
|
11
|
+
database.run(/*sql*/ `DROP INDEX IF EXISTS idx_memory_segments_conversation_id`);
|
|
11
12
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { getSqliteFrom, type DrizzleDb } from '../db-connection.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One-time migration: drop legacy enum-based notification tables so they can
|
|
5
|
+
* be recreated with the new signal-contract schema.
|
|
6
|
+
*
|
|
7
|
+
* Guard: only runs when the old `notification_type` column exists on the
|
|
8
|
+
* `notification_events` table (the old enum-based schema). On fresh installs
|
|
9
|
+
* the table either doesn't exist yet or was created with the new schema, so
|
|
10
|
+
* CREATE TABLE IF NOT EXISTS in db-init handles idempotent creation.
|
|
11
|
+
*
|
|
12
|
+
* Drop order matters because of FK references:
|
|
13
|
+
* notification_deliveries -> notification_events (old schema FK)
|
|
14
|
+
* notification_decisions -> notification_events (new schema FK, may exist from partial upgrade)
|
|
15
|
+
* notification_events (the table being rebuilt)
|
|
16
|
+
* notification_preferences (fully removed, replaced by decision engine)
|
|
17
|
+
*/
|
|
18
|
+
export function migrateNotificationTablesSchema(database: DrizzleDb): void {
|
|
19
|
+
const raw = getSqliteFrom(database);
|
|
20
|
+
const checkpointKey = 'migration_notification_tables_schema_v1';
|
|
21
|
+
const checkpoint = raw.query(
|
|
22
|
+
`SELECT 1 FROM memory_checkpoints WHERE key = ?`,
|
|
23
|
+
).get(checkpointKey);
|
|
24
|
+
if (checkpoint) return;
|
|
25
|
+
|
|
26
|
+
// Check if the old schema is present: the legacy notification_events table
|
|
27
|
+
// had a `notification_type` column that the new schema does not.
|
|
28
|
+
const hasOldSchema = raw.query(
|
|
29
|
+
`SELECT COUNT(*) as cnt FROM pragma_table_info('notification_events') WHERE name = 'notification_type'`,
|
|
30
|
+
).get() as { cnt: number } | undefined;
|
|
31
|
+
|
|
32
|
+
if (hasOldSchema && hasOldSchema.cnt > 0) {
|
|
33
|
+
try {
|
|
34
|
+
raw.exec('BEGIN');
|
|
35
|
+
|
|
36
|
+
// Drop in FK-safe order: children before parents
|
|
37
|
+
raw.exec(/*sql*/ `DROP TABLE IF EXISTS notification_deliveries`);
|
|
38
|
+
raw.exec(/*sql*/ `DROP TABLE IF EXISTS notification_decisions`);
|
|
39
|
+
raw.exec(/*sql*/ `DROP TABLE IF EXISTS notification_events`);
|
|
40
|
+
raw.exec(/*sql*/ `DROP TABLE IF EXISTS notification_preferences`);
|
|
41
|
+
|
|
42
|
+
raw.query(
|
|
43
|
+
`INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
|
|
44
|
+
).run(checkpointKey, Date.now());
|
|
45
|
+
|
|
46
|
+
raw.exec('COMMIT');
|
|
47
|
+
} catch (e) {
|
|
48
|
+
try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
|
|
49
|
+
throw e;
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
// No old schema detected (fresh install or already migrated).
|
|
53
|
+
// Still clean up notification_preferences if it exists, since we want
|
|
54
|
+
// to ensure it's removed regardless.
|
|
55
|
+
try {
|
|
56
|
+
raw.exec('BEGIN');
|
|
57
|
+
|
|
58
|
+
raw.exec(/*sql*/ `DROP TABLE IF EXISTS notification_preferences`);
|
|
59
|
+
|
|
60
|
+
raw.query(
|
|
61
|
+
`INSERT OR IGNORE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
|
|
62
|
+
).run(checkpointKey, Date.now());
|
|
63
|
+
|
|
64
|
+
raw.exec('COMMIT');
|
|
65
|
+
} catch (e) {
|
|
66
|
+
try { raw.exec('ROLLBACK'); } catch { /* no active transaction */ }
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -22,3 +22,4 @@ export { migrateDropActiveSearchIndex } from './015-drop-active-search-index.js'
|
|
|
22
22
|
export { migrateMemorySegmentsIndexes } from './016-memory-segments-indexes.js';
|
|
23
23
|
export { migrateMemoryItemsIndexes } from './017-memory-items-indexes.js';
|
|
24
24
|
export { migrateRemainingTableIndexes } from './018-remaining-table-indexes.js';
|
|
25
|
+
export { migrateNotificationTablesSchema } from './019-notification-tables-schema-migration.js';
|
|
@@ -69,6 +69,11 @@ export const MIGRATION_REGISTRY: MigrationRegistryEntry[] = [
|
|
|
69
69
|
version: 9,
|
|
70
70
|
description: 'Drop old idx_memory_items_active_search so it can be recreated with updated covering columns',
|
|
71
71
|
},
|
|
72
|
+
{
|
|
73
|
+
key: 'migration_notification_tables_schema_v1',
|
|
74
|
+
version: 10,
|
|
75
|
+
description: 'Drop legacy enum-based notification tables so they can be recreated with the new signal-contract schema',
|
|
76
|
+
},
|
|
72
77
|
];
|
|
73
78
|
|
|
74
79
|
export interface MigrationValidationResult {
|
package/src/memory/schema.ts
CHANGED
|
@@ -64,7 +64,6 @@ export const memorySegments = sqliteTable('memory_segments', {
|
|
|
64
64
|
updatedAt: integer('updated_at').notNull(),
|
|
65
65
|
}, (table) => [
|
|
66
66
|
index('idx_memory_segments_scope_id').on(table.scopeId),
|
|
67
|
-
index('idx_memory_segments_conversation_id').on(table.conversationId),
|
|
68
67
|
]);
|
|
69
68
|
|
|
70
69
|
export const memoryItems = sqliteTable('memory_items', {
|
|
@@ -901,3 +900,62 @@ export const assistantInboxThreadState = sqliteTable('assistant_inbox_thread_sta
|
|
|
901
900
|
createdAt: integer('created_at').notNull(),
|
|
902
901
|
updatedAt: integer('updated_at').notNull(),
|
|
903
902
|
});
|
|
903
|
+
|
|
904
|
+
// ── Notification System ──────────────────────────────────────────────
|
|
905
|
+
|
|
906
|
+
export const notificationEvents = sqliteTable('notification_events', {
|
|
907
|
+
id: text('id').primaryKey(),
|
|
908
|
+
assistantId: text('assistant_id').notNull(),
|
|
909
|
+
sourceEventName: text('source_event_name').notNull(),
|
|
910
|
+
sourceChannel: text('source_channel').notNull(),
|
|
911
|
+
sourceSessionId: text('source_session_id').notNull(),
|
|
912
|
+
attentionHintsJson: text('attention_hints_json').notNull().default('{}'),
|
|
913
|
+
payloadJson: text('payload_json').notNull().default('{}'),
|
|
914
|
+
dedupeKey: text('dedupe_key'),
|
|
915
|
+
createdAt: integer('created_at').notNull(),
|
|
916
|
+
updatedAt: integer('updated_at').notNull(),
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
export const notificationDecisions = sqliteTable('notification_decisions', {
|
|
920
|
+
id: text('id').primaryKey(),
|
|
921
|
+
notificationEventId: text('notification_event_id')
|
|
922
|
+
.notNull()
|
|
923
|
+
.references(() => notificationEvents.id, { onDelete: 'cascade' }),
|
|
924
|
+
shouldNotify: integer('should_notify').notNull(),
|
|
925
|
+
selectedChannels: text('selected_channels').notNull().default('[]'),
|
|
926
|
+
reasoningSummary: text('reasoning_summary').notNull(),
|
|
927
|
+
confidence: real('confidence').notNull(),
|
|
928
|
+
fallbackUsed: integer('fallback_used').notNull().default(0),
|
|
929
|
+
promptVersion: text('prompt_version'),
|
|
930
|
+
validationResults: text('validation_results'),
|
|
931
|
+
createdAt: integer('created_at').notNull(),
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
export const notificationPreferences = sqliteTable('notification_preferences', {
|
|
935
|
+
id: text('id').primaryKey(),
|
|
936
|
+
assistantId: text('assistant_id').notNull(),
|
|
937
|
+
preferenceText: text('preference_text').notNull(),
|
|
938
|
+
appliesWhenJson: text('applies_when_json').notNull().default('{}'),
|
|
939
|
+
priority: integer('priority').notNull().default(0),
|
|
940
|
+
createdAt: integer('created_at').notNull(),
|
|
941
|
+
updatedAt: integer('updated_at').notNull(),
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
export const notificationDeliveries = sqliteTable('notification_deliveries', {
|
|
945
|
+
id: text('id').primaryKey(),
|
|
946
|
+
notificationDecisionId: text('notification_decision_id')
|
|
947
|
+
.notNull()
|
|
948
|
+
.references(() => notificationDecisions.id, { onDelete: 'cascade' }),
|
|
949
|
+
assistantId: text('assistant_id').notNull(),
|
|
950
|
+
channel: text('channel').notNull(),
|
|
951
|
+
destination: text('destination').notNull(),
|
|
952
|
+
status: text('status').notNull().default('pending'),
|
|
953
|
+
attempt: integer('attempt').notNull().default(1),
|
|
954
|
+
renderedTitle: text('rendered_title'),
|
|
955
|
+
renderedBody: text('rendered_body'),
|
|
956
|
+
errorCode: text('error_code'),
|
|
957
|
+
errorMessage: text('error_message'),
|
|
958
|
+
sentAt: integer('sent_at'),
|
|
959
|
+
createdAt: integer('created_at').notNull(),
|
|
960
|
+
updatedAt: integer('updated_at').notNull(),
|
|
961
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Notification System
|
|
2
|
+
|
|
3
|
+
Signal-driven notification architecture where producers emit free-form events and an LLM-backed decision engine determines whether, where, and how to notify the user.
|
|
4
|
+
|
|
5
|
+
## Lifecycle
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Producer → NotificationSignal → Decision Engine (LLM) → Deterministic Checks → Broadcaster → Adapters → Delivery
|
|
9
|
+
↑
|
|
10
|
+
Preference Summary
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### 1. Signal
|
|
14
|
+
|
|
15
|
+
A producer calls `emitNotificationSignal()` with a free-form event name, attention hints (urgency, requiresAction, deadlineAt), and a context payload. The signal is persisted as a `notification_events` row.
|
|
16
|
+
|
|
17
|
+
### 2. Decision
|
|
18
|
+
|
|
19
|
+
The decision engine (`decision-engine.ts`) sends the signal to an LLM (configured via `notifications.decisionModel`) along with available channels and the user's preference summary. The LLM responds with a structured decision: whether to notify, which channels, rendered copy per channel, and a deduplication key.
|
|
20
|
+
|
|
21
|
+
When the LLM is unavailable or returns invalid output, a deterministic fallback fires: high-urgency + requires-action signals notify on all channels; everything else is suppressed.
|
|
22
|
+
|
|
23
|
+
### 3. Deterministic Checks
|
|
24
|
+
|
|
25
|
+
Hard invariants that the LLM cannot override (`deterministic-checks.ts`):
|
|
26
|
+
|
|
27
|
+
- **Schema validity** -- fail-closed if the decision is malformed
|
|
28
|
+
- **Source-active suppression** -- if the user is already viewing the source context, suppress
|
|
29
|
+
- **Channel availability** -- at least one selected channel must be connected
|
|
30
|
+
- **Deduplication** -- same `dedupeKey` within the dedupe window (1 hour default) is suppressed
|
|
31
|
+
|
|
32
|
+
### 4. Dispatch
|
|
33
|
+
|
|
34
|
+
`runtime-dispatch.ts` handles three early-exit cases (shouldNotify=false, shadow mode, no channels), then delegates to the broadcaster.
|
|
35
|
+
|
|
36
|
+
### 5. Broadcast and Delivery
|
|
37
|
+
|
|
38
|
+
The broadcaster (`broadcaster.ts`) iterates over selected channels, resolves destinations via `destination-resolver.ts`, pulls rendered copy from the decision (falling back to `copy-composer.ts` templates), and dispatches through channel adapters. Each delivery attempt is recorded in `notification_deliveries`.
|
|
39
|
+
|
|
40
|
+
## Key Files
|
|
41
|
+
|
|
42
|
+
| File | Purpose |
|
|
43
|
+
|------|---------|
|
|
44
|
+
| `emit-signal.ts` | Single entry point for producers; orchestrates the full pipeline |
|
|
45
|
+
| `signal.ts` | `NotificationSignal` and `AttentionHints` type definitions |
|
|
46
|
+
| `types.ts` | Channel adapter interfaces, delivery types, decision output contract |
|
|
47
|
+
| `decision-engine.ts` | LLM-based routing with forced tool_choice; deterministic fallback |
|
|
48
|
+
| `deterministic-checks.ts` | Pre-send gate checks (dedupe, source-active, channel availability) |
|
|
49
|
+
| `runtime-dispatch.ts` | Dispatch gating (shadow mode, no-op decisions) |
|
|
50
|
+
| `broadcaster.ts` | Fan-out to channel adapters with delivery audit trail |
|
|
51
|
+
| `copy-composer.ts` | Template-based fallback copy when LLM copy is unavailable |
|
|
52
|
+
| `destination-resolver.ts` | Resolves per-channel endpoints (macOS IPC, Telegram chat ID) |
|
|
53
|
+
| `adapters/macos.ts` | macOS adapter -- broadcasts `notification_intent` via IPC |
|
|
54
|
+
| `adapters/telegram.ts` | Telegram adapter -- POSTs to gateway `/deliver/telegram` |
|
|
55
|
+
| `preference-extractor.ts` | Detects notification preferences in conversation messages |
|
|
56
|
+
| `preference-summary.ts` | Builds preference context string for the decision engine prompt |
|
|
57
|
+
| `preferences-store.ts` | CRUD for `notification_preferences` table |
|
|
58
|
+
| `events-store.ts` | CRUD for `notification_events` table |
|
|
59
|
+
| `decisions-store.ts` | CRUD for `notification_decisions` table |
|
|
60
|
+
| `deliveries-store.ts` | CRUD for `notification_deliveries` table |
|
|
61
|
+
|
|
62
|
+
## How to Add a New Notification Producer
|
|
63
|
+
|
|
64
|
+
1. Import `emitNotificationSignal` from `./emit-signal.js`.
|
|
65
|
+
2. Call it with the signal parameters:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { emitNotificationSignal } from '../notifications/emit-signal.js';
|
|
69
|
+
|
|
70
|
+
await emitNotificationSignal({
|
|
71
|
+
sourceEventName: 'your_event_name',
|
|
72
|
+
sourceChannel: 'scheduler', // where the event originated
|
|
73
|
+
sourceSessionId: sessionId,
|
|
74
|
+
attentionHints: {
|
|
75
|
+
requiresAction: true,
|
|
76
|
+
urgency: 'high',
|
|
77
|
+
isAsyncBackground: false,
|
|
78
|
+
visibleInSourceNow: false,
|
|
79
|
+
},
|
|
80
|
+
contextPayload: { /* arbitrary data for the decision engine */ },
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
3. Optionally add a fallback copy template in `copy-composer.ts` keyed by your `sourceEventName`. Without a template, the generic fallback produces a human-readable version of the event name.
|
|
85
|
+
|
|
86
|
+
The call is fire-and-forget safe -- errors are caught and logged internally.
|
|
87
|
+
|
|
88
|
+
## Audit Trail
|
|
89
|
+
|
|
90
|
+
Three SQLite tables form the audit chain:
|
|
91
|
+
|
|
92
|
+
- **`notification_events`** -- every signal that entered the pipeline, with attention hints and context payload
|
|
93
|
+
- **`notification_decisions`** -- the routing decision for each event (shouldNotify, selectedChannels, reasoning, confidence, whether fallback was used)
|
|
94
|
+
- **`notification_deliveries`** -- per-channel delivery attempts with status (pending/sent/failed/skipped), rendered copy, and error details
|
|
95
|
+
|
|
96
|
+
Query examples:
|
|
97
|
+
|
|
98
|
+
```sql
|
|
99
|
+
-- Recent decisions that resulted in notifications
|
|
100
|
+
SELECT e.source_event_name, d.should_notify, d.selected_channels, d.reasoning_summary
|
|
101
|
+
FROM notification_decisions d
|
|
102
|
+
JOIN notification_events e ON d.notification_event_id = e.id
|
|
103
|
+
WHERE d.should_notify = 1
|
|
104
|
+
ORDER BY d.created_at DESC
|
|
105
|
+
LIMIT 20;
|
|
106
|
+
|
|
107
|
+
-- Failed deliveries
|
|
108
|
+
SELECT d.channel, d.error_message, d.rendered_title
|
|
109
|
+
FROM notification_deliveries d
|
|
110
|
+
WHERE d.status = 'failed'
|
|
111
|
+
ORDER BY d.created_at DESC;
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Conversational Preferences
|
|
115
|
+
|
|
116
|
+
Users express notification preferences in natural language during conversations (e.g., "Use Telegram for urgent alerts", "Mute notifications after 10pm"). The system:
|
|
117
|
+
|
|
118
|
+
1. **Detects** preferences via `preference-extractor.ts` -- an LLM call that runs on each user message in `session-process.ts`
|
|
119
|
+
2. **Stores** them in `notification_preferences` with structured conditions (`appliesWhen`: timeRange, channels, urgencyLevels, contexts) and a priority level (0=default, 1=override, 2=critical)
|
|
120
|
+
3. **Summarizes** them at decision time via `preference-summary.ts`, which builds a compact text block injected into the decision engine's system prompt
|
|
121
|
+
|
|
122
|
+
Preferences are sanitized against prompt injection (angle brackets replaced with harmless unicode equivalents).
|
|
123
|
+
|
|
124
|
+
## Configuration
|
|
125
|
+
|
|
126
|
+
All settings live under the `notifications` key in `config.json`:
|
|
127
|
+
|
|
128
|
+
| Key | Type | Default | Description |
|
|
129
|
+
|-----|------|---------|-------------|
|
|
130
|
+
| `notifications.enabled` | boolean | `false` | Master switch for the notification pipeline |
|
|
131
|
+
| `notifications.shadowMode` | boolean | `true` | When true, decisions are logged but not dispatched |
|
|
132
|
+
| `notifications.decisionModel` | string | `"claude-haiku-4-5-20251001"` | Model used for both the decision engine and preference extraction |
|
|
133
|
+
|
|
134
|
+
Shadow mode is useful for validating decision quality before enabling live delivery. The audit trail (events + decisions) is written regardless of shadow mode.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS channel adapter — delivers notifications to connected desktop
|
|
3
|
+
* clients via the daemon's IPC broadcast mechanism.
|
|
4
|
+
*
|
|
5
|
+
* The adapter broadcasts a `notification_intent` message that the macOS
|
|
6
|
+
* client can use to display a native notification (e.g. NSUserNotification
|
|
7
|
+
* or UNUserNotificationCenter).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getLogger } from '../../util/logger.js';
|
|
11
|
+
import type { ServerMessage } from '../../daemon/ipc-contract.js';
|
|
12
|
+
import type {
|
|
13
|
+
NotificationChannel,
|
|
14
|
+
ChannelAdapter,
|
|
15
|
+
ChannelDeliveryPayload,
|
|
16
|
+
ChannelDestination,
|
|
17
|
+
DeliveryResult,
|
|
18
|
+
} from '../types.js';
|
|
19
|
+
|
|
20
|
+
const log = getLogger('notif-adapter-macos');
|
|
21
|
+
|
|
22
|
+
export type BroadcastFn = (msg: ServerMessage) => void;
|
|
23
|
+
|
|
24
|
+
export class MacOSAdapter implements ChannelAdapter {
|
|
25
|
+
readonly channel: NotificationChannel = 'macos';
|
|
26
|
+
|
|
27
|
+
private broadcast: BroadcastFn;
|
|
28
|
+
|
|
29
|
+
constructor(broadcast: BroadcastFn) {
|
|
30
|
+
this.broadcast = broadcast;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async send(payload: ChannelDeliveryPayload, _destination: ChannelDestination): Promise<DeliveryResult> {
|
|
34
|
+
try {
|
|
35
|
+
this.broadcast({
|
|
36
|
+
type: 'notification_intent',
|
|
37
|
+
sourceEventName: payload.sourceEventName,
|
|
38
|
+
title: payload.copy.title,
|
|
39
|
+
body: payload.copy.body,
|
|
40
|
+
deepLinkMetadata: payload.deepLinkTarget,
|
|
41
|
+
} as ServerMessage);
|
|
42
|
+
|
|
43
|
+
log.info(
|
|
44
|
+
{ sourceEventName: payload.sourceEventName, title: payload.copy.title },
|
|
45
|
+
'macOS notification intent broadcast',
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return { success: true };
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
51
|
+
log.error({ err, sourceEventName: payload.sourceEventName }, 'Failed to broadcast macOS notification intent');
|
|
52
|
+
return { success: false, error: message };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram channel adapter — delivers notifications to Telegram chats
|
|
3
|
+
* via the gateway's channel-reply endpoint.
|
|
4
|
+
*
|
|
5
|
+
* Follows the same delivery pattern used by guardian-dispatch: POST to
|
|
6
|
+
* the gateway's `/deliver/telegram` endpoint with a chat ID and text
|
|
7
|
+
* payload. The gateway forwards the message to the Telegram Bot API.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getLogger } from '../../util/logger.js';
|
|
11
|
+
import { getGatewayInternalBaseUrl } from '../../config/env.js';
|
|
12
|
+
import { deliverChannelReply } from '../../runtime/gateway-client.js';
|
|
13
|
+
import { readHttpToken } from '../../util/platform.js';
|
|
14
|
+
import type {
|
|
15
|
+
NotificationChannel,
|
|
16
|
+
ChannelAdapter,
|
|
17
|
+
ChannelDeliveryPayload,
|
|
18
|
+
ChannelDestination,
|
|
19
|
+
DeliveryResult,
|
|
20
|
+
} from '../types.js';
|
|
21
|
+
|
|
22
|
+
const log = getLogger('notif-adapter-telegram');
|
|
23
|
+
|
|
24
|
+
export class TelegramAdapter implements ChannelAdapter {
|
|
25
|
+
readonly channel: NotificationChannel = 'telegram';
|
|
26
|
+
|
|
27
|
+
async send(payload: ChannelDeliveryPayload, destination: ChannelDestination): Promise<DeliveryResult> {
|
|
28
|
+
const chatId = destination.endpoint;
|
|
29
|
+
if (!chatId) {
|
|
30
|
+
log.warn({ sourceEventName: payload.sourceEventName }, 'Telegram destination has no chat ID — skipping');
|
|
31
|
+
return { success: false, error: 'No chat ID configured for Telegram destination' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const gatewayBase = getGatewayInternalBaseUrl();
|
|
35
|
+
const deliverUrl = `${gatewayBase}/deliver/telegram`;
|
|
36
|
+
|
|
37
|
+
// Format copy for Telegram as plain text (no parse_mode set on gateway side)
|
|
38
|
+
let messageText = payload.copy.title + '\n\n' + payload.copy.body;
|
|
39
|
+
if (payload.copy.threadTitle) {
|
|
40
|
+
messageText += '\n\nThread: ' + payload.copy.threadTitle;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
await deliverChannelReply(
|
|
45
|
+
deliverUrl,
|
|
46
|
+
{ chatId, text: messageText },
|
|
47
|
+
readHttpToken() ?? undefined,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
log.info(
|
|
51
|
+
{ sourceEventName: payload.sourceEventName, chatId },
|
|
52
|
+
'Telegram notification delivered',
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return { success: true };
|
|
56
|
+
} catch (err) {
|
|
57
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
58
|
+
log.error(
|
|
59
|
+
{ err, sourceEventName: payload.sourceEventName, chatId },
|
|
60
|
+
'Failed to deliver Telegram notification',
|
|
61
|
+
);
|
|
62
|
+
return { success: false, error: message };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|