@vellumai/assistant 0.3.16 → 0.3.19

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.
Files changed (114) hide show
  1. package/ARCHITECTURE.md +74 -13
  2. package/README.md +6 -0
  3. package/docs/architecture/http-token-refresh.md +23 -1
  4. package/docs/architecture/security.md +80 -0
  5. package/package.json +1 -1
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
  7. package/src/__tests__/access-request-decision.test.ts +4 -7
  8. package/src/__tests__/call-controller.test.ts +170 -0
  9. package/src/__tests__/channel-guardian.test.ts +3 -1
  10. package/src/__tests__/checker.test.ts +139 -48
  11. package/src/__tests__/config-watcher.test.ts +11 -13
  12. package/src/__tests__/conversation-pairing.test.ts +103 -3
  13. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
  14. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
  15. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
  16. package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
  17. package/src/__tests__/guardian-action-store.test.ts +182 -0
  18. package/src/__tests__/guardian-dispatch.test.ts +180 -0
  19. package/src/__tests__/guardian-grant-minting.test.ts +543 -0
  20. package/src/__tests__/ipc-snapshot.test.ts +22 -0
  21. package/src/__tests__/non-member-access-request.test.ts +1 -2
  22. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  23. package/src/__tests__/notification-decision-strategy.test.ts +2 -1
  24. package/src/__tests__/notification-deep-link.test.ts +44 -1
  25. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  26. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  27. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  28. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  29. package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
  30. package/src/__tests__/slack-channel-config.test.ts +3 -3
  31. package/src/__tests__/trust-store.test.ts +23 -21
  32. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
  33. package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
  34. package/src/__tests__/trusted-contact-verification.test.ts +9 -9
  35. package/src/__tests__/update-bulletin-state.test.ts +1 -1
  36. package/src/__tests__/update-bulletin.test.ts +66 -3
  37. package/src/__tests__/update-template-contract.test.ts +6 -11
  38. package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
  39. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  40. package/src/calls/call-controller.ts +150 -8
  41. package/src/calls/call-domain.ts +12 -0
  42. package/src/calls/guardian-action-sweep.ts +1 -1
  43. package/src/calls/guardian-dispatch.ts +16 -0
  44. package/src/calls/relay-server.ts +13 -0
  45. package/src/calls/voice-session-bridge.ts +46 -5
  46. package/src/cli/core-commands.ts +41 -1
  47. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  48. package/src/config/schema.ts +6 -0
  49. package/src/config/skills-schema.ts +27 -0
  50. package/src/config/templates/UPDATES.md +5 -6
  51. package/src/config/update-bulletin-format.ts +2 -0
  52. package/src/config/update-bulletin-state.ts +1 -1
  53. package/src/config/update-bulletin-template-path.ts +6 -0
  54. package/src/config/update-bulletin.ts +21 -6
  55. package/src/daemon/config-watcher.ts +3 -2
  56. package/src/daemon/daemon-control.ts +64 -10
  57. package/src/daemon/handlers/config-channels.ts +18 -0
  58. package/src/daemon/handlers/config-slack-channel.ts +1 -1
  59. package/src/daemon/handlers/identity.ts +45 -25
  60. package/src/daemon/handlers/sessions.ts +1 -1
  61. package/src/daemon/handlers/skills.ts +45 -2
  62. package/src/daemon/ipc-contract/sessions.ts +1 -1
  63. package/src/daemon/ipc-contract/skills.ts +1 -0
  64. package/src/daemon/ipc-contract/workspace.ts +12 -1
  65. package/src/daemon/ipc-contract-inventory.json +1 -0
  66. package/src/daemon/lifecycle.ts +8 -0
  67. package/src/daemon/server.ts +25 -3
  68. package/src/daemon/session-process.ts +450 -184
  69. package/src/daemon/tls-certs.ts +17 -12
  70. package/src/daemon/tool-side-effects.ts +1 -1
  71. package/src/memory/channel-delivery-store.ts +18 -20
  72. package/src/memory/channel-guardian-store.ts +39 -42
  73. package/src/memory/conversation-crud.ts +2 -2
  74. package/src/memory/conversation-queries.ts +2 -2
  75. package/src/memory/conversation-store.ts +24 -25
  76. package/src/memory/db-init.ts +17 -1
  77. package/src/memory/embedding-local.ts +16 -7
  78. package/src/memory/fts-reconciler.ts +41 -26
  79. package/src/memory/guardian-action-store.ts +65 -7
  80. package/src/memory/guardian-verification.ts +1 -0
  81. package/src/memory/jobs-worker.ts +2 -2
  82. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  83. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  84. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  85. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  86. package/src/memory/migrations/index.ts +6 -2
  87. package/src/memory/schema-migration.ts +1 -0
  88. package/src/memory/schema.ts +36 -1
  89. package/src/memory/scoped-approval-grants.ts +509 -0
  90. package/src/memory/search/semantic.ts +3 -3
  91. package/src/notifications/README.md +158 -17
  92. package/src/notifications/broadcaster.ts +68 -50
  93. package/src/notifications/conversation-pairing.ts +96 -18
  94. package/src/notifications/decision-engine.ts +6 -3
  95. package/src/notifications/deliveries-store.ts +12 -0
  96. package/src/notifications/emit-signal.ts +1 -0
  97. package/src/notifications/thread-candidates.ts +60 -25
  98. package/src/notifications/types.ts +2 -1
  99. package/src/permissions/checker.ts +28 -16
  100. package/src/permissions/defaults.ts +14 -4
  101. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  102. package/src/runtime/guardian-action-grant-minter.ts +97 -0
  103. package/src/runtime/http-server.ts +11 -11
  104. package/src/runtime/routes/access-request-decision.ts +1 -1
  105. package/src/runtime/routes/debug-routes.ts +4 -4
  106. package/src/runtime/routes/guardian-approval-interception.ts +120 -4
  107. package/src/runtime/routes/inbound-message-handler.ts +100 -33
  108. package/src/runtime/routes/integration-routes.ts +2 -2
  109. package/src/security/tool-approval-digest.ts +67 -0
  110. package/src/skills/remote-skill-policy.ts +131 -0
  111. package/src/tools/permission-checker.ts +1 -2
  112. package/src/tools/secret-detection-handler.ts +1 -1
  113. package/src/tools/system/voice-config.ts +1 -1
  114. package/src/version.ts +29 -2
