@vellumai/assistant 0.3.8 → 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__/session-init.benchmark.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/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/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 +84 -1
- 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/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 -0
- 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 +10 -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 +31 -1
- package/src/runtime/routes/run-routes.ts +1 -1
- package/src/runtime/run-orchestrator.ts +157 -2
- package/src/tools/browser/browser-manager.ts +1 -1
- package/src/__tests__/call-orchestrator.test.ts +0 -1496
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const NotificationsConfigSchema = z.object({
|
|
4
|
+
enabled: z
|
|
5
|
+
.boolean({ error: 'notifications.enabled must be a boolean' })
|
|
6
|
+
.default(false),
|
|
7
|
+
shadowMode: z
|
|
8
|
+
.boolean({ error: 'notifications.shadowMode must be a boolean' })
|
|
9
|
+
.default(true),
|
|
10
|
+
decisionModel: z
|
|
11
|
+
.string({ error: 'notifications.decisionModel must be a string' })
|
|
12
|
+
.default('claude-haiku-4-5-20251001'),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export type NotificationsConfig = z.infer<typeof NotificationsConfigSchema>;
|
package/src/config/schema.ts
CHANGED
|
@@ -90,6 +90,13 @@ export type {
|
|
|
90
90
|
WorkspaceGitConfig,
|
|
91
91
|
} from './agent-schema.js';
|
|
92
92
|
|
|
93
|
+
export {
|
|
94
|
+
NotificationsConfigSchema,
|
|
95
|
+
} from './notifications-schema.js';
|
|
96
|
+
export type {
|
|
97
|
+
NotificationsConfig,
|
|
98
|
+
} from './notifications-schema.js';
|
|
99
|
+
|
|
93
100
|
export {
|
|
94
101
|
TimeoutConfigSchema,
|
|
95
102
|
RateLimitConfigSchema,
|
|
@@ -129,6 +136,7 @@ import { CallsConfigSchema } from './calls-schema.js';
|
|
|
129
136
|
import { SandboxConfigSchema } from './sandbox-schema.js';
|
|
130
137
|
import { SkillsConfigSchema } from './skills-schema.js';
|
|
131
138
|
import { AgentHeartbeatConfigSchema, SwarmConfigSchema, WorkspaceGitConfigSchema } from './agent-schema.js';
|
|
139
|
+
import { NotificationsConfigSchema } from './notifications-schema.js';
|
|
132
140
|
import {
|
|
133
141
|
TimeoutConfigSchema,
|
|
134
142
|
RateLimitConfigSchema,
|
|
@@ -437,6 +445,11 @@ export const AssistantConfigSchema = z.object({
|
|
|
437
445
|
sigkillGracePeriodMs: 2000,
|
|
438
446
|
titleGenerationMaxTokens: 30,
|
|
439
447
|
}),
|
|
448
|
+
notifications: NotificationsConfigSchema.default({
|
|
449
|
+
enabled: false,
|
|
450
|
+
shadowMode: true,
|
|
451
|
+
decisionModel: 'claude-haiku-4-5-20251001',
|
|
452
|
+
}),
|
|
440
453
|
}).superRefine((config, ctx) => {
|
|
441
454
|
if (config.contextWindow.targetInputTokens >= config.contextWindow.maxInputTokens) {
|
|
442
455
|
ctx.addIssue({
|
package/src/config/types.ts
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Broadcast to connected macOS clients when a notification should be displayed. */
|
|
2
|
+
export interface NotificationIntent {
|
|
3
|
+
type: 'notification_intent';
|
|
4
|
+
sourceEventName: string;
|
|
5
|
+
title: string;
|
|
6
|
+
body: string;
|
|
7
|
+
/** Optional deep-link metadata so the client can navigate to the relevant context. */
|
|
8
|
+
deepLinkMetadata?: Record<string, unknown>;
|
|
9
|
+
}
|
|
@@ -201,6 +201,7 @@
|
|
|
201
201
|
"MessageQueued",
|
|
202
202
|
"MessageQueuedDeleted",
|
|
203
203
|
"ModelInfo",
|
|
204
|
+
"NotificationIntent",
|
|
204
205
|
"OpenBundleResponse",
|
|
205
206
|
"OpenTasksWindow",
|
|
206
207
|
"OpenUrl",
|
|
@@ -486,6 +487,7 @@
|
|
|
486
487
|
"message_queued",
|
|
487
488
|
"message_queued_deleted",
|
|
488
489
|
"model_info",
|
|
490
|
+
"notification_intent",
|
|
489
491
|
"open_bundle_response",
|
|
490
492
|
"open_tasks_window",
|
|
491
493
|
"open_url",
|
|
@@ -27,6 +27,7 @@ export * from './ipc-contract/diagnostics.js';
|
|
|
27
27
|
export * from './ipc-contract/parental-control.js';
|
|
28
28
|
export * from './ipc-contract/inbox.js';
|
|
29
29
|
export * from './ipc-contract/pairing.js';
|
|
30
|
+
export * from './ipc-contract/notifications.js';
|
|
30
31
|
|
|
31
32
|
// Import types needed for aggregate unions and SubagentEvent
|
|
32
33
|
import type { AuthMessage, PingMessage, CancelRequest, DeleteQueuedMessage, ModelGetRequest, ModelSetRequest, ImageGenModelSetRequest, HistoryRequest, UndoRequest, RegenerateRequest, UsageRequest, SandboxSetRequest, SessionListRequest, SessionCreateRequest, SessionSwitchRequest, SessionRenameRequest, SessionsClearRequest, ConversationSearchRequest } from './ipc-contract/sessions.js';
|
|
@@ -64,6 +65,7 @@ import type { SchedulesList, ScheduleToggle, ScheduleRemove, ScheduleRunNow, Rem
|
|
|
64
65
|
import type { ParentalControlGetRequest, ParentalControlVerifyPinRequest, ParentalControlSetPinRequest, ParentalControlUpdateRequest, ParentalControlGetResponse, ParentalControlVerifyPinResponse, ParentalControlSetPinResponse, ParentalControlUpdateResponse } from './ipc-contract/parental-control.js';
|
|
65
66
|
import type { IngressInviteRequest, IngressMemberRequest, AssistantInboxRequest, AssistantInboxEscalationRequest, AssistantInboxReplyRequest, IngressInviteResponse, IngressMemberResponse, AssistantInboxResponse, AssistantInboxEscalationResponse, AssistantInboxReplyResponse } from './ipc-contract/inbox.js';
|
|
66
67
|
import type { PairingApprovalResponse, ApprovedDevicesList, ApprovedDeviceRemove, ApprovedDevicesClear, PairingApprovalRequest, ApprovedDevicesListResponse, ApprovedDeviceRemoveResponse } from './ipc-contract/pairing.js';
|
|
68
|
+
import type { NotificationIntent } from './ipc-contract/notifications.js';
|
|
67
69
|
|
|
68
70
|
// === SubagentEvent — defined here because it references ServerMessage ===
|
|
69
71
|
|
|
@@ -361,7 +363,8 @@ export type ServerMessage =
|
|
|
361
363
|
| AssistantInboxReplyResponse
|
|
362
364
|
| PairingApprovalRequest
|
|
363
365
|
| ApprovedDevicesListResponse
|
|
364
|
-
| ApprovedDeviceRemoveResponse
|
|
366
|
+
| ApprovedDeviceRemoveResponse
|
|
367
|
+
| NotificationIntent;
|
|
365
368
|
|
|
366
369
|
// === Contract schema ===
|
|
367
370
|
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { ensurePromptFiles } from '../config/system-prompt.js';
|
|
|
26
26
|
import { loadPrebuiltHtml } from '../home-base/prebuilt/seed.js';
|
|
27
27
|
import { DaemonServer } from './server.js';
|
|
28
28
|
import { setRelayBroadcast } from '../calls/relay-server.js';
|
|
29
|
+
import { setVoiceBridgeOrchestrator } from '../calls/voice-session-bridge.js';
|
|
29
30
|
import { listWorkItems, updateWorkItem } from '../work-items/work-item-store.js';
|
|
30
31
|
import { getLogger, initLogger } from '../util/logger.js';
|
|
31
32
|
import { initSentry } from '../instrument.js';
|
|
@@ -44,6 +45,7 @@ import { HeartbeatService } from '../workspace/heartbeat-service.js';
|
|
|
44
45
|
import { AgentHeartbeatService } from '../agent-heartbeat/agent-heartbeat-service.js';
|
|
45
46
|
import { reconcileCallsOnStartup } from '../calls/call-recovery.js';
|
|
46
47
|
import { TwilioConversationRelayProvider } from '../calls/twilio-provider.js';
|
|
48
|
+
import { emitNotificationSignal, registerBroadcastFn } from '../notifications/emit-signal.js';
|
|
47
49
|
import { createApprovalCopyGenerator, createApprovalConversationGenerator } from './approval-generators.js';
|
|
48
50
|
import { initializeProvidersAndTools, registerWatcherProviders, registerMessagingProviders } from './providers-setup.js';
|
|
49
51
|
import { installShutdownHandlers } from './shutdown-handlers.js';
|
|
@@ -187,38 +189,112 @@ export async function runDaemon(): Promise<void> {
|
|
|
187
189
|
registerWatcherProviders();
|
|
188
190
|
registerMessagingProviders();
|
|
189
191
|
|
|
192
|
+
// Register the IPC broadcast function for the notification signal pipeline's
|
|
193
|
+
// macOS adapter so it can deliver notification_intent messages to desktop clients.
|
|
194
|
+
registerBroadcastFn((msg) => server.broadcast(msg));
|
|
195
|
+
|
|
190
196
|
const scheduler = startScheduler(
|
|
191
197
|
async (conversationId, message) => {
|
|
192
198
|
await server.processMessage(conversationId, message);
|
|
193
199
|
},
|
|
194
200
|
(reminder) => {
|
|
201
|
+
// Legacy IPC broadcast for backward compatibility with desktop client UI
|
|
195
202
|
server.broadcast({
|
|
196
203
|
type: 'reminder_fired',
|
|
197
204
|
reminderId: reminder.id,
|
|
198
205
|
label: reminder.label,
|
|
199
206
|
message: reminder.message,
|
|
200
207
|
});
|
|
208
|
+
// Signal pipeline: fire-and-forget
|
|
209
|
+
void emitNotificationSignal({
|
|
210
|
+
sourceEventName: 'reminder.fired',
|
|
211
|
+
sourceChannel: 'scheduler',
|
|
212
|
+
sourceSessionId: reminder.id,
|
|
213
|
+
attentionHints: {
|
|
214
|
+
requiresAction: true,
|
|
215
|
+
urgency: 'high',
|
|
216
|
+
isAsyncBackground: false,
|
|
217
|
+
visibleInSourceNow: false,
|
|
218
|
+
},
|
|
219
|
+
contextPayload: {
|
|
220
|
+
reminderId: reminder.id,
|
|
221
|
+
label: reminder.label,
|
|
222
|
+
message: reminder.message,
|
|
223
|
+
},
|
|
224
|
+
dedupeKey: `reminder:${reminder.id}`,
|
|
225
|
+
});
|
|
201
226
|
},
|
|
202
227
|
(schedule) => {
|
|
228
|
+
// Legacy IPC broadcast for backward compatibility with desktop client UI
|
|
203
229
|
server.broadcast({
|
|
204
230
|
type: 'schedule_complete',
|
|
205
231
|
scheduleId: schedule.id,
|
|
206
232
|
name: schedule.name,
|
|
207
233
|
});
|
|
234
|
+
// Signal pipeline: fire-and-forget
|
|
235
|
+
void emitNotificationSignal({
|
|
236
|
+
sourceEventName: 'schedule.complete',
|
|
237
|
+
sourceChannel: 'scheduler',
|
|
238
|
+
sourceSessionId: schedule.id,
|
|
239
|
+
attentionHints: {
|
|
240
|
+
requiresAction: false,
|
|
241
|
+
urgency: 'medium',
|
|
242
|
+
isAsyncBackground: true,
|
|
243
|
+
visibleInSourceNow: false,
|
|
244
|
+
},
|
|
245
|
+
contextPayload: {
|
|
246
|
+
scheduleId: schedule.id,
|
|
247
|
+
name: schedule.name,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
208
250
|
},
|
|
209
251
|
(notification) => {
|
|
252
|
+
// Legacy IPC broadcast for backward compatibility with desktop client UI
|
|
210
253
|
server.broadcast({
|
|
211
254
|
type: 'watcher_notification',
|
|
212
255
|
title: notification.title,
|
|
213
256
|
body: notification.body,
|
|
214
257
|
});
|
|
258
|
+
// Signal pipeline: fire-and-forget
|
|
259
|
+
void emitNotificationSignal({
|
|
260
|
+
sourceEventName: 'watcher.notification',
|
|
261
|
+
sourceChannel: 'watcher',
|
|
262
|
+
sourceSessionId: `watcher-${Date.now()}`,
|
|
263
|
+
attentionHints: {
|
|
264
|
+
requiresAction: false,
|
|
265
|
+
urgency: 'low',
|
|
266
|
+
isAsyncBackground: true,
|
|
267
|
+
visibleInSourceNow: false,
|
|
268
|
+
},
|
|
269
|
+
contextPayload: {
|
|
270
|
+
title: notification.title,
|
|
271
|
+
body: notification.body,
|
|
272
|
+
},
|
|
273
|
+
});
|
|
215
274
|
},
|
|
216
275
|
(params) => {
|
|
276
|
+
// Legacy IPC broadcast for backward compatibility with desktop client UI
|
|
217
277
|
server.broadcast({
|
|
218
278
|
type: 'watcher_escalation',
|
|
219
279
|
title: params.title,
|
|
220
280
|
body: params.body,
|
|
221
281
|
});
|
|
282
|
+
// Signal pipeline: fire-and-forget
|
|
283
|
+
void emitNotificationSignal({
|
|
284
|
+
sourceEventName: 'watcher.escalation',
|
|
285
|
+
sourceChannel: 'watcher',
|
|
286
|
+
sourceSessionId: `watcher-escalation-${Date.now()}`,
|
|
287
|
+
attentionHints: {
|
|
288
|
+
requiresAction: true,
|
|
289
|
+
urgency: 'high',
|
|
290
|
+
isAsyncBackground: false,
|
|
291
|
+
visibleInSourceNow: false,
|
|
292
|
+
},
|
|
293
|
+
contextPayload: {
|
|
294
|
+
title: params.title,
|
|
295
|
+
body: params.body,
|
|
296
|
+
},
|
|
297
|
+
});
|
|
222
298
|
},
|
|
223
299
|
);
|
|
224
300
|
|
|
@@ -250,6 +326,8 @@ export async function runDaemon(): Promise<void> {
|
|
|
250
326
|
|
|
251
327
|
const hostname = getRuntimeHttpHost();
|
|
252
328
|
|
|
329
|
+
const runOrchestrator = server.createRunOrchestrator();
|
|
330
|
+
|
|
253
331
|
runtimeHttp = new RuntimeHttpServer({
|
|
254
332
|
port: httpPort,
|
|
255
333
|
hostname,
|
|
@@ -258,7 +336,7 @@ export async function runDaemon(): Promise<void> {
|
|
|
258
336
|
server.processMessage(conversationId, content, attachmentIds, options, sourceChannel),
|
|
259
337
|
persistAndProcessMessage: (conversationId, content, attachmentIds, options, sourceChannel) =>
|
|
260
338
|
server.persistAndProcessMessage(conversationId, content, attachmentIds, options, sourceChannel),
|
|
261
|
-
runOrchestrator
|
|
339
|
+
runOrchestrator,
|
|
262
340
|
interfacesDir: getInterfacesDir(),
|
|
263
341
|
approvalCopyGenerator: createApprovalCopyGenerator(),
|
|
264
342
|
approvalConversationGenerator: createApprovalConversationGenerator(),
|
|
@@ -275,6 +353,11 @@ export async function runDaemon(): Promise<void> {
|
|
|
275
353
|
})),
|
|
276
354
|
},
|
|
277
355
|
});
|
|
356
|
+
|
|
357
|
+
// Inject the voice bridge orchestrator BEFORE attempting to start the HTTP
|
|
358
|
+
// server. The bridge only needs the RunOrchestrator instance (already created
|
|
359
|
+
// above) and must be available even when the HTTP server fails to bind.
|
|
360
|
+
setVoiceBridgeOrchestrator(runOrchestrator);
|
|
278
361
|
try {
|
|
279
362
|
await runtimeHttp.start();
|
|
280
363
|
setRelayBroadcast((msg) => server.broadcast(msg));
|
|
@@ -100,6 +100,7 @@ export interface AgentLoopSessionContext {
|
|
|
100
100
|
channelCapabilities?: ChannelCapabilities;
|
|
101
101
|
commandIntent?: { type: string; payload?: string; languageCode?: string };
|
|
102
102
|
guardianContext?: GuardianRuntimeContext;
|
|
103
|
+
voiceCallControlPrompt?: string;
|
|
103
104
|
|
|
104
105
|
readonly coreToolNames: Set<string>;
|
|
105
106
|
allowedToolNames?: Set<string>;
|
|
@@ -321,6 +322,7 @@ export async function runAgentLoopImpl(
|
|
|
321
322
|
channelTurnContext,
|
|
322
323
|
guardianContext: ctx.guardianContext ?? null,
|
|
323
324
|
temporalContext,
|
|
325
|
+
voiceCallControlPrompt: ctx.voiceCallControlPrompt ?? null,
|
|
324
326
|
});
|
|
325
327
|
|
|
326
328
|
// Pre-run repair
|
|
@@ -431,6 +433,7 @@ export async function runAgentLoopImpl(
|
|
|
431
433
|
channelTurnContext,
|
|
432
434
|
guardianContext: ctx.guardianContext ?? null,
|
|
433
435
|
temporalContext,
|
|
436
|
+
voiceCallControlPrompt: ctx.voiceCallControlPrompt ?? null,
|
|
434
437
|
});
|
|
435
438
|
preRepairMessages = runMessages;
|
|
436
439
|
preRunHistoryLength = runMessages.length;
|
|
@@ -466,6 +469,7 @@ export async function runAgentLoopImpl(
|
|
|
466
469
|
channelTurnContext,
|
|
467
470
|
guardianContext: ctx.guardianContext ?? null,
|
|
468
471
|
temporalContext,
|
|
472
|
+
voiceCallControlPrompt: ctx.voiceCallControlPrompt ?? null,
|
|
469
473
|
});
|
|
470
474
|
preRepairMessages = runMessages;
|
|
471
475
|
preRunHistoryLength = runMessages.length;
|
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
import { answerCall } from '../calls/call-domain.js';
|
|
25
25
|
import { resolveSlash, type SlashContext } from './session-slash.js';
|
|
26
26
|
import { getConfig } from '../config/loader.js';
|
|
27
|
+
import { extractPreferences } from '../notifications/preference-extractor.js';
|
|
28
|
+
import { createPreference } from '../notifications/preferences-store.js';
|
|
27
29
|
import { getLogger } from '../util/logger.js';
|
|
28
30
|
|
|
29
31
|
const log = getLogger('session-process');
|
|
@@ -68,6 +70,8 @@ export interface ProcessSessionContext {
|
|
|
68
70
|
readonly usageStats: UsageStats;
|
|
69
71
|
/** Request-scoped skill IDs preactivated via slash resolution. */
|
|
70
72
|
preactivatedSkillIds?: string[];
|
|
73
|
+
/** Assistant identity — used for scoping notification preferences. */
|
|
74
|
+
readonly assistantId?: string;
|
|
71
75
|
persistUserMessage(content: string, attachments: UserMessageAttachment[], requestId?: string, metadata?: Record<string, unknown>): string;
|
|
72
76
|
runAgentLoop(
|
|
73
77
|
content: string,
|
|
@@ -231,6 +235,29 @@ export function drainQueue(session: ProcessSessionContext, reason: QueueDrainRea
|
|
|
231
235
|
session.currentActiveSurfaceId = next.activeSurfaceId;
|
|
232
236
|
session.currentPage = next.currentPage;
|
|
233
237
|
|
|
238
|
+
// Fire-and-forget: detect notification preferences in the queued message
|
|
239
|
+
// and persist any that are found, mirroring the logic in processMessage.
|
|
240
|
+
if (session.assistantId && getConfig().notifications.enabled) {
|
|
241
|
+
const aid = session.assistantId;
|
|
242
|
+
extractPreferences(resolvedContent)
|
|
243
|
+
.then((result) => {
|
|
244
|
+
if (!result.detected) return;
|
|
245
|
+
for (const pref of result.preferences) {
|
|
246
|
+
createPreference({
|
|
247
|
+
assistantId: aid,
|
|
248
|
+
preferenceText: pref.preferenceText,
|
|
249
|
+
appliesWhen: pref.appliesWhen,
|
|
250
|
+
priority: pref.priority,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
log.info({ count: result.preferences.length, conversationId: session.conversationId }, 'Persisted extracted notification preferences (queued)');
|
|
254
|
+
})
|
|
255
|
+
.catch((err) => {
|
|
256
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
257
|
+
log.warn({ err: errMsg, conversationId: session.conversationId }, 'Background preference extraction failed (queued)');
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
234
261
|
// Fire-and-forget: persistUserMessage set session.processing = true
|
|
235
262
|
// so subsequent messages will still be enqueued.
|
|
236
263
|
// runAgentLoop's finally block will call drainQueue when this run completes.
|
|
@@ -375,6 +402,30 @@ export async function processMessage(
|
|
|
375
402
|
return '';
|
|
376
403
|
}
|
|
377
404
|
|
|
405
|
+
// Fire-and-forget: detect notification preferences in the user message
|
|
406
|
+
// and persist any that are found. Runs in the background so it doesn't
|
|
407
|
+
// block the main conversation flow.
|
|
408
|
+
if (session.assistantId && getConfig().notifications.enabled) {
|
|
409
|
+
const aid = session.assistantId;
|
|
410
|
+
extractPreferences(resolvedContent)
|
|
411
|
+
.then((result) => {
|
|
412
|
+
if (!result.detected) return;
|
|
413
|
+
for (const pref of result.preferences) {
|
|
414
|
+
createPreference({
|
|
415
|
+
assistantId: aid,
|
|
416
|
+
preferenceText: pref.preferenceText,
|
|
417
|
+
appliesWhen: pref.appliesWhen,
|
|
418
|
+
priority: pref.priority,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
log.info({ count: result.preferences.length, conversationId: session.conversationId }, 'Persisted extracted notification preferences');
|
|
422
|
+
})
|
|
423
|
+
.catch((err) => {
|
|
424
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
425
|
+
log.warn({ err: errMsg, conversationId: session.conversationId }, 'Background preference extraction failed');
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
378
429
|
await session.runAgentLoop(resolvedContent, userMessageId, onEvent);
|
|
379
430
|
return userMessageId;
|
|
380
431
|
}
|
|
@@ -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(
|
|
@@ -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 {
|