@vellumai/assistant 0.3.15 → 0.3.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +142 -0
- package/Dockerfile +1 -1
- package/README.md +5 -5
- package/docs/architecture/http-token-refresh.md +252 -0
- package/docs/architecture/memory.md +5 -4
- package/docs/architecture/scheduling.md +4 -88
- package/docs/runbook-trusted-contacts.md +283 -0
- package/docs/trusted-contact-access.md +247 -0
- package/package.json +1 -1
- package/scripts/ipc/check-swift-decoder-drift.ts +2 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +2 -6
- package/src/__tests__/access-request-decision.test.ts +331 -0
- package/src/__tests__/asset-materialize-tool.test.ts +7 -7
- package/src/__tests__/asset-search-tool.test.ts +15 -15
- package/src/__tests__/attachments-store.test.ts +13 -13
- package/src/__tests__/call-controller.test.ts +150 -4
- package/src/__tests__/call-conversation-messages.test.ts +2 -2
- package/src/__tests__/call-pointer-messages.test.ts +28 -0
- package/src/__tests__/call-start-guardian-guard.test.ts +93 -0
- package/src/__tests__/channel-approval-routes.test.ts +108 -12
- package/src/__tests__/channel-guardian.test.ts +16 -14
- package/src/__tests__/checker.test.ts +24 -0
- package/src/__tests__/computer-use-skill-manifest-regression.test.ts +2 -2
- package/src/__tests__/config-watcher.test.ts +358 -0
- package/src/__tests__/conversation-pairing.test.ts +24 -24
- package/src/__tests__/conversation-store.test.ts +36 -36
- package/src/__tests__/date-context.test.ts +179 -1
- package/src/__tests__/db-migration-rollback.test.ts +4 -7
- package/src/__tests__/deterministic-verification-control-plane.test.ts +5 -5
- package/src/__tests__/emit-signal-routing-intent.test.ts +179 -0
- package/src/__tests__/gateway-only-guard.test.ts +188 -0
- package/src/__tests__/guardian-action-conversation-turn.test.ts +451 -0
- package/src/__tests__/guardian-action-copy-generator.test.ts +197 -0
- package/src/__tests__/guardian-action-followup-executor.test.ts +379 -0
- package/src/__tests__/guardian-action-followup-store.test.ts +376 -0
- package/src/__tests__/guardian-action-late-reply.test.ts +294 -0
- package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +71 -0
- package/src/__tests__/guardian-action-sweep.test.ts +9 -9
- package/src/__tests__/guardian-outbound-http.test.ts +194 -2
- package/src/__tests__/guardian-verification-intent-routing.test.ts +179 -0
- package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +141 -0
- package/src/__tests__/handlers-telegram-config.test.ts +6 -6
- package/src/__tests__/hooks-runner.test.ts +13 -4
- package/src/__tests__/ingress-routes-http.test.ts +443 -0
- package/src/__tests__/intent-routing.test.ts +14 -0
- package/src/__tests__/ipc-snapshot.test.ts +2 -5
- package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
- package/src/__tests__/memory-regressions.test.ts +16 -12
- package/src/__tests__/non-member-access-request.test.ts +282 -0
- package/src/__tests__/notification-decision-strategy.test.ts +136 -0
- package/src/__tests__/notification-routing-intent.test.ts +11 -1
- package/src/__tests__/notification-thread-candidates.test.ts +166 -0
- package/src/__tests__/recording-intent.test.ts +1 -0
- package/src/__tests__/recording-state-machine.test.ts +328 -17
- package/src/__tests__/registry.test.ts +17 -8
- package/src/__tests__/relay-server.test.ts +105 -0
- package/src/__tests__/reminder.test.ts +13 -0
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -4
- package/src/__tests__/scheduler-recurrence.test.ts +50 -0
- package/src/__tests__/server-history-render.test.ts +8 -8
- package/src/__tests__/session-agent-loop.test.ts +1 -0
- package/src/__tests__/session-runtime-assembly.test.ts +49 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -3
- package/src/__tests__/slack-channel-config.test.ts +230 -0
- package/src/__tests__/subagent-manager-notify.test.ts +4 -4
- package/src/__tests__/swarm-session-integration.test.ts +2 -2
- package/src/__tests__/system-prompt.test.ts +43 -0
- package/src/__tests__/task-management-tools.test.ts +3 -3
- package/src/__tests__/task-tools.test.ts +3 -3
- package/src/__tests__/trust-store.test.ts +17 -1
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +491 -0
- package/src/__tests__/trusted-contact-multichannel.test.ts +409 -0
- package/src/__tests__/trusted-contact-verification.test.ts +360 -0
- package/src/__tests__/update-bulletin-format.test.ts +119 -0
- package/src/__tests__/update-bulletin-state.test.ts +129 -0
- package/src/__tests__/update-bulletin.test.ts +260 -0
- package/src/__tests__/update-template-contract.test.ts +29 -0
- package/src/agent/loop.ts +2 -2
- package/src/amazon/client.ts +2 -3
- package/src/calls/call-controller.ts +115 -34
- package/src/calls/call-conversation-messages.ts +2 -2
- package/src/calls/call-domain.ts +10 -3
- package/src/calls/call-pointer-messages.ts +17 -5
- package/src/calls/guardian-action-sweep.ts +77 -36
- package/src/calls/relay-server.ts +51 -12
- package/src/calls/twilio-routes.ts +3 -1
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +4 -4
- package/src/cli/core-commands.ts +3 -3
- package/src/cli/map.ts +8 -5
- package/src/config/bundled-skills/phone-calls/SKILL.md +16 -1
- package/src/config/bundled-skills/tasks/SKILL.md +1 -1
- package/src/config/bundled-skills/tasks/TOOLS.json +4 -4
- package/src/config/bundled-skills/time-based-actions/SKILL.md +11 -1
- package/src/config/computer-use-prompt.ts +1 -0
- package/src/config/core-schema.ts +16 -0
- package/src/config/env-registry.ts +1 -0
- package/src/config/env.ts +16 -1
- package/src/config/memory-schema.ts +5 -0
- package/src/config/schema.ts +4 -0
- package/src/config/system-prompt.ts +69 -2
- package/src/config/templates/BOOTSTRAP.md +1 -1
- package/src/config/templates/IDENTITY.md +8 -4
- package/src/config/templates/SOUL.md +14 -0
- package/src/config/templates/UPDATES.md +16 -0
- package/src/config/templates/USER.md +5 -1
- package/src/config/types.ts +1 -0
- package/src/config/update-bulletin-format.ts +52 -0
- package/src/config/update-bulletin-state.ts +49 -0
- package/src/config/update-bulletin.ts +82 -0
- package/src/config/vellum-skills/catalog.json +6 -0
- package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +1 -1
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +44 -10
- package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +147 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
- package/src/context/window-manager.ts +43 -3
- package/src/daemon/config-watcher.ts +1 -0
- package/src/daemon/connection-policy.ts +21 -1
- package/src/daemon/daemon-control.ts +164 -7
- package/src/daemon/date-context.ts +174 -1
- package/src/daemon/guardian-action-generators.ts +175 -0
- package/src/daemon/guardian-verification-intent.ts +120 -0
- package/src/daemon/handlers/apps.ts +1 -3
- package/src/daemon/handlers/config-channels.ts +2 -2
- package/src/daemon/handlers/config-heartbeat.ts +1 -1
- package/src/daemon/handlers/config-inbox.ts +55 -159
- package/src/daemon/handlers/config-ingress.ts +1 -1
- package/src/daemon/handlers/config-integrations.ts +1 -1
- package/src/daemon/handlers/config-platform.ts +1 -1
- package/src/daemon/handlers/config-scheduling.ts +2 -2
- package/src/daemon/handlers/config-slack-channel.ts +190 -0
- package/src/daemon/handlers/config-telegram.ts +1 -1
- package/src/daemon/handlers/config-twilio.ts +1 -1
- package/src/daemon/handlers/config-voice.ts +100 -0
- package/src/daemon/handlers/config.ts +3 -0
- package/src/daemon/handlers/misc.ts +83 -5
- package/src/daemon/handlers/navigate-settings.ts +27 -0
- package/src/daemon/handlers/recording.ts +270 -144
- package/src/daemon/handlers/sessions.ts +100 -17
- package/src/daemon/handlers/subagents.ts +3 -3
- package/src/daemon/handlers/work-items.ts +10 -7
- package/src/daemon/ipc-contract/integrations.ts +9 -1
- package/src/daemon/ipc-contract/messages.ts +4 -0
- package/src/daemon/ipc-contract/sessions.ts +1 -1
- package/src/daemon/ipc-contract/settings.ts +26 -0
- package/src/daemon/ipc-contract/shared.ts +2 -0
- package/src/daemon/ipc-contract/work-items.ts +1 -7
- package/src/daemon/ipc-contract-inventory.json +5 -1
- package/src/daemon/ipc-contract.ts +5 -1
- package/src/daemon/lifecycle.ts +306 -266
- package/src/daemon/recording-intent.ts +0 -41
- package/src/daemon/response-tier.ts +2 -2
- package/src/daemon/server.ts +6 -6
- package/src/daemon/session-agent-loop-handlers.ts +34 -9
- package/src/daemon/session-agent-loop.ts +15 -8
- package/src/daemon/session-history.ts +3 -2
- package/src/daemon/session-media-retry.ts +3 -0
- package/src/daemon/session-messaging.ts +38 -4
- package/src/daemon/session-notifiers.ts +2 -2
- package/src/daemon/session-process.ts +256 -23
- package/src/daemon/session-queue-manager.ts +2 -0
- package/src/daemon/session-runtime-assembly.ts +39 -0
- package/src/daemon/session-skill-tools.ts +13 -4
- package/src/daemon/session-tool-setup.ts +5 -6
- package/src/daemon/session.ts +19 -8
- package/src/daemon/tls-certs.ts +55 -13
- package/src/daemon/tool-side-effects.ts +13 -5
- package/src/gallery/default-gallery.ts +32 -9
- package/src/influencer/client.ts +2 -1
- package/src/memory/channel-delivery-store.ts +37 -567
- package/src/memory/channel-guardian-store.ts +66 -1317
- package/src/memory/conflict-store.ts +4 -4
- package/src/memory/conversation-attention-store.ts +0 -3
- package/src/memory/conversation-crud.ts +668 -0
- package/src/memory/conversation-queries.ts +361 -0
- package/src/memory/conversation-store.ts +45 -983
- package/src/memory/db-connection.ts +3 -0
- package/src/memory/db-init.ts +25 -0
- package/src/memory/delivery-channels.ts +175 -0
- package/src/memory/delivery-crud.ts +211 -0
- package/src/memory/delivery-status.ts +199 -0
- package/src/memory/embedding-backend.ts +70 -4
- package/src/memory/embedding-local.ts +12 -2
- package/src/memory/entity-extractor.ts +3 -8
- package/src/memory/fts-reconciler.ts +121 -0
- package/src/memory/guardian-action-store.ts +366 -3
- package/src/memory/guardian-approvals.ts +569 -0
- package/src/memory/guardian-bindings.ts +130 -0
- package/src/memory/guardian-rate-limits.ts +196 -0
- package/src/memory/guardian-verification.ts +520 -0
- package/src/memory/job-handlers/index-maintenance.ts +2 -1
- package/src/memory/job-utils.ts +8 -5
- package/src/memory/jobs-store.ts +66 -6
- package/src/memory/jobs-worker.ts +23 -1
- package/src/memory/migrations/030-guardian-action-followup.ts +21 -0
- package/src/memory/migrations/030-guardian-verification-purpose.ts +17 -0
- package/src/memory/migrations/031-conversations-thread-type-index.ts +5 -0
- package/src/memory/migrations/100-core-tables.ts +1 -1
- package/src/memory/migrations/101-watchers-and-logs.ts +4 -0
- package/src/memory/migrations/108-tasks-and-work-items.ts +1 -1
- package/src/memory/migrations/112-assistant-inbox.ts +1 -1
- package/src/memory/migrations/113-late-migrations.ts +1 -1
- package/src/memory/migrations/116-messages-fts.ts +13 -0
- package/src/memory/migrations/119-schema-indexes-and-columns.ts +37 -0
- package/src/memory/migrations/120-fk-cascade-rebuilds.ts +161 -0
- package/src/memory/migrations/index.ts +8 -3
- package/src/memory/migrations/validate-migration-state.ts +114 -15
- package/src/memory/qdrant-circuit-breaker.ts +105 -0
- package/src/memory/retriever.ts +46 -13
- package/src/memory/schema-migration.ts +3 -0
- package/src/memory/schema.ts +25 -7
- package/src/memory/search/semantic.ts +8 -90
- package/src/notifications/README.md +1 -1
- package/src/notifications/broadcaster.ts +20 -2
- package/src/notifications/conversation-pairing.ts +3 -3
- package/src/notifications/decision-engine.ts +173 -8
- package/src/notifications/deliveries-store.ts +27 -8
- package/src/notifications/preferences-store.ts +7 -7
- package/src/notifications/thread-candidates.ts +234 -0
- package/src/notifications/types.ts +18 -0
- package/src/permissions/defaults.ts +11 -1
- package/src/permissions/prompter.ts +17 -0
- package/src/permissions/trust-store.ts +2 -0
- package/src/providers/failover.ts +19 -0
- package/src/providers/registry.ts +46 -1
- package/src/runtime/approval-message-composer.ts +1 -1
- package/src/runtime/channel-guardian-service.ts +15 -3
- package/src/runtime/channel-retry-sweep.ts +7 -2
- package/src/runtime/guardian-action-conversation-turn.ts +85 -0
- package/src/runtime/guardian-action-followup-executor.ts +301 -0
- package/src/runtime/guardian-action-message-composer.ts +245 -0
- package/src/runtime/guardian-outbound-actions.ts +26 -6
- package/src/runtime/guardian-verification-templates.ts +15 -9
- package/src/runtime/http-errors.ts +93 -0
- package/src/runtime/http-server.ts +133 -44
- package/src/runtime/http-types.ts +53 -0
- package/src/runtime/ingress-service.ts +237 -0
- package/src/runtime/middleware/error-handler.ts +4 -3
- package/src/runtime/middleware/rate-limiter.ts +160 -0
- package/src/runtime/middleware/request-logger.ts +71 -0
- package/src/runtime/middleware/twilio-validation.ts +7 -6
- package/src/runtime/pending-interactions.ts +12 -0
- package/src/runtime/routes/access-request-decision.ts +215 -0
- package/src/runtime/routes/app-routes.ts +25 -18
- package/src/runtime/routes/approval-routes.ts +18 -47
- package/src/runtime/routes/attachment-routes.ts +15 -41
- package/src/runtime/routes/call-routes.ts +20 -20
- package/src/runtime/routes/channel-delivery-routes.ts +6 -5
- package/src/runtime/routes/contact-routes.ts +4 -9
- package/src/runtime/routes/conversation-attention-routes.ts +2 -1
- package/src/runtime/routes/conversation-routes.ts +26 -57
- package/src/runtime/routes/debug-routes.ts +71 -0
- package/src/runtime/routes/events-routes.ts +3 -2
- package/src/runtime/routes/guardian-approval-interception.ts +221 -0
- package/src/runtime/routes/identity-routes.ts +14 -10
- package/src/runtime/routes/inbound-conversation.ts +3 -2
- package/src/runtime/routes/inbound-message-handler.ts +527 -62
- package/src/runtime/routes/ingress-routes.ts +174 -0
- package/src/runtime/routes/integration-routes.ts +78 -16
- package/src/runtime/routes/pairing-routes.ts +11 -10
- package/src/runtime/routes/secret-routes.ts +10 -18
- package/src/runtime/verification-rate-limiter.ts +83 -0
- package/src/schedule/schedule-store.ts +13 -1
- package/src/schedule/scheduler.ts +1 -1
- package/src/security/secret-ingress.ts +5 -2
- package/src/security/secret-scanner.ts +72 -6
- package/src/subagent/manager.ts +6 -4
- package/src/swarm/plan-validator.ts +4 -1
- package/src/tasks/task-runner.ts +3 -1
- package/src/tools/browser/api-map.ts +9 -6
- package/src/tools/calls/call-start.ts +20 -0
- package/src/tools/executor.ts +50 -568
- package/src/tools/permission-checker.ts +272 -0
- package/src/tools/registry.ts +14 -6
- package/src/tools/reminder/reminder-store.ts +7 -7
- package/src/tools/reminder/reminder.ts +6 -3
- package/src/tools/secret-detection-handler.ts +301 -0
- package/src/tools/subagent/message.ts +1 -1
- package/src/tools/system/voice-config.ts +62 -0
- package/src/tools/tasks/index.ts +3 -3
- package/src/tools/tasks/work-item-list.ts +3 -3
- package/src/tools/tasks/work-item-update.ts +4 -5
- package/src/tools/tool-approval-handler.ts +192 -0
- package/src/tools/tool-manifest.ts +2 -0
- package/src/watcher/watcher-store.ts +9 -9
- package/src/work-items/work-item-runner.ts +9 -6
- /package/src/memory/migrations/{026-embeddings-nullable-vector-json.ts → 026a-embeddings-nullable-vector-json.ts} +0 -0
- /package/src/memory/migrations/{027-guardian-bootstrap-token.ts → 027a-guardian-bootstrap-token.ts} +0 -0
|
@@ -34,11 +34,11 @@ export interface PairingResult {
|
|
|
34
34
|
* Errors are caught and logged — this function never throws so the
|
|
35
35
|
* notification pipeline is not disrupted by pairing failures.
|
|
36
36
|
*/
|
|
37
|
-
export function pairDeliveryWithConversation(
|
|
37
|
+
export async function pairDeliveryWithConversation(
|
|
38
38
|
signal: NotificationSignal,
|
|
39
39
|
channel: NotificationChannel,
|
|
40
40
|
copy: RenderedChannelCopy,
|
|
41
|
-
): PairingResult {
|
|
41
|
+
): Promise<PairingResult> {
|
|
42
42
|
try {
|
|
43
43
|
const strategy = getConversationStrategy(channel as ChannelId);
|
|
44
44
|
|
|
@@ -78,7 +78,7 @@ export function pairDeliveryWithConversation(
|
|
|
78
78
|
: composeThreadSeed(signal, channel, copy);
|
|
79
79
|
// Skip memory indexing — notification audit messages are not conversational
|
|
80
80
|
// memory and should not pollute recall or incur embedding/extraction overhead.
|
|
81
|
-
const message = addMessage(conversation.id, 'assistant', messageContent, undefined, { skipIndexing: true });
|
|
81
|
+
const message = await addMessage(conversation.id, 'assistant', messageContent, undefined, { skipIndexing: true });
|
|
82
82
|
|
|
83
83
|
log.info(
|
|
84
84
|
{
|
|
@@ -19,18 +19,20 @@ import { getLogger } from '../util/logger.js';
|
|
|
19
19
|
import { createDecision } from './decisions-store.js';
|
|
20
20
|
import { getPreferenceSummary } from './preference-summary.js';
|
|
21
21
|
import type { NotificationSignal, RoutingIntent } from './signal.js';
|
|
22
|
-
import type
|
|
22
|
+
import { type ThreadCandidateSet, buildThreadCandidates, serializeCandidatesForPrompt } from './thread-candidates.js';
|
|
23
|
+
import type { NotificationChannel, NotificationDecision, RenderedChannelCopy, ThreadAction } from './types.js';
|
|
23
24
|
|
|
24
25
|
const log = getLogger('notification-decision-engine');
|
|
25
26
|
|
|
26
27
|
const DECISION_TIMEOUT_MS = 15_000;
|
|
27
|
-
const PROMPT_VERSION = '
|
|
28
|
+
const PROMPT_VERSION = 'v4';
|
|
28
29
|
|
|
29
30
|
// ── System prompt ──────────────────────────────────────────────────────
|
|
30
31
|
|
|
31
32
|
function buildSystemPrompt(
|
|
32
33
|
availableChannels: NotificationChannel[],
|
|
33
34
|
preferenceContext?: string,
|
|
35
|
+
candidateContext?: string,
|
|
34
36
|
): string {
|
|
35
37
|
const sections: string[] = [
|
|
36
38
|
`You are a notification routing engine. Given a signal describing an event, decide whether the user should be notified, on which channel(s), and compose the notification copy.`,
|
|
@@ -73,6 +75,26 @@ function buildSystemPrompt(
|
|
|
73
75
|
`- \`threadSeedMessage\` is the opening message in the internal notification thread — it can be richer and more contextual.`,
|
|
74
76
|
` - For vellum (desktop): 2-4 short sentences with useful context and clear next step if action is required.`,
|
|
75
77
|
` - Never dump raw JSON. Include only human-readable context.`,
|
|
78
|
+
``,
|
|
79
|
+
`Thread reuse guidelines:`,
|
|
80
|
+
`- For each selected channel, decide whether to start a new conversation thread or reuse an existing one.`,
|
|
81
|
+
`- Set \`threadActions\` keyed by channel name with \`action\` = "start_new" or "reuse_existing" (with \`conversationId\` from the candidates).`,
|
|
82
|
+
`- Prefer \`reuse_existing\` when the signal is clearly a continuation or update of an existing notification thread (same event type, related context).`,
|
|
83
|
+
`- Prefer \`start_new\` when the signal is a distinct event that deserves its own thread.`,
|
|
84
|
+
`- You may ONLY reuse a conversationId that appears in the provided candidate list. Any other ID will be rejected and downgraded to start_new.`,
|
|
85
|
+
`- When no candidates are available for a channel, always use start_new.`,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (candidateContext) {
|
|
89
|
+
sections.push(
|
|
90
|
+
``,
|
|
91
|
+
`<thread-candidates>`,
|
|
92
|
+
candidateContext,
|
|
93
|
+
`</thread-candidates>`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
sections.push(
|
|
76
98
|
``,
|
|
77
99
|
`You MUST respond using the \`record_notification_decision\` tool. Do not respond with text.`,
|
|
78
100
|
);
|
|
@@ -158,6 +180,30 @@ function buildDecisionTool(availableChannels: NotificationChannel[]) {
|
|
|
158
180
|
]),
|
|
159
181
|
),
|
|
160
182
|
},
|
|
183
|
+
threadActions: {
|
|
184
|
+
type: 'object',
|
|
185
|
+
description: 'Per-channel thread action: start a new thread or reuse an existing candidate. Keyed by channel name.',
|
|
186
|
+
properties: Object.fromEntries(
|
|
187
|
+
availableChannels.map((ch) => [
|
|
188
|
+
ch,
|
|
189
|
+
{
|
|
190
|
+
type: 'object',
|
|
191
|
+
properties: {
|
|
192
|
+
action: {
|
|
193
|
+
type: 'string',
|
|
194
|
+
enum: ['start_new', 'reuse_existing'],
|
|
195
|
+
description: 'Whether to start a new thread or reuse an existing one.',
|
|
196
|
+
},
|
|
197
|
+
conversationId: {
|
|
198
|
+
type: 'string',
|
|
199
|
+
description: 'Required when action is reuse_existing. Must be a conversationId from the provided thread candidates.',
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
required: ['action'],
|
|
203
|
+
},
|
|
204
|
+
]),
|
|
205
|
+
),
|
|
206
|
+
},
|
|
161
207
|
deepLinkTarget: {
|
|
162
208
|
type: 'object',
|
|
163
209
|
description: 'Optional deep link metadata for navigating to the source context',
|
|
@@ -237,6 +283,7 @@ const VALID_CHANNELS = new Set<string>(getDeliverableChannels());
|
|
|
237
283
|
function validateDecisionOutput(
|
|
238
284
|
input: Record<string, unknown>,
|
|
239
285
|
availableChannels: NotificationChannel[],
|
|
286
|
+
candidateSet?: ThreadCandidateSet,
|
|
240
287
|
): NotificationDecision | null {
|
|
241
288
|
if (typeof input.shouldNotify !== 'boolean') return null;
|
|
242
289
|
if (typeof input.reasoningSummary !== 'string') return null;
|
|
@@ -277,6 +324,9 @@ function validateDecisionOutput(
|
|
|
277
324
|
}
|
|
278
325
|
}
|
|
279
326
|
|
|
327
|
+
// Validate threadActions — strictly against the provided candidate set
|
|
328
|
+
const threadActions = validateThreadActions(input.threadActions, validChannels, candidateSet);
|
|
329
|
+
|
|
280
330
|
const deepLinkTarget = input.deepLinkTarget && typeof input.deepLinkTarget === 'object'
|
|
281
331
|
? input.deepLinkTarget as Record<string, unknown>
|
|
282
332
|
: undefined;
|
|
@@ -286,6 +336,7 @@ function validateDecisionOutput(
|
|
|
286
336
|
selectedChannels: validChannels,
|
|
287
337
|
reasoningSummary: input.reasoningSummary,
|
|
288
338
|
renderedCopy,
|
|
339
|
+
threadActions: Object.keys(threadActions).length > 0 ? threadActions : undefined,
|
|
289
340
|
deepLinkTarget,
|
|
290
341
|
dedupeKey: input.dedupeKey,
|
|
291
342
|
confidence,
|
|
@@ -293,6 +344,74 @@ function validateDecisionOutput(
|
|
|
293
344
|
};
|
|
294
345
|
}
|
|
295
346
|
|
|
347
|
+
// ── Thread action validation ───────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Validate and sanitize thread actions from LLM output.
|
|
351
|
+
*
|
|
352
|
+
* - reuse_existing targets are checked against the candidate set; invalid
|
|
353
|
+
* targets are downgraded to start_new with a warning.
|
|
354
|
+
* - Channels not in the selected set are ignored.
|
|
355
|
+
* - Missing actions for selected channels default to start_new (handled
|
|
356
|
+
* downstream, not materialized here to keep the output compact).
|
|
357
|
+
*/
|
|
358
|
+
export function validateThreadActions(
|
|
359
|
+
raw: unknown,
|
|
360
|
+
validChannels: NotificationChannel[],
|
|
361
|
+
candidateSet?: ThreadCandidateSet,
|
|
362
|
+
): Partial<Record<NotificationChannel, ThreadAction>> {
|
|
363
|
+
const result: Partial<Record<NotificationChannel, ThreadAction>> = {};
|
|
364
|
+
|
|
365
|
+
if (!raw || typeof raw !== 'object') return result;
|
|
366
|
+
|
|
367
|
+
const actionsObj = raw as Record<string, unknown>;
|
|
368
|
+
const channelSet = new Set(validChannels);
|
|
369
|
+
|
|
370
|
+
// Build a lookup of valid candidate conversationIds per channel
|
|
371
|
+
const validCandidateIds = new Map<NotificationChannel, Set<string>>();
|
|
372
|
+
if (candidateSet) {
|
|
373
|
+
for (const [ch, candidates] of Object.entries(candidateSet) as [NotificationChannel, { conversationId: string }[]][]) {
|
|
374
|
+
validCandidateIds.set(ch, new Set(candidates.map((c) => c.conversationId)));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
for (const [ch, actionRaw] of Object.entries(actionsObj)) {
|
|
379
|
+
if (!channelSet.has(ch as NotificationChannel)) continue;
|
|
380
|
+
if (!actionRaw || typeof actionRaw !== 'object') continue;
|
|
381
|
+
|
|
382
|
+
const channel = ch as NotificationChannel;
|
|
383
|
+
const action = actionRaw as Record<string, unknown>;
|
|
384
|
+
|
|
385
|
+
if (action.action === 'start_new') {
|
|
386
|
+
result[channel] = { action: 'start_new' };
|
|
387
|
+
} else if (action.action === 'reuse_existing') {
|
|
388
|
+
const conversationId = action.conversationId;
|
|
389
|
+
if (typeof conversationId !== 'string' || !conversationId.trim()) {
|
|
390
|
+
log.warn({ channel }, 'LLM returned reuse_existing without conversationId — downgrading to start_new');
|
|
391
|
+
result[channel] = { action: 'start_new' };
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Strict validation: the conversationId must exist in the candidate set
|
|
396
|
+
const candidateIds = validCandidateIds.get(channel);
|
|
397
|
+
if (!candidateIds || !candidateIds.has(conversationId)) {
|
|
398
|
+
log.warn(
|
|
399
|
+
{ channel, conversationId },
|
|
400
|
+
'LLM returned reuse_existing with conversationId not in candidate set — downgrading to start_new',
|
|
401
|
+
);
|
|
402
|
+
result[channel] = { action: 'start_new' };
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
result[channel] = { action: 'reuse_existing', conversationId };
|
|
407
|
+
}
|
|
408
|
+
// Unknown action values are silently ignored — the channel will default
|
|
409
|
+
// to start_new downstream.
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return result;
|
|
413
|
+
}
|
|
414
|
+
|
|
296
415
|
// ── Core evaluation function ───────────────────────────────────────────
|
|
297
416
|
|
|
298
417
|
export async function evaluateSignal(
|
|
@@ -317,6 +436,16 @@ export async function evaluateSignal(
|
|
|
317
436
|
}
|
|
318
437
|
}
|
|
319
438
|
|
|
439
|
+
// Build thread candidate set for reuse decisions. Wrapped in try/catch
|
|
440
|
+
// so candidate lookup failures do not block the decision path.
|
|
441
|
+
let candidateSet: ThreadCandidateSet | undefined;
|
|
442
|
+
try {
|
|
443
|
+
candidateSet = buildThreadCandidates(availableChannels, signal.assistantId);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
446
|
+
log.warn({ err: errMsg }, 'Failed to build thread candidates, proceeding without candidates');
|
|
447
|
+
}
|
|
448
|
+
|
|
320
449
|
const provider = getConfiguredProvider();
|
|
321
450
|
if (!provider) {
|
|
322
451
|
log.warn('Configured provider unavailable for notification decision, using fallback');
|
|
@@ -327,7 +456,7 @@ export async function evaluateSignal(
|
|
|
327
456
|
|
|
328
457
|
let decision: NotificationDecision;
|
|
329
458
|
try {
|
|
330
|
-
decision = await classifyWithLLM(signal, availableChannels, resolvedPreferenceContext, decisionModelIntent);
|
|
459
|
+
decision = await classifyWithLLM(signal, availableChannels, resolvedPreferenceContext, decisionModelIntent, candidateSet);
|
|
331
460
|
} catch (err) {
|
|
332
461
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
333
462
|
log.warn({ err: errMsg }, 'Notification decision LLM call failed, using fallback');
|
|
@@ -346,11 +475,13 @@ async function classifyWithLLM(
|
|
|
346
475
|
availableChannels: NotificationChannel[],
|
|
347
476
|
preferenceContext: string | undefined,
|
|
348
477
|
modelIntent: ModelIntent,
|
|
478
|
+
candidateSet?: ThreadCandidateSet,
|
|
349
479
|
): Promise<NotificationDecision> {
|
|
350
480
|
const provider = getConfiguredProvider()!;
|
|
351
481
|
const { signal: abortSignal, cleanup } = createTimeout(DECISION_TIMEOUT_MS);
|
|
352
482
|
|
|
353
|
-
const
|
|
483
|
+
const candidateContext = candidateSet ? serializeCandidatesForPrompt(candidateSet) ?? undefined : undefined;
|
|
484
|
+
const systemPrompt = buildSystemPrompt(availableChannels, preferenceContext, candidateContext);
|
|
354
485
|
const prompt = buildUserPrompt(signal);
|
|
355
486
|
const tool = buildDecisionTool(availableChannels);
|
|
356
487
|
|
|
@@ -379,6 +510,7 @@ async function classifyWithLLM(
|
|
|
379
510
|
const validated = validateDecisionOutput(
|
|
380
511
|
toolBlock.input as Record<string, unknown>,
|
|
381
512
|
availableChannels,
|
|
513
|
+
candidateSet,
|
|
382
514
|
);
|
|
383
515
|
if (!validated) {
|
|
384
516
|
log.warn('Invalid notification decision output from LLM, using fallback');
|
|
@@ -432,12 +564,31 @@ export function enforceRoutingIntent(
|
|
|
432
564
|
if (routingIntent === 'multi_channel') {
|
|
433
565
|
// Ensure at least 2 channels when 2+ are connected
|
|
434
566
|
if (connectedChannels.length >= 2 && decision.selectedChannels.length < 2) {
|
|
567
|
+
const connectedSet = new Set<NotificationChannel>(connectedChannels);
|
|
568
|
+
const selectedConnected = decision.selectedChannels.filter((ch) => connectedSet.has(ch));
|
|
569
|
+
const expanded: NotificationChannel[] = [];
|
|
570
|
+
const seen = new Set<NotificationChannel>();
|
|
571
|
+
|
|
572
|
+
// Preserve the decision's selected channels first, then add connected
|
|
573
|
+
// channels until we reach two channels total.
|
|
574
|
+
for (const ch of selectedConnected) {
|
|
575
|
+
if (seen.has(ch)) continue;
|
|
576
|
+
expanded.push(ch);
|
|
577
|
+
seen.add(ch);
|
|
578
|
+
}
|
|
579
|
+
for (const ch of connectedChannels) {
|
|
580
|
+
if (seen.has(ch)) continue;
|
|
581
|
+
expanded.push(ch);
|
|
582
|
+
seen.add(ch);
|
|
583
|
+
if (expanded.length >= 2) break;
|
|
584
|
+
}
|
|
585
|
+
|
|
435
586
|
const enforced = { ...decision };
|
|
436
|
-
enforced.selectedChannels =
|
|
437
|
-
enforced.reasoningSummary = `${decision.reasoningSummary} [routing_intent=multi_channel enforced: expanded to ${
|
|
587
|
+
enforced.selectedChannels = expanded;
|
|
588
|
+
enforced.reasoningSummary = `${decision.reasoningSummary} [routing_intent=multi_channel enforced: expanded to ${expanded.join(', ')}]`;
|
|
438
589
|
log.info(
|
|
439
|
-
{ routingIntent, connectedChannels, originalChannels: decision.selectedChannels },
|
|
440
|
-
'Routing intent enforcement: multi_channel → expanded to
|
|
590
|
+
{ routingIntent, connectedChannels, originalChannels: decision.selectedChannels, enforcedChannels: expanded },
|
|
591
|
+
'Routing intent enforcement: multi_channel → expanded to at least two channels',
|
|
441
592
|
);
|
|
442
593
|
return enforced;
|
|
443
594
|
}
|
|
@@ -451,6 +602,19 @@ export function enforceRoutingIntent(
|
|
|
451
602
|
function persistDecision(signal: NotificationSignal, decision: NotificationDecision): string | undefined {
|
|
452
603
|
try {
|
|
453
604
|
const decisionId = uuid();
|
|
605
|
+
|
|
606
|
+
// Summarize thread actions for the audit trail
|
|
607
|
+
const threadActionSummary: Record<string, string> = {};
|
|
608
|
+
if (decision.threadActions) {
|
|
609
|
+
for (const [ch, ta] of Object.entries(decision.threadActions)) {
|
|
610
|
+
if (ta.action === 'reuse_existing') {
|
|
611
|
+
threadActionSummary[ch] = `reuse:${ta.conversationId}`;
|
|
612
|
+
} else {
|
|
613
|
+
threadActionSummary[ch] = 'start_new';
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
454
618
|
createDecision({
|
|
455
619
|
id: decisionId,
|
|
456
620
|
notificationEventId: signal.signalId,
|
|
@@ -464,6 +628,7 @@ function persistDecision(signal: NotificationSignal, decision: NotificationDecis
|
|
|
464
628
|
dedupeKey: decision.dedupeKey,
|
|
465
629
|
channelCount: decision.selectedChannels.length,
|
|
466
630
|
hasCopy: Object.keys(decision.renderedCopy).length > 0,
|
|
631
|
+
...(Object.keys(threadActionSummary).length > 0 ? { threadActions: threadActionSummary } : {}),
|
|
467
632
|
},
|
|
468
633
|
});
|
|
469
634
|
return decisionId;
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* (decision, channel, destination) are tracked via the `attempt` counter.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { eq } from 'drizzle-orm';
|
|
9
|
+
import { and, eq } from 'drizzle-orm';
|
|
10
10
|
|
|
11
|
-
import { getDb } from '../memory/db.js';
|
|
11
|
+
import { getDb, rawChanges } from '../memory/db.js';
|
|
12
12
|
import { notificationDeliveries } from '../memory/schema.js';
|
|
13
13
|
import type { NotificationChannel, NotificationDeliveryStatus } from './types.js';
|
|
14
14
|
|
|
@@ -131,13 +131,13 @@ export function updateDeliveryStatus(
|
|
|
131
131
|
updates.errorMessage = error.message;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
db
|
|
135
135
|
.update(notificationDeliveries)
|
|
136
136
|
.set(updates)
|
|
137
137
|
.where(eq(notificationDeliveries.id, id))
|
|
138
|
-
.run()
|
|
138
|
+
.run();
|
|
139
139
|
|
|
140
|
-
return (
|
|
140
|
+
return rawChanges() > 0;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
/**
|
|
@@ -171,13 +171,13 @@ export function updateDeliveryClientOutcome(
|
|
|
171
171
|
updates.clientDeliveryError = error.code;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
|
|
174
|
+
db
|
|
175
175
|
.update(notificationDeliveries)
|
|
176
176
|
.set(updates)
|
|
177
177
|
.where(eq(notificationDeliveries.id, deliveryId))
|
|
178
|
-
.run()
|
|
178
|
+
.run();
|
|
179
179
|
|
|
180
|
-
return (
|
|
180
|
+
return rawChanges() > 0;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
/** List all delivery records for a given notification decision. */
|
|
@@ -190,3 +190,22 @@ export function listDeliveries(decisionId: string): NotificationDeliveryRow[] {
|
|
|
190
190
|
.all();
|
|
191
191
|
return rows.map(rowToDelivery);
|
|
192
192
|
}
|
|
193
|
+
|
|
194
|
+
/** Check whether a delivery already exists for a given decision+channel pair. */
|
|
195
|
+
export function findDeliveryByDecisionAndChannel(
|
|
196
|
+
decisionId: string,
|
|
197
|
+
channel: NotificationChannel,
|
|
198
|
+
): NotificationDeliveryRow | undefined {
|
|
199
|
+
const db = getDb();
|
|
200
|
+
const row = db
|
|
201
|
+
.select()
|
|
202
|
+
.from(notificationDeliveries)
|
|
203
|
+
.where(
|
|
204
|
+
and(
|
|
205
|
+
eq(notificationDeliveries.notificationDecisionId, decisionId),
|
|
206
|
+
eq(notificationDeliveries.channel, channel),
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
.get();
|
|
210
|
+
return row ? rowToDelivery(row) : undefined;
|
|
211
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { desc, eq } from 'drizzle-orm';
|
|
11
11
|
import { v4 as uuid } from 'uuid';
|
|
12
12
|
|
|
13
|
-
import { getDb } from '../memory/db.js';
|
|
13
|
+
import { getDb, rawChanges } from '../memory/db.js';
|
|
14
14
|
import { notificationPreferences } from '../memory/schema.js';
|
|
15
15
|
|
|
16
16
|
// ── Row type ────────────────────────────────────────────────────────────
|
|
@@ -107,13 +107,13 @@ export function updatePreference(id: string, params: UpdatePreferenceParams): bo
|
|
|
107
107
|
if (params.appliesWhen !== undefined) updates.appliesWhenJson = JSON.stringify(params.appliesWhen);
|
|
108
108
|
if (params.priority !== undefined) updates.priority = params.priority;
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
db
|
|
111
111
|
.update(notificationPreferences)
|
|
112
112
|
.set(updates)
|
|
113
113
|
.where(eq(notificationPreferences.id, id))
|
|
114
|
-
.run()
|
|
114
|
+
.run();
|
|
115
115
|
|
|
116
|
-
return (
|
|
116
|
+
return rawChanges() > 0;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
// ── Delete ──────────────────────────────────────────────────────────────
|
|
@@ -121,12 +121,12 @@ export function updatePreference(id: string, params: UpdatePreferenceParams): bo
|
|
|
121
121
|
export function deletePreference(id: string): boolean {
|
|
122
122
|
const db = getDb();
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
db
|
|
125
125
|
.delete(notificationPreferences)
|
|
126
126
|
.where(eq(notificationPreferences.id, id))
|
|
127
|
-
.run()
|
|
127
|
+
.run();
|
|
128
128
|
|
|
129
|
-
return (
|
|
129
|
+
return rawChanges() > 0;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
// ── Get by ID ───────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread candidate builder for notification thread reuse.
|
|
3
|
+
*
|
|
4
|
+
* Builds a lightweight candidate set of recent notification conversations
|
|
5
|
+
* per channel that the decision engine can choose to reuse instead of
|
|
6
|
+
* starting a new thread. Includes guardian-specific context (pending
|
|
7
|
+
* unresolved request count) when available.
|
|
8
|
+
*
|
|
9
|
+
* The candidate set is intentionally compact — only the fields the LLM
|
|
10
|
+
* needs for a routing decision, not full conversation contents.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { and, desc, eq, isNotNull } from 'drizzle-orm';
|
|
14
|
+
|
|
15
|
+
import { getDb } from '../memory/db.js';
|
|
16
|
+
import { countPendingByConversation } from '../memory/guardian-approvals.js';
|
|
17
|
+
import { conversations, notificationDeliveries, notificationDecisions, notificationEvents } from '../memory/schema.js';
|
|
18
|
+
import { getLogger } from '../util/logger.js';
|
|
19
|
+
import type { NotificationChannel } from './types.js';
|
|
20
|
+
|
|
21
|
+
const log = getLogger('thread-candidates');
|
|
22
|
+
|
|
23
|
+
/** Maximum number of candidate threads to surface per channel. */
|
|
24
|
+
const MAX_CANDIDATES_PER_CHANNEL = 5;
|
|
25
|
+
|
|
26
|
+
/** Only consider conversations updated within this window (ms). */
|
|
27
|
+
const CANDIDATE_RECENCY_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
28
|
+
|
|
29
|
+
// -- Public types -------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/** Guardian-specific context attached to a thread candidate when available. */
|
|
32
|
+
export interface GuardianCandidateContext {
|
|
33
|
+
/** Number of unresolved (pending) guardian approval requests in this conversation. */
|
|
34
|
+
pendingUnresolvedRequestCount: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A single candidate conversation that the decision engine can select for reuse. */
|
|
38
|
+
export interface ThreadCandidate {
|
|
39
|
+
conversationId: string;
|
|
40
|
+
title: string | null;
|
|
41
|
+
updatedAt: number;
|
|
42
|
+
/** The source event name from the most recent notification delivered to this conversation. */
|
|
43
|
+
latestSourceEventName: string | null;
|
|
44
|
+
channel: NotificationChannel;
|
|
45
|
+
/** Guardian-specific context, present only when there are relevant guardian records. */
|
|
46
|
+
guardianContext?: GuardianCandidateContext;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Candidate set for the decision engine, keyed by channel. */
|
|
50
|
+
export type ThreadCandidateSet = Partial<Record<NotificationChannel, ThreadCandidate[]>>;
|
|
51
|
+
|
|
52
|
+
// -- Core builder -------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the thread candidate set for all selected channels.
|
|
56
|
+
*
|
|
57
|
+
* Queries recent notification-sourced conversations that were delivered
|
|
58
|
+
* to each channel and enriches them with guardian-specific metadata
|
|
59
|
+
* when available.
|
|
60
|
+
*
|
|
61
|
+
* Errors are caught per-channel so a failure in one channel does not
|
|
62
|
+
* block candidates for others.
|
|
63
|
+
*/
|
|
64
|
+
export function buildThreadCandidates(
|
|
65
|
+
channels: NotificationChannel[],
|
|
66
|
+
assistantId: string,
|
|
67
|
+
): ThreadCandidateSet {
|
|
68
|
+
const result: ThreadCandidateSet = {};
|
|
69
|
+
const cutoff = Date.now() - CANDIDATE_RECENCY_WINDOW_MS;
|
|
70
|
+
|
|
71
|
+
for (const channel of channels) {
|
|
72
|
+
try {
|
|
73
|
+
const candidates = buildCandidatesForChannel(channel, assistantId, cutoff);
|
|
74
|
+
if (candidates.length > 0) {
|
|
75
|
+
result[channel] = candidates;
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
79
|
+
log.warn({ err: errMsg, channel }, 'Failed to build thread candidates for channel');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// -- Per-channel query --------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Query recent notification conversations for a given channel.
|
|
90
|
+
*
|
|
91
|
+
* Joins notification_deliveries -> notification_decisions -> notification_events
|
|
92
|
+
* to find conversations that were created by the notification pipeline for
|
|
93
|
+
* this channel, then enriches with guardian context.
|
|
94
|
+
*/
|
|
95
|
+
function buildCandidatesForChannel(
|
|
96
|
+
channel: NotificationChannel,
|
|
97
|
+
assistantId: string,
|
|
98
|
+
cutoffMs: number,
|
|
99
|
+
): ThreadCandidate[] {
|
|
100
|
+
const db = getDb();
|
|
101
|
+
|
|
102
|
+
// Find recent notification deliveries for this channel that have a
|
|
103
|
+
// conversationId and were successfully sent.
|
|
104
|
+
const rows = db
|
|
105
|
+
.select({
|
|
106
|
+
conversationId: notificationDeliveries.conversationId,
|
|
107
|
+
channel: notificationDeliveries.channel,
|
|
108
|
+
deliverySentAt: notificationDeliveries.sentAt,
|
|
109
|
+
sourceEventName: notificationEvents.sourceEventName,
|
|
110
|
+
convTitle: conversations.title,
|
|
111
|
+
convUpdatedAt: conversations.updatedAt,
|
|
112
|
+
})
|
|
113
|
+
.from(notificationDeliveries)
|
|
114
|
+
.innerJoin(
|
|
115
|
+
notificationDecisions,
|
|
116
|
+
eq(notificationDeliveries.notificationDecisionId, notificationDecisions.id),
|
|
117
|
+
)
|
|
118
|
+
.innerJoin(
|
|
119
|
+
notificationEvents,
|
|
120
|
+
eq(notificationDecisions.notificationEventId, notificationEvents.id),
|
|
121
|
+
)
|
|
122
|
+
.innerJoin(
|
|
123
|
+
conversations,
|
|
124
|
+
eq(notificationDeliveries.conversationId, conversations.id),
|
|
125
|
+
)
|
|
126
|
+
.where(
|
|
127
|
+
and(
|
|
128
|
+
eq(notificationDeliveries.channel, channel),
|
|
129
|
+
eq(notificationDeliveries.assistantId, assistantId),
|
|
130
|
+
eq(notificationDeliveries.status, 'sent'),
|
|
131
|
+
isNotNull(notificationDeliveries.conversationId),
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
.orderBy(desc(notificationDeliveries.sentAt))
|
|
135
|
+
.limit(MAX_CANDIDATES_PER_CHANNEL * 3) // over-fetch to allow deduplication
|
|
136
|
+
.all();
|
|
137
|
+
|
|
138
|
+
// Deduplicate by conversationId (keep the most recent delivery per conversation)
|
|
139
|
+
const seen = new Set<string>();
|
|
140
|
+
const candidates: ThreadCandidate[] = [];
|
|
141
|
+
|
|
142
|
+
for (const row of rows) {
|
|
143
|
+
if (!row.conversationId) continue;
|
|
144
|
+
if (seen.has(row.conversationId)) continue;
|
|
145
|
+
|
|
146
|
+
// Apply recency filter on the conversation's updatedAt
|
|
147
|
+
if (row.convUpdatedAt < cutoffMs) continue;
|
|
148
|
+
|
|
149
|
+
seen.add(row.conversationId);
|
|
150
|
+
|
|
151
|
+
const candidate: ThreadCandidate = {
|
|
152
|
+
conversationId: row.conversationId,
|
|
153
|
+
title: row.convTitle,
|
|
154
|
+
updatedAt: row.convUpdatedAt,
|
|
155
|
+
latestSourceEventName: row.sourceEventName ?? null,
|
|
156
|
+
channel: channel,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Enrich with guardian context
|
|
160
|
+
const guardianContext = buildGuardianContext(row.conversationId, assistantId);
|
|
161
|
+
if (guardianContext) {
|
|
162
|
+
candidate.guardianContext = guardianContext;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
candidates.push(candidate);
|
|
166
|
+
|
|
167
|
+
if (candidates.length >= MAX_CANDIDATES_PER_CHANNEL) break;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return candidates;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// -- Guardian context enrichment ----------------------------------------------
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Build guardian-specific context for a candidate conversation.
|
|
177
|
+
* Returns null when there is no guardian-relevant data.
|
|
178
|
+
*/
|
|
179
|
+
function buildGuardianContext(
|
|
180
|
+
conversationId: string,
|
|
181
|
+
assistantId: string,
|
|
182
|
+
): GuardianCandidateContext | null {
|
|
183
|
+
try {
|
|
184
|
+
const pendingCount = countPendingByConversation(conversationId, assistantId);
|
|
185
|
+
if (pendingCount > 0) {
|
|
186
|
+
return { pendingUnresolvedRequestCount: pendingCount };
|
|
187
|
+
}
|
|
188
|
+
} catch (err) {
|
|
189
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
190
|
+
log.warn({ err: errMsg, conversationId }, 'Failed to query guardian context for candidate');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// -- Prompt serialization -----------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Serialize a thread candidate set into a compact text block suitable for
|
|
200
|
+
* injection into the decision engine's user prompt.
|
|
201
|
+
*
|
|
202
|
+
* Designed to be token-efficient while giving the LLM enough context
|
|
203
|
+
* to make a reuse decision.
|
|
204
|
+
*/
|
|
205
|
+
export function serializeCandidatesForPrompt(candidateSet: ThreadCandidateSet): string | null {
|
|
206
|
+
const channelEntries = Object.entries(candidateSet) as [NotificationChannel, ThreadCandidate[]][];
|
|
207
|
+
if (channelEntries.length === 0) return null;
|
|
208
|
+
|
|
209
|
+
const sections: string[] = [];
|
|
210
|
+
|
|
211
|
+
for (const [channel, candidates] of channelEntries) {
|
|
212
|
+
if (candidates.length === 0) continue;
|
|
213
|
+
|
|
214
|
+
const lines: string[] = [`Channel: ${channel}`];
|
|
215
|
+
for (const c of candidates) {
|
|
216
|
+
const parts: string[] = [
|
|
217
|
+
` - id=${c.conversationId}`,
|
|
218
|
+
`title="${c.title ?? '(untitled)'}"`,
|
|
219
|
+
`updated=${new Date(c.updatedAt).toISOString()}`,
|
|
220
|
+
];
|
|
221
|
+
if (c.latestSourceEventName) {
|
|
222
|
+
parts.push(`lastEvent="${c.latestSourceEventName}"`);
|
|
223
|
+
}
|
|
224
|
+
if (c.guardianContext) {
|
|
225
|
+
parts.push(`pendingRequests=${c.guardianContext.pendingUnresolvedRequestCount}`);
|
|
226
|
+
}
|
|
227
|
+
lines.push(parts.join(' '));
|
|
228
|
+
}
|
|
229
|
+
sections.push(lines.join('\n'));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (sections.length === 0) return null;
|
|
233
|
+
return sections.join('\n\n');
|
|
234
|
+
}
|
|
@@ -79,12 +79,30 @@ export interface RenderedChannelCopy {
|
|
|
79
79
|
threadSeedMessage?: string;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// -- Thread action types ------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/** Start a new conversation thread for the notification delivery. */
|
|
85
|
+
export interface ThreadActionStartNew {
|
|
86
|
+
action: 'start_new';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Reuse an existing conversation thread identified by conversationId. */
|
|
90
|
+
export interface ThreadActionReuseExisting {
|
|
91
|
+
action: 'reuse_existing';
|
|
92
|
+
conversationId: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Per-channel thread action — either start a new thread or reuse an existing one. */
|
|
96
|
+
export type ThreadAction = ThreadActionStartNew | ThreadActionReuseExisting;
|
|
97
|
+
|
|
82
98
|
/** Output produced by the notification decision engine for a given signal. */
|
|
83
99
|
export interface NotificationDecision {
|
|
84
100
|
shouldNotify: boolean;
|
|
85
101
|
selectedChannels: NotificationChannel[];
|
|
86
102
|
reasoningSummary: string;
|
|
87
103
|
renderedCopy: Partial<Record<NotificationChannel, RenderedChannelCopy>>;
|
|
104
|
+
/** Per-channel thread action. When absent for a channel, defaults to start_new. */
|
|
105
|
+
threadActions?: Partial<Record<NotificationChannel, ThreadAction>>;
|
|
88
106
|
deepLinkTarget?: Record<string, unknown>;
|
|
89
107
|
dedupeKey: string;
|
|
90
108
|
confidence: number;
|