@@ -5,22 +5,51 @@ Signal-driven notification architecture where producers emit free-form events an
5
5
  ## Lifecycle
6
6
 
7
7
  ```
8
- Producer → NotificationSignal → Decision Engine (LLM) → Deterministic Checks → Broadcaster → Conversation Pairing → Adapters → Delivery
9
- ↑ ↓
10
- Preference Summary notification_thread_created IPC
8
+ Producer → NotificationSignal → Candidate Generation → Decision Engine (LLM) → Deterministic Checks → Broadcaster → Conversation Pairing → Adapters → Delivery
9
+ ↑ ↓
10
+ Preference Summary notification_thread_created IPC
11
+ Thread Candidates (creation-only — not emitted on reuse)
11
12
  ```
12
13
 
13
14
  ### 1. Signal
14
15
 
15
16
  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
 
17
- ### 2. Decision
18
+ ### 2. Candidate Generation
18
19
 
19
- The decision engine (`decision-engine.ts`) sends the signal to an LLM (configured via `notifications.decisionModelIntent`) 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
+ Before the decision engine runs, the system builds a **thread candidate set** per channel (`thread-candidates.ts`). This is a compact snapshot of recent notification-sourced conversations that the decision engine can choose to reuse instead of starting a new thread.
20
21
 
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
+ **How candidates are generated:**
22
23
 
23
- ### 3. Deterministic Checks
24
+ - For each selected channel, the system queries `notification_deliveries` joined with `notification_decisions` and `notification_events` to find conversations that were created by the notification pipeline within the last 24 hours.
25
+ - Up to 5 candidates per channel are returned, deduplicated by conversation ID, most-recent first.
26
+ - Each candidate includes: `conversationId`, `title`, `updatedAt`, `latestSourceEventName`, and `channel`.
27
+ - **Guardian context enrichment**: When candidates exist, a batch query counts pending (unresolved) guardian approval requests per conversation. Candidates with `pendingUnresolvedRequestCount > 0` carry a `guardianContext` field so the LLM can make informed reuse decisions for threads with active guardian questions.
28
+ - **Candidate-affinity hints**: Guardian dispatch (`guardian-dispatch.ts`) includes `activeGuardianRequestCount` in the signal's `contextPayload`. When multiple guardian questions arise in the same call session, this hint nudges the decision engine toward reusing the existing thread rather than creating a new one for each question.
29
+
30
+ The candidate set is serialized into a compact `<thread-candidates>` block in the decision engine's system prompt. Candidate generation is wrapped in try/catch — a failure does not block the decision path; the engine simply proceeds without candidates (all channels default to `start_new`).
31
+
32
+ ### 3. Decision
33
+
34
+ The decision engine (`decision-engine.ts`) sends the signal to an LLM (configured via `notifications.decisionModelIntent`) along with available channels, the user's preference summary, and the thread candidate set. The LLM responds with a structured decision: whether to notify, which channels, rendered copy per channel, a deduplication key, and **per-channel thread actions**.
35
+
36
+ **Thread actions:** For each selected channel, the LLM decides:
37
+
38
+ - `start_new` — create a fresh conversation thread for this delivery.
39
+ - `reuse_existing` — append to an existing candidate thread (must provide a `conversationId` from the candidate set).
40
+
41
+ The LLM is guided to prefer `reuse_existing` when the signal is a continuation or update of an existing notification thread (same event type, related context), and `start_new` when the signal is a distinct event deserving its own thread.
42
+
43
+ **Validation and fallback:** Thread actions are strictly validated against the candidate set (`validateThreadActions` in `decision-engine.ts`):
44
+
45
+ - A `reuse_existing` action with an empty or missing `conversationId` is downgraded to `start_new` with a warning.
46
+ - A `reuse_existing` action referencing a conversation ID not in the candidate set is downgraded to `start_new` with a warning.
47
+ - Unknown action values are silently ignored; the channel defaults to `start_new` downstream.
48
+ - Channels with no thread action in the decision output default to `start_new`.
49
+
50
+ 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. The fallback path does not produce thread actions (all channels use `start_new`).
51
+
52
+ ### 4. Deterministic Checks
24
53
 
