@vellumai/assistant 0.11.10 → 0.11.11-staging.1

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 (199) hide show
  1. package/AGENTS.md +1 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  12. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  13. package/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  14. package/openapi.yaml +152 -55
  15. package/package.json +1 -1
  16. package/src/__tests__/anthropic-provider.test.ts +60 -0
  17. package/src/__tests__/config-get-vision-flag.test.ts +36 -0
  18. package/src/__tests__/conversation-error.test.ts +38 -0
  19. package/src/__tests__/copy-composer-tc-templates.test.ts +2 -2
  20. package/src/__tests__/guardian-action-service-scope.test.ts +53 -0
  21. package/src/__tests__/headless-browser-mode.test.ts +63 -13
  22. package/src/__tests__/heartbeat-disk-pressure.test.ts +2 -0
  23. package/src/__tests__/heartbeat-service.test.ts +118 -27
  24. package/src/__tests__/host-app-control-proxy.test.ts +2 -2
  25. package/src/__tests__/host-app-control-routes.test.ts +41 -0
  26. package/src/__tests__/host-bash-routes.test.ts +30 -1
  27. package/src/__tests__/host-browser-routes.test.ts +34 -0
  28. package/src/__tests__/host-cu-routes-targeted.test.ts +29 -0
  29. package/src/__tests__/host-file-routes-targeted.test.ts +29 -0
  30. package/src/__tests__/host-proxy-base.test.ts +1 -0
  31. package/src/__tests__/host-transfer-routes-targeted.test.ts +86 -0
  32. package/src/__tests__/llm-schema.test.ts +32 -0
  33. package/src/__tests__/mcp-config-secret-boundary.test.ts +2 -0
  34. package/src/__tests__/memory-config-file-parity.test.ts +18 -0
  35. package/src/__tests__/memory-retrieval-hook.test.ts +15 -0
  36. package/src/__tests__/messaging-send-tool.test.ts +159 -13
  37. package/src/__tests__/messaging-shared.test.ts +19 -0
  38. package/src/__tests__/notification-events-source-context-probe.test.ts +211 -0
  39. package/src/__tests__/openai-responses-prompt-cache.test.ts +40 -6
  40. package/src/__tests__/outlook-messaging-provider.test.ts +9 -0
  41. package/src/__tests__/provider-catalog-visibility.test.ts +2 -2
  42. package/src/__tests__/schedule-routes.test.ts +30 -1
  43. package/src/__tests__/scheduler-result-notification.test.ts +209 -0
  44. package/src/__tests__/skills-install-extract.test.ts +26 -0
  45. package/src/__tests__/skills-install-staging.test.ts +11 -2
  46. package/src/api/constants/personality-sliders.test.ts +21 -0
  47. package/src/api/constants/personality-sliders.ts +64 -0
  48. package/src/api/index.ts +8 -0
  49. package/src/archive/ustar-size.test.ts +32 -0
  50. package/src/archive/ustar-size.ts +23 -0
  51. package/src/background-wake/next-wake.test.ts +13 -0
  52. package/src/background-wake/next-wake.ts +4 -2
  53. package/src/calls/voice-session-bridge.ts +26 -0
  54. package/src/cli/__tests__/catalog-search-help.test.ts +1 -0
  55. package/src/cli/commands/__tests__/plugins-search.test.ts +81 -0
  56. package/src/cli/commands/config.help.ts +3 -1
  57. package/src/cli/commands/memory/worker.ts +1 -1
  58. package/src/cli/commands/platform/__tests__/credits-format.test.ts +94 -5
  59. package/src/cli/commands/platform/__tests__/credits.test.ts +10 -0
  60. package/src/cli/commands/platform/credits-format.ts +57 -0
  61. package/src/cli/commands/platform/index.help.ts +19 -3
  62. package/src/cli/commands/plugins.help.ts +4 -1
  63. package/src/cli/commands/plugins.ts +2 -1
  64. package/src/cli/lib/bundled-marketplace.json +1 -1
  65. package/src/cli/lib/install-from-platform.ts +8 -10
  66. package/src/config/bundled-skills/messaging/SKILL.md +2 -0
  67. package/src/config/bundled-skills/messaging/TOOLS.json +4 -4
  68. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +85 -1
  69. package/src/config/bundled-skills/messaging/tools/shared.ts +6 -0
  70. package/src/config/bundled-skills/schedule/SKILL.md +9 -1
  71. package/src/config/bundled-skills/screen-annotation/SKILL.md +87 -20
  72. package/src/config/bundled-skills/screen-annotation/TOOLS.json +21 -5
  73. package/src/config/bundled-skills/screen-annotation/tools/screen-annotation.test.ts +67 -0
  74. package/src/config/feature-flag-registry.json +17 -1
  75. package/src/config/input-modalities.test.ts +71 -0
  76. package/src/config/input-modalities.ts +95 -0
  77. package/src/config/schemas/heartbeat.ts +3 -3
  78. package/src/config/schemas/llm.ts +13 -3
  79. package/src/config/schemas/memory-storage.test.ts +39 -0
  80. package/src/config/schemas/memory-storage.ts +21 -0
  81. package/src/context/token-estimator.ts +2 -2
  82. package/src/daemon/__tests__/embedding-reconcile.test.ts +77 -0
  83. package/src/daemon/__tests__/lifecycle-embedding-reconcile.test.ts +3 -0
  84. package/src/daemon/conversation-error.ts +20 -7
  85. package/src/daemon/embedding-reconcile.ts +64 -1
  86. package/src/daemon/handlers/config-embeddings.ts +31 -0
  87. package/src/daemon/host-app-control-proxy.ts +2 -0
  88. package/src/daemon/host-bash-proxy.ts +1 -0
  89. package/src/daemon/host-cu-proxy.ts +6 -6
  90. package/src/daemon/host-file-proxy.ts +6 -6
  91. package/src/daemon/host-proxy-base.ts +10 -9
  92. package/src/daemon/host-transfer-proxy.ts +21 -24
  93. package/src/daemon/mcp-reload-service.ts +6 -0
  94. package/src/daemon/providers-setup.ts +5 -26
  95. package/src/heartbeat/heartbeat-service.ts +72 -13
  96. package/src/live-voice/__tests__/live-voice-photo.test.ts +72 -0
  97. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +81 -0
  98. package/src/live-voice/live-voice-photo.ts +68 -4
  99. package/src/live-voice/live-voice-session.ts +36 -2
  100. package/src/live-voice/protocol.ts +98 -1
  101. package/src/mcp/__tests__/reload-signal-emission.test.ts +54 -0
  102. package/src/mcp/__tests__/reload-signal.test.ts +57 -0
  103. package/src/mcp/__tests__/startup.test.ts +150 -0
  104. package/src/mcp/reload-signal.ts +50 -0
  105. package/src/mcp/startup.ts +101 -0
  106. package/src/messaging/providers/outlook/adapter.ts +2 -13
  107. package/src/messaging/providers/outlook/client.ts +13 -1
  108. package/src/messaging/providers/outlook/types.ts +3 -0
  109. package/src/notifications/__tests__/decision-engine.test.ts +79 -6
  110. package/src/notifications/__tests__/home-feed-side-effect.test.ts +27 -0
  111. package/src/notifications/__tests__/schedule-result-producer.test.ts +397 -0
  112. package/src/notifications/copy-composer.ts +33 -11
  113. package/src/notifications/decision-engine.ts +48 -11
  114. package/src/notifications/events-store.ts +68 -2
  115. package/src/notifications/home-feed-side-effect.ts +15 -3
  116. package/src/notifications/schedule-result-producer.ts +275 -0
  117. package/src/notifications/signal.ts +5 -0
  118. package/src/oauth/seed-providers.ts +3 -2
  119. package/src/persistence/conversation-crud.ts +38 -0
  120. package/src/persistence/embeddings/embedding-backend.test.ts +162 -0
  121. package/src/persistence/embeddings/embedding-backend.ts +148 -11
  122. package/src/persistence/embeddings/embedding-openai.test.ts +113 -0
  123. package/src/persistence/embeddings/embedding-openai.ts +55 -4
  124. package/src/persistence/embeddings/embedding-types.test.ts +24 -0
  125. package/src/persistence/embeddings/embedding-types.ts +25 -1
  126. package/src/persistence/job-utils.ts +6 -2
  127. package/src/plugin-api/index.ts +2 -1
  128. package/src/plugin-api/vision-support.test.ts +81 -0
  129. package/src/plugin-api/vision-support.ts +57 -10
  130. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +8 -0
  131. package/src/plugins/defaults/memory/jobs/__tests__/embed-concept-page.test.ts +1 -0
  132. package/src/plugins/defaults/memory/jobs/embed-concept-page.ts +11 -3
  133. package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +20 -0
  134. package/src/plugins/defaults/memory/src/memory-worker-routes.ts +3 -1
  135. package/src/plugins/defaults/memory/substrate/__tests__/reembed-job.test.ts +1 -0
  136. package/src/plugins/defaults/memory/v2/__tests__/backfill-jobs.test.ts +1 -0
  137. package/src/plugins/defaults/memory/v3/__tests__/section-dense-store.test.ts +82 -0
  138. package/src/plugins/defaults/memory/v3/section-dense-store.ts +9 -27
  139. package/src/providers/__tests__/provider-secret-catalog.test.ts +4 -0
  140. package/src/providers/__tests__/xml-tool-call-salvage.test.ts +172 -0
  141. package/src/providers/anthropic/client.ts +9 -4
  142. package/src/providers/inline-audio-support.test.ts +34 -0
  143. package/src/providers/inline-audio-support.ts +27 -0
  144. package/src/providers/model-catalog.ts +22 -2
  145. package/src/providers/openai/__tests__/api-error-normalization.test.ts +50 -0
  146. package/src/providers/openai/__tests__/chat-completions-extra-body.test.ts +51 -0
  147. package/src/providers/openai/__tests__/chat-completions-provider-xml-salvage.test.ts +291 -0
  148. package/src/providers/openai/api-error-normalization.ts +18 -3
  149. package/src/providers/openai/chat-completions-provider.ts +113 -13
  150. package/src/providers/openai/responses-provider.ts +10 -8
  151. package/src/providers/provider-secret-catalog.ts +11 -1
  152. package/src/providers/vellum/client.ts +14 -1
  153. package/src/providers/vellum/personality-directions.test.ts +163 -0
  154. package/src/providers/vellum/personality-directions.ts +120 -0
  155. package/src/providers/xml-tool-call-salvage.ts +212 -0
  156. package/src/runtime/__tests__/background-job-runner.test.ts +42 -4
  157. package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -6
  158. package/src/runtime/auth/__tests__/route-policy.test.ts +65 -0
  159. package/src/runtime/auth/same-actor.ts +29 -5
  160. package/src/runtime/background-job-runner.ts +38 -29
  161. package/src/runtime/guardian-action-service.ts +20 -1
  162. package/src/runtime/migrations/vbundle-validator.ts +3 -3
  163. package/src/runtime/pending-interactions.ts +4 -5
  164. package/src/runtime/routes/__tests__/heartbeat-routes.test.ts +24 -0
  165. package/src/runtime/routes/__tests__/platform-credits-routes.test.ts +99 -5
  166. package/src/runtime/routes/conversation-query-routes.ts +53 -11
  167. package/src/runtime/routes/document-comments-routes.ts +8 -2
  168. package/src/runtime/routes/guardian-action-routes.ts +7 -0
  169. package/src/runtime/routes/heartbeat-routes.ts +41 -53
  170. package/src/runtime/routes/host-app-control-routes.ts +10 -36
  171. package/src/runtime/routes/host-bash-routes.ts +11 -45
  172. package/src/runtime/routes/host-browser-routes.ts +24 -0
  173. package/src/runtime/routes/host-cu-routes.ts +11 -45
  174. package/src/runtime/routes/host-file-routes.ts +11 -44
  175. package/src/runtime/routes/host-proxy-result-binding.ts +57 -0
  176. package/src/runtime/routes/host-transfer-routes.ts +33 -25
  177. package/src/runtime/routes/index.ts +0 -2
  178. package/src/runtime/routes/integrations/a2a.ts +12 -3
  179. package/src/runtime/routes/integrations/vercel.ts +13 -3
  180. package/src/runtime/routes/platform-routes.ts +121 -24
  181. package/src/runtime/routes/schedule-routes.ts +15 -1
  182. package/src/schedule/__tests__/schedule-timezone.test.ts +21 -3
  183. package/src/schedule/__tests__/worker-mcp-tools.test.ts +88 -0
  184. package/src/schedule/schedule-timezone.ts +25 -0
  185. package/src/schedule/scheduler.ts +19 -0
  186. package/src/schedule/worker.ts +119 -2
  187. package/src/skills/catalog-install.ts +40 -5
  188. package/src/tools/browser/__tests__/browser-status.test.ts +3 -0
  189. package/src/tools/browser/browser-execution.ts +117 -53
  190. package/src/tools/browser/browser-status-constants.ts +6 -6
  191. package/src/tools/capability-offer.test.ts +87 -0
  192. package/src/tools/capability-offer.ts +93 -0
  193. package/src/tools/terminal/__tests__/safe-env.test.ts +17 -0
  194. package/src/tools/terminal/safe-env.ts +3 -2
  195. package/src/util/provider-error-patterns.ts +34 -0
  196. package/src/watcher/__tests__/engine.test.ts +5 -2
  197. package/src/watcher/engine.ts +7 -4
  198. package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +0 -487
  199. package/src/runtime/routes/sight-frame-routes.ts +0 -136