25
54
  Hard invariants that the LLM cannot override (`deterministic-checks.ts`):
26
55
 
@@ -29,11 +58,11 @@ Hard invariants that the LLM cannot override (`deterministic-checks.ts`):
29
58
  - **Channel availability** -- at least one selected channel must be connected
30
59
  - **Deduplication** -- same `dedupeKey` within the dedupe window (1 hour default) is suppressed
31
60
 
32
- ### 4. Dispatch
61
+ ### 5. Dispatch
33
62
 
34
63
  `runtime-dispatch.ts` handles two early-exit cases (shouldNotify=false, no channels), then delegates to the broadcaster.
35
64
 
36
- ### 5. Broadcast, Conversation Pairing, and Delivery
65
+ ### 6. Broadcast, Conversation Pairing, and Delivery
37
66
 
38
67
  The broadcaster (`broadcaster.ts`) iterates over selected channels (vellum first for fast IPC push), resolves destinations via `destination-resolver.ts`, pairs each delivery with a conversation via `conversation-pairing.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` with `conversation_id`, `message_id`, and `conversation_strategy` columns.
39
68
 
@@ -73,9 +102,19 @@ Each policy defines:
73
102
 
74
103
  ## Conversation Pairing Invariant
75
104
 
76
- **Every notification delivery gets a conversation.** Before the adapter sends a notification, `pairDeliveryWithConversation()` (in `conversation-pairing.ts`) materializes a conversation and seed message based on the channel's conversation strategy:
105
+ **Every notification delivery gets a conversation.** Before the adapter sends a notification, `pairDeliveryWithConversation()` (in `conversation-pairing.ts`) materializes a conversation and seed message based on the channel's conversation strategy and the decision engine's per-channel thread action:
106
+
107
+ ### Thread Reuse Path (`reuse_existing`)
108
+
109
+ When the decision engine selects `reuse_existing` for a channel with a valid candidate `conversationId`:
77
110
 
78
- - **`start_new_conversation`**: Creates a new conversation with `threadType: 'standard'` and `source: 'notification'`, plus an assistant message containing the thread seed. Memory indexing is skipped on the seed message to prevent notification copy from polluting conversational recall.
111
+ 1. The pairing function looks up the target conversation.
112
+ 2. If the conversation exists and has `source: 'notification'`, the seed message is **appended** to the existing thread (not a new conversation). The result has `createdNewConversation: false`.
113
+ 3. If the target is invalid (does not exist, or has a different `source`), the function falls back to creating a new conversation and sets `threadDecisionFallbackUsed: true` on the result. A warning is logged with the invalid target details.
114
+
115
+ ### New Thread Path (`start_new` / default)
116
+
117
+ - **`start_new_conversation`**: Creates a new conversation with `threadType: 'standard'` and `source: 'notification'`, plus an assistant message containing the thread seed. Memory indexing is skipped on the seed message to prevent notification copy from polluting conversational recall. The result has `createdNewConversation: true`.
79
118
  - **`continue_existing_conversation`**: Currently materializes a background audit conversation per delivery (true continuation via binding key lookup is planned for a future PR). The audit trail records the intended strategy without adding visible sidebar threads.
80
119
  - **`not_deliverable`**: Returns `{ conversationId: null, messageId: null }`.
81
120
 
@@ -133,9 +172,22 @@ Take out the trash
133
172
  Reminder. Take out the trash. Action required.
134
173
  ```
135
174
 
136
- ## Thread Surfacing via `notification_thread_created` IPC
175
+ ## Thread Surfacing via `notification_thread_created` IPC (Creation-Only)
137
176
 
138
- When a vellum notification thread is paired with a conversation (strategy `start_new_conversation`), the broadcaster emits a `notification_thread_created` IPC event **immediately**, before waiting for slower channel deliveries (e.g. Telegram). This avoids a race where a slow Telegram delivery delays the IPC push past the macOS deep-link retry window.
177
+ The `notification_thread_created` IPC event is emitted **only when a brand-new conversation is actually created** by the broadcaster. Reused threads do not trigger this event the macOS/iOS client already knows about the conversation from the original creation.
178
+
179
+ This is enforced in `broadcaster.ts` by gating the IPC emission on `pairing.createdNewConversation === true`:
180
+
181
+ ```ts
182
+ // Emit notification_thread_created only when a NEW conversation was
183
+ // actually created. Reusing an existing thread should not fire the IPC
184
+ // event — the client already knows about the conversation.
185
+ if (pairing.createdNewConversation && pairing.strategy === 'start_new_conversation') {
186
+ // ... emit IPC event
187
+ }
188
+ ```
189
+
190
+ When a vellum notification thread **is** newly created (strategy `start_new_conversation`), the broadcaster emits the IPC event **immediately**, before waiting for slower channel deliveries (e.g. Telegram). This avoids a race where a slow Telegram delivery delays the IPC push past the macOS deep-link retry window.
139
191
 
140
192
  The IPC event payload:
141
193
 
@@ -152,7 +204,12 @@ The macOS/iOS client listens for this event and surfaces the thread in the sideb
152
204
 
153
205
  ### Per-Dispatch Thread Callback
154
206
 
155
- `emitNotificationSignal()` accepts an optional `onThreadCreated` callback. This lets producers run domain side effects (for example, creating cross-channel guardian delivery rows) as soon as vellum pairing occurs, without introducing a second thread-creation path.
207
+ `emitNotificationSignal()` accepts an optional `onThreadCreated` callback (`options.onThreadCreated`). This lets producers run domain side effects (for example, creating cross-channel guardian delivery rows) as soon as vellum pairing occurs, without introducing a second thread-creation path.
208
+
209
+ **Important distinction between the two callbacks:**
210
+
211
+ - **Per-dispatch `options.onThreadCreated`**: Fires for **both** new and reused vellum conversation pairings. Callers like `dispatchGuardianQuestion` rely on this to create delivery bookkeeping rows before `emitNotificationSignal()` returns, regardless of whether the conversation was newly created or reused.
212
+ - **Class-level `this.onThreadCreated` (IPC)**: Fires **only** when a brand-new conversation is created (`createdNewConversation === true && strategy === 'start_new_conversation'`). This emits the `notification_thread_created` IPC event so macOS/iOS clients surface the new thread in the sidebar. Reused threads do not trigger this event because the client already knows about the conversation.
156
213
 
157
214
  ## Reminder Routing Metadata and Trigger-Time Enforcement
158
215
 
@@ -248,8 +305,8 @@ Connected channels are resolved at signal emission time by `getConnectedChannels
248
305
  The system uses a single conversation materialization path for **all** notifications -- there are no legacy bypass paths or dual-broadcast mechanisms. Every notification, including guardian questions and ingress escalation alerts, flows through `emitNotificationSignal()`:
249
306
 
250
307
  1. `emitNotificationSignal()` evaluates the signal and dispatches to channels.
251
- 2. `NotificationBroadcaster` pairs each delivery with a conversation via `pairDeliveryWithConversation()`.
252
- 3. For vellum deliveries, the broadcaster merges `conversationId` into `deepLinkMetadata` and emits `notification_thread_created`.
308
+ 2. `NotificationBroadcaster` pairs each delivery with a conversation via `pairDeliveryWithConversation()`, executing the per-channel thread action (start_new or reuse_existing).
309
+ 3. For vellum deliveries, the broadcaster merges `conversationId` into `deepLinkMetadata` and emits `notification_thread_created` only when a new conversation was created (not on reuse).
253
310
 
254
311
  Guardian dispatch follows this same path and uses the optional `onThreadCreated` callback to attach guardian-delivery bookkeeping to the canonical vellum conversation.
255
312
 
@@ -257,6 +314,89 @@ Guardian dispatch follows this same path and uses the optional `onThreadCreated`
257
314
 
258
315
  For notification flows that create conversations, the conversation must be created **before** the IPC event is emitted. This ensures the macOS client can immediately fetch the conversation contents when it receives the thread-created event.
259
316
 
317
+ ## Thread Decision Audit Trail
318
+
319
+ Every thread routing decision is persisted for observability:
320
+
321
+ ### Decision-Level Audit (`notification_decisions`)
322
+
323
+ When the decision is persisted, a `threadActions` summary is included in `validationResults`:
324
+
325
+ ```json
326
+ {
327
+ "threadActions": {
328
+ "vellum": "start_new",
329
+ "telegram": "reuse:conv-abc-123"
330
+ }
331
+ }
332
+ ```
333
+
334
+ ### Delivery-Level Audit (`notification_deliveries`)
335
+
336
+ Three columns on `notification_deliveries` record the per-channel thread decision:
337
+
338
+ | Column | Type | Description |
339
+ |--------|------|-------------|
340
+ | `thread_action` | TEXT | `'start_new'` or `'reuse_existing'` — what the model decided |
341
+ | `thread_target_conversation_id` | TEXT | The candidate `conversationId` when action is `reuse_existing` |
342
+ | `thread_decision_fallback_used` | INTEGER | `1` if `reuse_existing` was attempted but the target was invalid, so a new conversation was created instead |
343
+
344
+ ### Query Examples
345
+
346
+ ```sql
347
+ -- Thread reuse decisions with fallback tracking
348
+ SELECT d.channel, d.thread_action, d.thread_target_conversation_id,
349
+ d.thread_decision_fallback_used, d.conversation_id
350
+ FROM notification_deliveries d
351
+ WHERE d.thread_action IS NOT NULL
352
+ ORDER BY d.created_at DESC
353
+ LIMIT 20;
354
+
355
+ -- Reuse failures (model hallucinated an invalid conversation ID)
356
+ SELECT d.channel, d.thread_target_conversation_id, d.conversation_id
357
+ FROM notification_deliveries d
358
+ WHERE d.thread_decision_fallback_used = 1
359
+ ORDER BY d.created_at DESC;
360
+ ```
361
+
362
+ ## Guardian Multi-Request Disambiguation in Reused Threads
363
+
364
+ When the decision engine routes multiple guardian questions to the **same** conversation (via `reuse_existing`), those questions share a single thread. The guardian needs a way to indicate which question they are answering. This is handled via **request-code disambiguation**.
365
+
366
+ ### How Request Codes Work
367
+
368
+ Each `guardian_action_request` is assigned a unique 6-character hex code (e.g. `A1B2C3`) at creation time by `generateRequestCode()` in `guardian-action-store.ts`. The code is included in the notification copy delivered to the guardian.
369
+
370
+ ### Disambiguation Flow
371
+
372
+ The disambiguation logic is identical on all channels — mac/vellum (`session-process.ts`), Telegram, and SMS (`inbound-message-handler.ts`):
373
+
374
+ 1. **Single pending delivery in the thread**: The guardian's reply is matched to the sole pending request automatically. No request code prefix is needed. This is the **single-match fast path**.
375
+
376
+ 2. **Multiple pending deliveries in the thread**: The guardian must prefix their reply with the request code of the question they are answering (e.g. `A1B2C3 yes, allow it`). Matching is case-insensitive.
377
+
378
+ 3. **No code match**: If the guardian's reply does not start with any active request code, a **disambiguation message** is sent back listing all active request codes so the guardian can retry with the correct prefix.
379
+
380
+ ### Channel Parity
381
+
382
+ The disambiguation invariant is enforced identically across:
383
+
384
+ - **Mac/Vellum** (`session-process.ts`): Intercepts user messages in conversations with pending guardian action deliveries before the agent loop runs.
385
+ - **Telegram** (`inbound-message-handler.ts`): Intercepts inbound messages matched to conversations with pending guardian action deliveries.
386
+ - **SMS** (`inbound-message-handler.ts`): Same codepath as Telegram.
387
+
388
+ All three paths use the same pattern: look up pending deliveries by conversation, apply single-match fast path or request-code prefix matching, and send disambiguation messages via the guardian action message composer when ambiguous.
389
+
390
+ ### Disambiguation Message Generation
391
+
392
+ All disambiguation messages are generated through `composeGuardianActionMessageGenerative()` in `guardian-action-message-composer.ts`, which uses a 2-tier priority chain (LLM generator with deterministic fallback). Three disambiguation scenarios exist:
393
+
394
+ | Scenario | When triggered |
395
+ |----------|---------------|
396
+ | `guardian_disambiguation` | Multiple pending approval requests in a thread |
397
+ | `guardian_expired_disambiguation` | Multiple expired requests with late replies |
398
+ | `guardian_followup_disambiguation` | Multiple follow-up deliveries awaiting guardian action |
399
+
260
400
  ## Key Files