@@ -86,6 +86,20 @@ const ASSISTANT_REPLY_CHANNELS = [
86
86
  "platform",
87
87
  ] as const satisfies readonly NotificationChannel[];
88
88
 
89
+ /**
90
+ * Delivery scope for `schedule.result` signals. Wider than
91
+ * {@link ASSISTANT_REPLY_CHANNELS} by one channel, and the difference is the
92
+ * point: an unseen chat reply is already sitting in a conversation the user
93
+ * opened, so a push is the only thing it can add. A scheduled run's output has
94
+ * no such home — nobody is looking at the run's conversation — so `vellum`
95
+ * carries it into the notification center where it persists, and `platform`
96
+ * pushes it.
97
+ */
98
+ const SCHEDULE_RESULT_CHANNELS = [
99
+ "vellum",
100
+ "platform",
101
+ ] as const satisfies readonly NotificationChannel[];
102
+
89
103
  // ── System prompt ──────────────────────────────────────────────────────
90
104
 
91
105
  function buildSystemPrompt(
@@ -224,23 +238,28 @@ function buildUserPrompt(signal: NotificationSignal): string {
224
238
  // ── Tool definition ────────────────────────────────────────────────────
225
239
 
226
240
  /**
227
- * Spec for the per-channel notification title. Mirrors the conversation title
228
- * prompt (`persistence/conversation-title-service.ts`), which gets clean
229
- * noun-phrase headlines out of this same model profile.
241
+ * Spec for the per-channel notification title. The title is the one line a
242
+ * reader sees in the notification bell, where a row that only reports shows
243
+ * nothing else, so it has to say what happened on its own rather than name a
244
+ * topic the body then explains. Same length discipline as the conversation
245
+ * title prompt (`persistence/conversation-title-service.ts`), which shares
246
+ * this model profile and the `normalizeTitle` clamp.
230
247
  */
231
248
  const TITLE_FIELD_DESCRIPTION = [
232
- "Scannable headline naming the TOPIC of this notification, not a summary of it.",
249
+ "Scannable headline saying WHAT HAPPENED or WHAT IS NEEDED, so it stands on its own without the body.",
233
250
  "Rules:",
234
- "- 2 to 5 words. Longer titles are unacceptable, ruthlessly compress",
251
+ "- 2 to 6 words. Longer titles are unacceptable, ruthlessly compress",
235
252
  "- 40 characters absolute maximum, longer titles get truncated and look broken",
236
- "- A noun phrase naming the topic, never a sentence, question, or greeting (e.g. 'Platform Standup', 'Nightly Backup Failure')",
237
- "- Do NOT restate, summarize, or echo the body. The title and the body must carry different information",
253
+ "- Lead with the outcome as a short clause: a past-tense verb for something done ('Prepared a wedding schedule', 'Nightly backup failed'), a need for something waiting on the reader ('Needs an answer on the venue')",
254
+ "- Sentence case, never Title Case",
255
+ "- Do NOT restate the body word for word. The body adds the detail the title leaves out: numbers, names, what to do next",
238
256
  "- No quotes, no markdown, no trailing punctuation",
239
- "- Never describe missing or thin context. Titles like 'Notification', 'Update', 'Missing Context' are forbidden. Extract a topic from the words that ARE present",
257
+ "- Never describe missing or thin context. Titles like 'Notification', 'Update', 'Activity complete', 'Missing context' are forbidden. Say what was done from the words that ARE present",
240
258
  "Examples:",
241
- "- Body 'Your 9am standup with the platform team starts in 5 minutes' -> 'Platform Standup', NOT 'Standup Starts In 5 Minutes'",
242
- "- Body 'The nightly backup job failed on db-primary at 02:14' -> 'Nightly Backup Failure', NOT 'Nightly Backup Job Failed On db-primary'",
243
- "- Body 'Alice replied about the Q3 pricing deck and wants your notes' -> 'Q3 Pricing Deck', NOT 'Alice Replied About The Pricing Deck'",
259
+ "- Body 'Your 9am standup with the platform team starts in 5 minutes' -> 'Platform standup in 5 minutes', NOT 'Platform Standup'",
260
+ "- Body 'The nightly backup job failed on db-primary at 02:14' -> 'Nightly backup failed', NOT 'Nightly Backup Failure'",
261
+ "- Body 'Alice replied about the Q3 pricing deck and wants your notes' -> 'Alice wants notes on the Q3 deck', NOT 'Q3 Pricing Deck'",
262
+ "- Body 'Recapped the 12 emails that needed a reply and deleted 6 newsletters' -> 'Recapped 12 emails, deleted 6', NOT 'Inbox Recap'",
244
263
  ].join("\n");
245
264
 
246
265
  function buildDecisionTool(availableChannels: NotificationChannel[]) {
@@ -907,6 +926,24 @@ export async function evaluateSignal(
907
926
  });
908
927
  }
909
928
 
929
+ // Schedule-result pass-through: the body is the run's own reply, which is
930
+ // the whole point of the notification — a briefing, a digest, a report. The
931
+ // classifier rewrites bodies into short alerts, which would throw away the
932
+ // content the user set the schedule up to receive. Routing has nothing to
933
+ // decide either: the user asked for this cadence, so it goes to the inbox
934
+ // and to push, and `enforceRoutingIntent` still narrows it afterwards.
935
+ if (signal.sourceEventName === "schedule.result" && requestedBody) {
936
+ return buildPassThroughDecision({
937
+ signal,
938
+ availableChannels,
939
+ selectedChannels: SCHEDULE_RESULT_CHANNELS.filter((ch) =>
940
+ availableChannels.includes(ch),
941
+ ),
942
+ body: requestedBody,
943
+ reasoningSummary: "schedule_result pass-through",
944
+ });
945
+ }
946
+
910
947
  // When no explicit preference context is provided, load the user's
911
948
  // stored notification preferences from the memory-backed store.
912
949
  // Wrapped in try/catch so a DB failure doesn't break the decision path.
@@ -6,10 +6,14 @@
6
6
  * and context payload. Decision/delivery records are tracked separately.
7
7
  */
8
8
 
9
- import { and, desc, eq } from "drizzle-orm";
9
+ import { and, desc, eq, gte, inArray, or } from "drizzle-orm";
10
10
 
11
11
  import { getDb } from "../persistence/db-connection.js";
12
- import { notificationEvents } from "../persistence/schema/index.js";
12
+ import {
13
+ notificationDecisions,
14
+ notificationDeliveries,
15
+ notificationEvents,
16
+ } from "../persistence/schema/index.js";
13
17
  import type { AttentionHints } from "./signal.js";
14
18
 
15
19
  export interface NotificationEventRow {
@@ -138,3 +142,65 @@ export function listEvents(
138
142
 
139
143
  return rows.map(rowToEvent);
140
144
  }
145
+
146
+ /**
147
+ * True when a notification emitted against `sourceContextId` at or after
148
+ * `sinceMs` reached a verdict the user can be said to have received.
149
+ *
150
+ * The schedule-result producer's "did this run already speak for itself?"
151
+ * probe. A run's conversation id is the `sourceContextId` every notification
152
+ * the run emits carries — `assistant notifications send` resolves it from
153
+ * `__CONVERSATION_ID` — so a hit means the agent notified on its own and the
154
+ * fallback must stay quiet.
155
+ *
156
+ * An event row alone is not a hit. `emitNotificationSignal` persists the event
157
+ * first and the pipeline can still fail after that, leaving an audit row for a
158
+ * notification nobody received; counting it would suppress the fallback in
159
+ * exactly the case it exists for. A hit therefore needs one of:
160
+ *
161
+ * - a decision **not** to notify. The engine ruled on the run's own signal
162
+ * (preferences, quiet hours, its own judgment), and the fallback exists to
163
+ * cover runs that never asked, not to appeal verdicts the user's settings
164
+ * produced; or
165
+ * - a delivery row that is `sent` or still `pending` — something went out, or
166
+ * is about to. An event whose every delivery `failed`, or that never got a
167
+ * decision row at all, does not count.
168
+ *
169
+ * The `sinceMs` bound is load-bearing rather than an optimization: a recurring
170
+ * schedule with `reuseConversation` runs every firing in the same conversation,
171
+ * so an unbounded probe would see last week's notification and silence every
172
+ * run after the first.
173
+ */
174
+ export function hasNotifiedSourceContextSince(
175
+ sourceContextId: string,
176
+ sinceMs: number,
177
+ ): boolean {
178
+ const db = getDb();
179
+ const row = db
180
+ .select({ id: notificationEvents.id })
181
+ .from(notificationEvents)
182
+ .innerJoin(
183
+ notificationDecisions,
184
+ eq(notificationDecisions.notificationEventId, notificationEvents.id),
185
+ )
186
+ .leftJoin(
187
+ notificationDeliveries,
188
+ eq(
189
+ notificationDeliveries.notificationDecisionId,
190
+ notificationDecisions.id,
191
+ ),
192
+ )
193
+ .where(
194
+ and(
195
+ eq(notificationEvents.sourceContextId, sourceContextId),
196
+ gte(notificationEvents.createdAt, sinceMs),
197
+ or(
198
+ eq(notificationDecisions.shouldNotify, 0),
199
+ inArray(notificationDeliveries.status, ["pending", "sent"]),
200
+ ),
201
+ ),
202
+ )
203
+ .limit(1)
204
+ .get();
205
+ return row !== undefined;
206
+ }
@@ -194,7 +194,7 @@ export async function writeHomeFeedItemForSignal(
194
194
  timestamp: now,
195
195
  createdAt: now,
196
196
  status: "new",
197
- category,
197
+ ...(category ? { category } : {}),
198
198
  noteworthy: deriveNoteworthy(signal),
199
199
  fromAssistant: signal.sourceChannel === "assistant_tool",
200
200
  ...(guardianProjection ? { guardianRequest: guardianProjection } : {}),
@@ -442,14 +442,26 @@ const EVENT_CATEGORY_MAP: Record<string, FeedItemCategory> = {
442
442
  "activity.complete": "background",
443
443
  "watcher.notification": "system",
444
444
  "schedule.notify": "scheduling",
445
+ "schedule.result": "scheduling",
445
446
  "guardian.question": "security",
446
447
  "guardian.channel_activation": "security",
447
448
  "ingress.access_request": "security",
448
449
  "telegram.webhook_health_alert": "system",
449
450
  };
450
451
 
451
- function deriveCategory(signal: NotificationSignal): FeedItemCategory {
452
- return EVENT_CATEGORY_MAP[signal.sourceEventName] ?? "system";
452
+ /**
453
+ * Map a signal's source event to a feed category, or nothing when the event
454
+ * has no entry. An unmapped event used to land in `system`, a bucket named
455
+ * for our architecture rather than the user's world, and every deliberate
456
+ * assistant notification (`user.send_notification`) ended up there. The
457
+ * category is optional on the wire, so an event without a home simply
458
+ * carries none: readers that filter by category skip it, and nothing has
459
+ * to guess.
460
+ */
461
+ function deriveCategory(
462
+ signal: NotificationSignal,
463
+ ): FeedItemCategory | undefined {
464
+ return EVENT_CATEGORY_MAP[signal.sourceEventName];
453
465
  }
454
466
 
455
467
  function deriveDetailPanelKind(
@@ -0,0 +1,275 @@
1
+ /**
2
+ * `schedule.result` producer: the notification a scheduled run owes the user
3
+ * when it produced output and never spoke up on its own.
4
+ *
5
+ * Every other conversation kind already has a producer that answers for it
6
+ * (see the kind switch in `assistant-reply-producer.ts`). Scheduled runs were
7
+ * the exception: their "producer" was the model remembering to call
8
+ * `assistant notifications send`, decided per run against free-form prompt
9
+ * text and the notifications skill's "only when something notable happened"
10
+ * guidance. A daily briefing could therefore run, spend money, write a reply
11
+ * into a conversation nobody has open, and leave no trace the user ever sees.
12
+ *
13
+ * This closes that hole from the other end. It does not re-judge whether the
14
+ * output was notable — that judgment is the bug. It asks two mechanical
15
+ * questions: did this run say anything, and has the user been told? A run that
16
+ * notified itself is left alone, so a well-authored schedule sees no change
17
+ * and no duplicate.
18
+ *
19
+ * Deliberately scoped to execute-mode schedule runs. Heartbeats, watchers, and
20
+ * background jobs stay strict — their firing is the assistant's idea, so
21
+ * silence is the correct default. A schedule's firing is the user's idea, on a
22
+ * cadence they chose, which is what earns its output a notification.
23
+ *
24
+ * Best-effort throughout, like every producer here: a notification failure
25
+ * must never turn a schedule run that succeeded into one recorded as failed.
26
+ */
27
+
28
+ import type pino from "pino";
29
+
30
+ import { isAssistantFeatureFlagEnabled } from "../config/assistant-feature-flags.js";
31
+ import { getAttentionStateByConversationIds } from "../persistence/conversation-attention-store.js";
32
+ import {
33
+ getAssistantMessageIdsInTurn,
34
+ getMessageById,
35
+ type MessageRow,
36
+ } from "../persistence/conversation-crud.js";
37
+ import { stringifyMessageContent } from "../persistence/message-content.js";
38
+ import type { ContentBlock } from "../providers/types.js";
39
+ import { emitNotificationSignal } from "./emit-signal.js";
40
+ import { hasNotifiedSourceContextSince } from "./events-store.js";
41
+ import {
42
+ sanitizeNotificationTitle,
43
+ stripMarkdownForPreview,
44
+ truncate,
45
+ } from "./notification-utils.js";
46
+
47
+ /** Kill switch for this producer, on by default. */
48
+ const SCHEDULE_RESULT_NOTIFY_FLAG = "schedule-result-notify" as const;
49
+
50
+ /**
51
+ * Body cap. Far above `MESSAGE_PREVIEW_MAX_LENGTH` (200) on purpose: this body
52
+ * is the deliverable, not a preview of one, and the home feed's detail panel
53
+ * renders it as markdown. A lock-screen banner truncates on its own, so the
54
+ * cap exists only to keep a runaway reply out of the payload.
55
+ */
56
+ const MAX_RESULT_BODY_CHARS = 2000;
57
+
58
+ export interface ScheduleResultNotificationParams {
59
+ /** `cron_jobs.id` of the schedule that fired. */
60
+ scheduleId: string;
61
+ /** The schedule's user-authored name; becomes the notification title. */
62
+ scheduleName: string;
63
+ /** Conversation the run executed in. */
64
+ conversationId: string;
65
+ /** `cron_runs.id` for this firing — scopes the dedupe key to one run. */
66
+ runId: string;
67
+ /**
68
+ * When the run started, in epoch ms. The lower bound on the "did this run
69
+ * already notify?" probe, and the reason a reused conversation's earlier
70
+ * runs cannot silence this one.
71
+ */
72
+ runStartedAt: number;
73
+ rlog: pino.Logger;
74
+ }
75
+
76
+ /**
77
+ * Whether a tool call in the run's turn delivered the result somewhere the
78
+ * user will see it, outside the notification pipeline.
79
+ *
80
+ * The schedule skill prescribes two such routes for rich content — the
81
+ * messaging tool for email, and the Slack Web API's `chat.postMessage` through
82
+ * bash — and neither writes a `notification_events` row, so the pipeline probe
83
+ * cannot see them. Without this check a well-authored Slack digest would post
84
+ * its summary and then get a second notification whose body is "Posted the
85
+ * digest to #general." This is a recognized-routes list, not a general "did
86
+ * the run do anything?" heuristic: a route that is not here gets the fallback,
87
+ * which is the safe failure.
88
+ */
89
+ function isDirectDelivery(block: ContentBlock): boolean {
90
+ if (block.type !== "tool_use") {
91
+ return false;
92
+ }
93
+ if (block.name === "messaging_send") {
94
+ return true;
95
+ }
96
+ if (block.name === "bash") {
97
+ const command = (block.input as { command?: unknown } | undefined)?.command;
98
+ return typeof command === "string" && command.includes("chat.postMessage");
99
+ }
100
+ return false;
101
+ }
102
+
103
+ /**
104
+ * The assistant rows this run wrote, in order, ending on `latestRow`.
105
+ *
106
+ * A run is one agent turn: `getAssistantMessageIdsInTurn` walks the tool-call
107
+ * loop back to the user message that opened it. Rows from before the run
108
+ * started are dropped defensively — a reused conversation's earlier turns must
109
+ * never be mistaken for this one.
110
+ */
111
+ function collectRunRows(
112
+ latestRow: MessageRow,
113
+ conversationId: string,
114
+ runStartedAt: number,
115
+ ): MessageRow[] {
116
+ const rows: MessageRow[] = [];
117
+ for (const id of getAssistantMessageIdsInTurn(latestRow.id)) {
118
+ const row =
119
+ id === latestRow.id ? latestRow : getMessageById(id, conversationId);
120
+ if (row && row.createdAt >= runStartedAt) {
121
+ rows.push(row);
122
+ }
123
+ }
124
+ return rows;
125
+ }
126
+
127
+ /**
128
+ * Whether the run left the user anything worth reading, and what.
129
+ *
130
+ * Substance is judged mechanically: markdown is flattened and whitespace
131
+ * collapsed, and whatever survives is the answer. A run that ended on tool
132
+ * calls with no closing prose flattens to nothing and stays silent — the
133
+ * "nothing to say" case. Anything else counts, including a briefing whose
134
+ * honest finding is that nothing changed; the user asked for that cadence, and
135
+ * "no new mail today" is a result, not noise.
136
+ *
137
+ * The returned body keeps its original markdown — only the emptiness test runs
138
+ * on the flattened form, because the detail panel renders the real thing.
139
+ */
140
+ function resolveRunOutput(latestRow: MessageRow): string | undefined {
141
+ const text = stringifyMessageContent(latestRow.content);
142
+ const flattened = stripMarkdownForPreview(text).replace(/\s+/g, " ").trim();
143
+ if (!flattened) {
144
+ return undefined;
145
+ }
146
+ return truncate(text.trim(), MAX_RESULT_BODY_CHARS);
147
+ }
148
+
149
+ /**
150
+ * The run's final assistant row, or nothing if the run wrote none.
151
+ *
152
+ * `latestAssistantMessageId` is per conversation, not per run, so a reused
153
+ * conversation whose current run wrote no reply would otherwise hand back the
154
+ * previous run's — and the fallback would re-send yesterday's briefing.
155
+ */
156
+ function resolveLatestRunRow(
157
+ conversationId: string,
158
+ runStartedAt: number,
159
+ ): MessageRow | undefined {
160
+ const attention = getAttentionStateByConversationIds([conversationId]).get(
161
+ conversationId,
162
+ );
163
+ const assistantMessageId = attention?.latestAssistantMessageId;
164
+ if (!assistantMessageId) {
165
+ return undefined;
166
+ }
167
+ const row = getMessageById(assistantMessageId, conversationId);
168
+ if (!row || row.createdAt < runStartedAt) {
169
+ return undefined;
170
+ }
171
+ return row;
172
+ }
173
+
174
+ /**
175
+ * Emit the fallback notification for a schedule run that finished without
176
+ * notifying. No-ops when the flag is off, when the run already notified, or
177
+ * when the run produced no user-facing text.
178
+ */
179
+ export async function emitScheduleResultNotification(
180
+ params: ScheduleResultNotificationParams,
181
+ ): Promise<void> {
182
+ const {
183
+ scheduleId,
184
+ scheduleName,
185
+ conversationId,
186
+ runId,
187
+ runStartedAt,
188
+ rlog,
189
+ } = params;
190
+ try {
191
+ if (!isAssistantFeatureFlagEnabled(SCHEDULE_RESULT_NOTIFY_FLAG)) {
192
+ return;
193
+ }
194
+ // A bootstrap failure hands back a sentinel rather than a real id; there
195
+ // is no conversation to read and nothing to say about it.
196
+ if (!conversationId || conversationId.startsWith("bootstrap-error:")) {
197
+ return;
198
+ }
199
+
200
+ // The run spoke for itself — an explicit `assistant notifications send`,
201
+ // or any other signal the turn emitted against this conversation, that
202
+ // reached a verdict or a channel. Leaving it alone is what keeps a
203
+ // well-authored schedule from notifying twice.
204
+ if (hasNotifiedSourceContextSince(conversationId, runStartedAt)) {
205
+ return;
206
+ }
207
+
208
+ const latestRow = resolveLatestRunRow(conversationId, runStartedAt);
209
+ if (!latestRow) {
210
+ return;
211
+ }
212
+
213
+ // The run delivered around the pipeline — an email through the messaging
214
+ // tool, a Slack post through the Web API. The user has the result; a
215
+ // notification reading "posted it" on top would be the duplicate.
216
+ const runRows = collectRunRows(latestRow, conversationId, runStartedAt);
217
+ if (runRows.some((row) => row.content.some(isDirectDelivery))) {
218
+ return;
219
+ }
220
+
221
+ const body = resolveRunOutput(latestRow);
222
+ if (!body) {
223
+ return;
224
+ }
225
+
226
+ // Schedule names are user-authored and unbounded, so the title gets the
227
+ // same clamp as any other string headed for a lock screen.
228
+ const requestedTitle = sanitizeNotificationTitle(
229
+ scheduleName.replace(/\s+/g, " ").trim(),
230
+ );
231
+
232
+ await emitNotificationSignal({
233
+ sourceEventName: "schedule.result",
234
+ sourceChannel: "scheduler",
235
+ // The run's conversation: the deep-link target, and what tells
236
+ // `resolveHomeFeedMirror` this is a background conversation worth
237
+ // mirroring into the home feed.
238
+ sourceContextId: conversationId,
239
+ attentionHints: {
240
+ requiresAction: false,
241
+ // Medium, not low: low routes to the notification center alone and
242
+ // posts silently, which is the invisibility this producer exists to
243
+ // fix. Not high either — that force-adds an in-app banner on every
244
+ // client for what is a digest, not an alarm.
245
+ urgency: "medium",
246
+ // Marks the signal as background-originated, which is what earns the
247
+ // home-feed mirror independently of the conversation-type check.
248
+ isAsyncBackground: true,
249
+ // Nobody is watching a scheduled run's conversation; that is the
250
+ // premise of this whole producer.
251
+ visibleInSourceNow: false,
252
+ },
253
+ contextPayload: {
254
+ requestedTitle,
255
+ requestedMessage: body,
256
+ scheduleName,
257
+ scheduleId,
258
+ },
259
+ // Keyed on the run, not the schedule: the pipeline's dedupe window is a
260
+ // flat hour, so a schedule firing more often than hourly would have its
261
+ // later runs silently dropped under a schedule-scoped key.
262
+ dedupeKey: `schedule.result:${scheduleId}:${runId}`,
263
+ });
264
+
265
+ rlog.info(
266
+ { scheduleId, runId, conversationId },
267
+ "Emitted fallback notification for a schedule run that produced output without notifying",
268
+ );
269
+ } catch (err) {
270
+ rlog.warn(
271
+ { err, scheduleId, runId, conversationId },
272
+ "Failed to emit schedule result notification (non-fatal)",
273
+ );
274
+ }
275
+ }
@@ -66,6 +66,11 @@ export const NOTIFICATION_SOURCE_EVENT_NAMES = [
66
66
  id: "schedule.notify",
67
67
  description: "Scheduled notification triggered (one-shot or recurring)",
68
68
  },
69
+ {
70
+ id: "schedule.result",
71
+ description:
72
+ "Execute-mode schedule run finished with user-facing output it did not notify about itself",
73
+ },
69
74
  {
70
75
  id: "schedule.definition_error",
71
76
  description: "Plugin schedule declaration failed to parse or validate",
@@ -1286,8 +1286,9 @@ export const PROVIDER_SEED_DATA: Record<
1286
1286
  // Link issues OAuth clients by request rather than through a self-serve
1287
1287
  // dashboard, so there is no placeholder shape to suggest.
1288
1288
  clientIdPlaceholder: null,
1289
- // Simple Icons has no Link mark (`link` 404s on the CDN), so the parent
1290
- // Stripe mark stands in.
1289
+ // Clients draw the bundled Link symbol; this remote fallback carries the
1290
+ // parent Stripe mark because Simple Icons has no Link one (`link` 404s on
1291
+ // the CDN).
1291
1292
  logoUrl: "https://cdn.simpleicons.org/stripe",
1292
1293
  defaultScopes: ["payment_methods.agentic", "userinfo:read"],
1293
1294
  availableScopes: [
@@ -2705,6 +2705,44 @@ export function selectSightFrameCaptureTimes(
2705
2705
  return captureTimes;
2706
2706
  }
2707
2707
 
2708
+ /**
2709
+ * The newest camera frame in the conversation, by the `createdAt` of the row
2710
+ * that carries it, or null when no row carries one.
2711
+ *
2712
+ * The one-row form of {@link selectSightFrameCaptureTimes}, for a caller that
2713
+ * wants only the latest and runs on every voice turn: every stored frame stays
2714
+ * a row for the life of the conversation, so the full scan grows with the
2715
+ * call. Same narrowing and the same validation, so the two agree on what a
2716
+ * frame is. A row carrying several frames answers with the last attached.
2717
+ */
2718
+ export function selectNewestSightFrameCapture(
2719
+ conversationId: string,
2720
+ ): { attachmentId: string; createdAt: number } | null {
2721
+ const db = getDb();
2722
+ const row = db
2723
+ .select({ metadata: messages.metadata, createdAt: messages.createdAt })
2724
+ .from(messages)
2725
+ .where(
2726
+ and(
2727
+ lineageFilter(conversationId),
2728
+ like(messages.metadata, `%"${SIGHT_FRAME_ATTACHMENT_IDS_KEY}"%`),
2729
+ ),
2730
+ )
2731
+ .orderBy(desc(messages.createdAt))
2732
+ .limit(1)
2733
+ .get();
2734
+ if (!row) {
2735
+ return null;
2736
+ }
2737
+ const ids = sightFrameAttachmentIdsFromMetadata(
2738
+ parseMessageMetadata(row.metadata),
2739
+ );
2740
+ const attachmentId = ids.at(-1);
2741
+ return attachmentId === undefined
2742
+ ? null
2743
+ : { attachmentId, createdAt: row.createdAt };
2744
+ }
2745
+
2708
2746
  /**
2709
2747
  * Count messages in a conversation that were created strictly after the
2710
2748
  * `afterMessageId` reference message. If `afterMessageId` is `null` or empty,