261
401
 
262
402
  | File | Purpose |
@@ -264,7 +404,8 @@ For notification flows that create conversations, the conversation must be creat
264
404
  | `../channels/config.ts` | Channel policy registry -- single source of truth for per-channel notification behavior |
265
405
  | `emit-signal.ts` | Single entry point for producers; orchestrates the full pipeline |
266
406
  | `signal.ts` | `NotificationSignal` and `AttentionHints` type definitions |
267
- | `types.ts` | Channel adapter interfaces, delivery types, decision output contract |
407
+ | `types.ts` | Channel adapter interfaces, delivery types, decision output contract, `ThreadAction` union |
408
+ | `thread-candidates.ts` | Builds per-channel candidate set of recent notification conversations for the decision engine |
268
409
  | `conversation-pairing.ts` | Materializes conversation + message per delivery based on channel strategy |
269
410
  | `decision-engine.ts` | LLM-based routing with forced tool_choice; deterministic fallback |
270
411
  | `deterministic-checks.ts` | Pre-send gate checks (dedupe, source-active, channel availability) |
@@ -24,6 +24,7 @@ import type {
24
24
  NotificationDecision,
25
25
  NotificationDeliveryResult,
26
26
  RenderedChannelCopy,
27
+ ThreadAction,
27
28
  } from './types.js';
28
29
 
29
30
  const log = getLogger('notif-broadcaster');
@@ -125,8 +126,36 @@ export class NotificationBroadcaster {
125
126
  copy = fallbackCopy[channel] ?? { title: 'Notification', body: signal.sourceEventName };
126
127
  }
127
128
 
128
- // Pair the delivery with a conversation before sending
129
- const pairing = await pairDeliveryWithConversation(signal, channel, copy);
129
+ // Resolve the per-channel thread action from the decision (default: start_new)
130
+ const threadAction: ThreadAction | undefined = decision.threadActions?.[channel];
131
+
132
+ // Check for duplicate delivery BEFORE pairing to avoid side effects
133
+ // (e.g. appending seed messages to existing threads) on retry paths
134
+ // where a delivery row already exists.
135
+ const persistedDecisionId = decision.persistedDecisionId;
136
+ const hasPersistedDecision = typeof persistedDecisionId === 'string';
137
+ if (hasPersistedDecision) {
138
+ const existingDelivery = findDeliveryByDecisionAndChannel(persistedDecisionId, channel);
139
+ if (existingDelivery) {
140
+ log.info(
141
+ { channel, signalId: signal.signalId, existingDeliveryId: existingDelivery.id },
142
+ 'Delivery already exists for this decision+channel — skipping duplicate',
143
+ );
144
+ results.push({
145
+ channel,
146
+ destination: destination.endpoint ?? channel,
147
+ status: 'skipped',
148
+ errorMessage: 'Duplicate delivery skipped',
149
+ conversationId: existingDelivery.conversationId ?? undefined,
150
+ messageId: existingDelivery.messageId ?? undefined,
151
+ conversationStrategy: existingDelivery.conversationStrategy ?? undefined,
152
+ });
153
+ continue;
154
+ }
155
+ }
156
+
157
+ // Pair the delivery with a conversation before sending, passing the thread action
158
+ const pairing = await pairDeliveryWithConversation(signal, channel, copy, { threadAction });
130
159
 
131
160
  // For the vellum channel, merge the conversationId into deep-link metadata
132
161
  // so the macOS/iOS client can navigate directly to the notification thread.
@@ -134,20 +163,36 @@ export class NotificationBroadcaster {
134
163
  if (channel === 'vellum' && pairing.conversationId) {
135
164
  deepLinkTarget = { ...deepLinkTarget, conversationId: pairing.conversationId };
136
165
 
137
- // Emit notification_thread_created immediately when the vellum
138
- // conversation is paired, BEFORE waiting for adapter send or other
139
- // channel deliveries. This avoids a race where slow Telegram delivery
140
- // delays the IPC push past the macOS deep-link retry window.
141
- if (pairing.strategy === 'start_new_conversation') {
142
- const threadTitle =
143
- copy.threadTitle ??
144
- copy.title ??
145
- signal.sourceEventName;
146
- const info: ThreadCreatedInfo = {
147
- conversationId: pairing.conversationId,
148
- title: threadTitle,
149
- sourceEventName: signal.sourceEventName,
150
- };
166
+ const threadTitle =
167
+ copy.threadTitle ??
168
+ copy.title ??
169
+ signal.sourceEventName;
170
+ const info: ThreadCreatedInfo = {
171
+ conversationId: pairing.conversationId,
172
+ title: threadTitle,
173
+ sourceEventName: signal.sourceEventName,
174
+ };
175
+
176
+ // The per-dispatch onThreadCreated callback fires whenever a vellum
177
+ // conversation is paired (new or reused) because callers like
178
+ // dispatchGuardianQuestion rely on it to create delivery bookkeeping
179
+ // rows before emitNotificationSignal() returns.
180
+ if (options?.onThreadCreated) {
181
+ try {
182
+ options.onThreadCreated(info);
183
+ } catch (err) {
184
+ log.error(
185
+ { err, signalId: signal.signalId },
186
+ 'per-dispatch onThreadCreated callback failed — continuing broadcast',
187
+ );
188
+ }
189
+ }
190
+
191
+ // Emit notification_thread_created IPC event only when a NEW
192
+ // conversation was actually created. Reusing an existing thread
193
+ // should not fire the IPC event — the client already knows about
194
+ // the conversation.
195
+ if (pairing.createdNewConversation && pairing.strategy === 'start_new_conversation') {
151
196
  if (this.onThreadCreated) {
152
197
  try {
153
198
  this.onThreadCreated(info);
@@ -155,16 +200,6 @@ export class NotificationBroadcaster {
155
200
  log.error({ err, signalId: signal.signalId }, 'onThreadCreated callback failed — continuing broadcast');
156
201
  }
157
202
  }
158
- if (options?.onThreadCreated) {
159
- try {
160
- options.onThreadCreated(info);
161
- } catch (err) {
162
- log.error(
163
- { err, signalId: signal.signalId },
164
- 'per-dispatch onThreadCreated callback failed — continuing broadcast',
165
- );
166
- }
167
- }
168
203
  }
169
204
  }
170
205
 
@@ -179,33 +214,15 @@ export class NotificationBroadcaster {
179
214
  deepLinkTarget,
180
215
  };
181
216
 
182
- // Only create a delivery audit record when we have a persisted decision ID
183
- // for the FK. If decision persistence failed (persistedDecisionId is
184
- // undefined), we still dispatch via the adapter but skip the delivery
185
- // record using dedupeKey would violate the FK constraint.
186
- const persistedDecisionId = decision.persistedDecisionId;
187
- const hasPersistedDecision = typeof persistedDecisionId === 'string';
217
+ // Compute thread decision audit fields for the delivery record
218
+ const threadAudit = {
219
+ threadAction: threadAction?.action ?? 'start_new',
220
+ threadTargetConversationId: threadAction?.action === 'reuse_existing' ? threadAction.conversationId : undefined,
221
+ threadDecisionFallbackUsed: pairing.threadDecisionFallbackUsed,
222
+ };
188
223
 
189
224
  try {
190
225
  if (hasPersistedDecision) {
191
- const existingDelivery = findDeliveryByDecisionAndChannel(persistedDecisionId, channel);
192
- if (existingDelivery) {
193
- log.info(
194
- { channel, signalId: signal.signalId, existingDeliveryId: existingDelivery.id },
195
- 'Delivery already exists for this decision+channel — skipping duplicate',
196
- );
197
- results.push({
198
- channel,
199
- destination: destinationLabel,
200
- status: 'skipped',
201
- errorMessage: 'Duplicate delivery skipped',
202
- conversationId: existingDelivery.conversationId ?? undefined,
203
- messageId: existingDelivery.messageId ?? undefined,
204
- conversationStrategy: existingDelivery.conversationStrategy ?? undefined,
205
- });
206
- continue;
207
- }
208
-
209
226
  createDelivery({
210
227
  id: deliveryId,
211
228
  notificationDecisionId: persistedDecisionId,
@@ -219,6 +236,7 @@ export class NotificationBroadcaster {
219
236
  conversationId: pairing.conversationId ?? undefined,
220
237
  messageId: pairing.messageId ?? undefined,
221
238
  conversationStrategy: pairing.strategy,
239
+ ...threadAudit,
222
240
  });
223
241
  } else {
224
242
  log.warn(
@@ -5,16 +5,20 @@
5
5
  * before the adapter sends it. This ensures every delivery has an
6
6
  * auditable conversation trail and enables the macOS/iOS client to
7
7
  * deep-link directly into the notification thread.
8
+ *
9
+ * When the decision engine selects `reuse_existing` for a channel and
10
+ * the target conversation is valid, the seed message is appended to the
11
+ * existing thread instead of creating a new one.
8
12
  */
9
13
 
10
14
  import type { ConversationStrategy } from '../channels/config.js';
11
15
  import { getConversationStrategy } from '../channels/config.js';
12
16
  import type { ChannelId } from '../channels/types.js';
13
- import { addMessage,createConversation } from '../memory/conversation-store.js';
17
+ import { addMessage, createConversation, getConversation } from '../memory/conversation-store.js';
14
18
  import { getLogger } from '../util/logger.js';
15
19
  import type { NotificationSignal } from './signal.js';
16
20
  import { composeThreadSeed, isThreadSeedSane } from './thread-seed-composer.js';
17
- import type { NotificationChannel } from './types.js';
21
+ import type { NotificationChannel, ThreadAction } from './types.js';
18
22
  import type { RenderedChannelCopy } from './types.js';
19
23
 
20
24
  const log = getLogger('notification-conversation-pairing');
@@ -23,6 +27,15 @@ export interface PairingResult {
23
27
  conversationId: string | null;
24
28
  messageId: string | null;
25
29
  strategy: ConversationStrategy;
30
+ /** True when a brand-new conversation was created; false when an existing one was reused. */
31
+ createdNewConversation: boolean;
32
+ /** When the model requested reuse_existing but the target was invalid, this is true. */
33
+ threadDecisionFallbackUsed: boolean;
34
+ }
35
+
36
+ export interface PairingOptions {
37
+ /** Per-channel thread action from the decision engine. */
38
+ threadAction?: ThreadAction;
26
39
  }
27
40
 
28
41
  /**
@@ -31,6 +44,12 @@ export interface PairingResult {
31
44
  * Looks up the channel's conversation strategy from the policy registry
32
45
  * and materializes a conversation + assistant message accordingly.
33
46
  *
47
+ * When `options.threadAction` is `reuse_existing`, the function attempts
48
+ * to look up the target conversation. If it exists and has the right source,
49
+ * the seed message is appended to it. If the target is invalid or stale,
50
+ * a new conversation is created instead (with `threadDecisionFallbackUsed`
51
+ * set to true on the result).
52
+ *
34
53
  * Errors are caught and logged — this function never throws so the
35
54
  * notification pipeline is not disrupted by pairing failures.
36
55
  */
@@ -38,22 +57,15 @@ export async function pairDeliveryWithConversation(
38
57
  signal: NotificationSignal,
39
58
  channel: NotificationChannel,
40
59
  copy: RenderedChannelCopy,
60
+ options?: PairingOptions,
41
61
  ): Promise<PairingResult> {
42
62
  try {
43
63
  const strategy = getConversationStrategy(channel as ChannelId);
44
64
 
45
65
  if (strategy === 'not_deliverable') {
46
- return { conversationId: null, messageId: null, strategy: 'not_deliverable' };
66
+ return { conversationId: null, messageId: null, strategy: 'not_deliverable', createdNewConversation: false, threadDecisionFallbackUsed: false };
47
67
  }
48
68
 
49
- // For both start_new_conversation and continue_existing_conversation,
50
- // we create a new conversation per notification delivery for now.
51
- //
52
- // True conversation continuation (reusing an existing conversation scoped
53
- // to channel + assistant via a key like `notif:{assistantId}:{channel}:ongoing`)
54
- // requires external chat binding lookup which is complex. A future PR will
55
- // add that capability. For this milestone we materialize conversations and
56
- // record the intended strategy so the audit trail is complete.
57
69
  const title = copy.threadTitle ?? copy.title ?? signal.sourceEventName;
58
70
 
59
71
  // Only start_new_conversation threads should be user-visible. For channels
@@ -62,6 +74,75 @@ export async function pairDeliveryWithConversation(
62
74
  // true continuation-by-key is implemented.
63
75
  const threadType = strategy === 'start_new_conversation' ? 'standard' : 'background';
64
76
 
77
+ // Prefer model-provided threadSeedMessage when present and sane;
78
+ // fall back to the runtime composer which adapts verbosity to the
79
+ // delivery surface (vellum/macos = richer, telegram = compact).
80
+ const messageContent = isThreadSeedSane(copy.threadSeedMessage)
81
+ ? copy.threadSeedMessage
82
+ : composeThreadSeed(signal, channel, copy);
83
+
84
+ const threadAction = options?.threadAction;
85
+
86
+ // Attempt to reuse an existing conversation when the model requests it
87
+ if (threadAction?.action === 'reuse_existing') {
88
+ const targetId = threadAction.conversationId;
89
+ const existing = getConversation(targetId);
90
+
91
+ if (existing && existing.source === 'notification') {
92
+ // Append the seed message to the existing conversation thread
93
+ const message = await addMessage(existing.id, 'assistant', messageContent, undefined, { skipIndexing: true });
94
+
95
+ log.info(
96
+ {
97
+ signalId: signal.signalId,
98
+ channel,
99
+ strategy,
100
+ conversationId: existing.id,
101
+ messageId: message.id,
102
+ threadAction: 'reuse_existing',
103
+ },
104
+ 'Reused existing notification conversation for delivery',
105
+ );
106
+
107
+ return {
108
+ conversationId: existing.id,
109
+ messageId: message.id,
110
+ strategy,
111
+ createdNewConversation: false,
112
+ threadDecisionFallbackUsed: false,
113
+ };
114
+ }
115
+
116
+ // Target is invalid/stale — fall back to creating a new conversation
117
+ log.warn(
118
+ {
119
+ signalId: signal.signalId,
120
+ channel,
121
+ targetConversationId: targetId,
122
+ targetExists: !!existing,
123
+ targetSource: existing?.source,
124
+ },
125
+ 'Thread reuse target invalid — falling back to new conversation',
126
+ );
127
+
128
+ const conversation = createConversation({
129
+ title,
130
+ threadType,
131
+ source: 'notification',
132
+ });
133
+
134
+ const message = await addMessage(conversation.id, 'assistant', messageContent, undefined, { skipIndexing: true });
135
+
136
+ return {
137
+ conversationId: conversation.id,
138
+ messageId: message.id,
139
+ strategy,
140
+ createdNewConversation: true,
141
+ threadDecisionFallbackUsed: true,
142
+ };
143
+ }
144
+
145
+ // Default path: create a new conversation
65
146
  // Memory indexing is skipped on the seed message below to prevent
66
147
  // notification copy from polluting conversational recall.
67
148
  const conversation = createConversation({
@@ -70,12 +151,6 @@ export async function pairDeliveryWithConversation(
70
151
  source: 'notification',
71
152
  });
72
153
 
73
- // Prefer model-provided threadSeedMessage when present and sane;
74
- // fall back to the runtime composer which adapts verbosity to the
75
- // delivery surface (vellum/macos = richer, telegram = compact).
76
- const messageContent = isThreadSeedSane(copy.threadSeedMessage)
77
- ? copy.threadSeedMessage
78
- : composeThreadSeed(signal, channel, copy);
79
154
  // Skip memory indexing — notification audit messages are not conversational
80
155
  // memory and should not pollute recall or incur embedding/extraction overhead.
81
156
  const message = await addMessage(conversation.id, 'assistant', messageContent, undefined, { skipIndexing: true });
@@ -87,6 +162,7 @@ export async function pairDeliveryWithConversation(
87
162
  strategy,
88
163
  conversationId: conversation.id,
89
164
  messageId: message.id,
165
+ threadAction: threadAction?.action ?? 'start_new',
90
166
  },
91
167
  'Paired notification delivery with conversation',
92
168
  );
@@ -95,6 +171,8 @@ export async function pairDeliveryWithConversation(
95
171
  conversationId: conversation.id,
96
172
  messageId: message.id,
97
173
  strategy,
174
+ createdNewConversation: true,
175
+ threadDecisionFallbackUsed: false,
98
176
  };
99
177
  } catch (err) {
100
178
  log.error(
@@ -108,6 +186,6 @@ export async function pairDeliveryWithConversation(
108
186
  return 'not_deliverable' as const;
109
187
  }
110
188
  })();
111
- return { conversationId: null, messageId: null, strategy: fallbackStrategy };
189
+ return { conversationId: null, messageId: null, strategy: fallbackStrategy, createdNewConversation: false, threadDecisionFallbackUsed: false };
112
190
  }
113
191
  }
@@ -19,7 +19,7 @@ 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 ThreadCandidateSet, buildThreadCandidates, serializeCandidatesForPrompt } from './thread-candidates.js';
22
+ import { buildThreadCandidates, serializeCandidatesForPrompt,type ThreadCandidateSet } from './thread-candidates.js';
23
23
  import type { NotificationChannel, NotificationDecision, RenderedChannelCopy, ThreadAction } from './types.js';
24
24
 
25
25
  const log = getLogger('notification-decision-engine');
@@ -385,13 +385,16 @@ export function validateThreadActions(
385
385
  if (action.action === 'start_new') {
386
386
  result[channel] = { action: 'start_new' };
387
387
  } else if (action.action === 'reuse_existing') {
388
- const conversationId = action.conversationId;
389
- if (typeof conversationId !== 'string' || !conversationId.trim()) {
388
+ const rawConversationId = action.conversationId;
389
+ if (typeof rawConversationId !== 'string' || !rawConversationId.trim()) {
390
390
  log.warn({ channel }, 'LLM returned reuse_existing without conversationId — downgrading to start_new');
391
391
  result[channel] = { action: 'start_new' };
392
392
  continue;
393
393
  }
394
394
 
395
+ // Normalize: the LLM may return a valid ID with leading/trailing whitespace
396
+ const conversationId = rawConversationId.trim();
397
+
395
398
  // Strict validation: the conversationId must exist in the candidate set
396
399
  const candidateIds = validCandidateIds.get(channel);
397
400
  if (!candidateIds || !candidateIds.has(conversationId)) {