@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.
- package/AGENTS.md +1 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
- package/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
- package/openapi.yaml +152 -55
- package/package.json +1 -1
- package/src/__tests__/anthropic-provider.test.ts +60 -0
- package/src/__tests__/config-get-vision-flag.test.ts +36 -0
- package/src/__tests__/conversation-error.test.ts +38 -0
- package/src/__tests__/copy-composer-tc-templates.test.ts +2 -2
- package/src/__tests__/guardian-action-service-scope.test.ts +53 -0
- package/src/__tests__/headless-browser-mode.test.ts +63 -13
- package/src/__tests__/heartbeat-disk-pressure.test.ts +2 -0
- package/src/__tests__/heartbeat-service.test.ts +118 -27
- package/src/__tests__/host-app-control-proxy.test.ts +2 -2
- package/src/__tests__/host-app-control-routes.test.ts +41 -0
- package/src/__tests__/host-bash-routes.test.ts +30 -1
- package/src/__tests__/host-browser-routes.test.ts +34 -0
- package/src/__tests__/host-cu-routes-targeted.test.ts +29 -0
- package/src/__tests__/host-file-routes-targeted.test.ts +29 -0
- package/src/__tests__/host-proxy-base.test.ts +1 -0
- package/src/__tests__/host-transfer-routes-targeted.test.ts +86 -0
- package/src/__tests__/llm-schema.test.ts +32 -0
- package/src/__tests__/mcp-config-secret-boundary.test.ts +2 -0
- package/src/__tests__/memory-config-file-parity.test.ts +18 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +15 -0
- package/src/__tests__/messaging-send-tool.test.ts +159 -13
- package/src/__tests__/messaging-shared.test.ts +19 -0
- package/src/__tests__/notification-events-source-context-probe.test.ts +211 -0
- package/src/__tests__/openai-responses-prompt-cache.test.ts +40 -6
- package/src/__tests__/outlook-messaging-provider.test.ts +9 -0
- package/src/__tests__/provider-catalog-visibility.test.ts +2 -2
- package/src/__tests__/schedule-routes.test.ts +30 -1
- package/src/__tests__/scheduler-result-notification.test.ts +209 -0
- package/src/__tests__/skills-install-extract.test.ts +26 -0
- package/src/__tests__/skills-install-staging.test.ts +11 -2
- package/src/api/constants/personality-sliders.test.ts +21 -0
- package/src/api/constants/personality-sliders.ts +64 -0
- package/src/api/index.ts +8 -0
- package/src/archive/ustar-size.test.ts +32 -0
- package/src/archive/ustar-size.ts +23 -0
- package/src/background-wake/next-wake.test.ts +13 -0
- package/src/background-wake/next-wake.ts +4 -2
- package/src/calls/voice-session-bridge.ts +26 -0
- package/src/cli/__tests__/catalog-search-help.test.ts +1 -0
- package/src/cli/commands/__tests__/plugins-search.test.ts +81 -0
- package/src/cli/commands/config.help.ts +3 -1
- package/src/cli/commands/memory/worker.ts +1 -1
- package/src/cli/commands/platform/__tests__/credits-format.test.ts +94 -5
- package/src/cli/commands/platform/__tests__/credits.test.ts +10 -0
- package/src/cli/commands/platform/credits-format.ts +57 -0
- package/src/cli/commands/platform/index.help.ts +19 -3
- package/src/cli/commands/plugins.help.ts +4 -1
- package/src/cli/commands/plugins.ts +2 -1
- package/src/cli/lib/bundled-marketplace.json +1 -1
- package/src/cli/lib/install-from-platform.ts +8 -10
- package/src/config/bundled-skills/messaging/SKILL.md +2 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +4 -4
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +85 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +6 -0
- package/src/config/bundled-skills/schedule/SKILL.md +9 -1
- package/src/config/bundled-skills/screen-annotation/SKILL.md +87 -20
- package/src/config/bundled-skills/screen-annotation/TOOLS.json +21 -5
- package/src/config/bundled-skills/screen-annotation/tools/screen-annotation.test.ts +67 -0
- package/src/config/feature-flag-registry.json +17 -1
- package/src/config/input-modalities.test.ts +71 -0
- package/src/config/input-modalities.ts +95 -0
- package/src/config/schemas/heartbeat.ts +3 -3
- package/src/config/schemas/llm.ts +13 -3
- package/src/config/schemas/memory-storage.test.ts +39 -0
- package/src/config/schemas/memory-storage.ts +21 -0
- package/src/context/token-estimator.ts +2 -2
- package/src/daemon/__tests__/embedding-reconcile.test.ts +77 -0
- package/src/daemon/__tests__/lifecycle-embedding-reconcile.test.ts +3 -0
- package/src/daemon/conversation-error.ts +20 -7
- package/src/daemon/embedding-reconcile.ts +64 -1
- package/src/daemon/handlers/config-embeddings.ts +31 -0
- package/src/daemon/host-app-control-proxy.ts +2 -0
- package/src/daemon/host-bash-proxy.ts +1 -0
- package/src/daemon/host-cu-proxy.ts +6 -6
- package/src/daemon/host-file-proxy.ts +6 -6
- package/src/daemon/host-proxy-base.ts +10 -9
- package/src/daemon/host-transfer-proxy.ts +21 -24
- package/src/daemon/mcp-reload-service.ts +6 -0
- package/src/daemon/providers-setup.ts +5 -26
- package/src/heartbeat/heartbeat-service.ts +72 -13
- package/src/live-voice/__tests__/live-voice-photo.test.ts +72 -0
- package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +81 -0
- package/src/live-voice/live-voice-photo.ts +68 -4
- package/src/live-voice/live-voice-session.ts +36 -2
- package/src/live-voice/protocol.ts +98 -1
- package/src/mcp/__tests__/reload-signal-emission.test.ts +54 -0
- package/src/mcp/__tests__/reload-signal.test.ts +57 -0
- package/src/mcp/__tests__/startup.test.ts +150 -0
- package/src/mcp/reload-signal.ts +50 -0
- package/src/mcp/startup.ts +101 -0
- package/src/messaging/providers/outlook/adapter.ts +2 -13
- package/src/messaging/providers/outlook/client.ts +13 -1
- package/src/messaging/providers/outlook/types.ts +3 -0
- package/src/notifications/__tests__/decision-engine.test.ts +79 -6
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +27 -0
- package/src/notifications/__tests__/schedule-result-producer.test.ts +397 -0
- package/src/notifications/copy-composer.ts +33 -11
- package/src/notifications/decision-engine.ts +48 -11
- package/src/notifications/events-store.ts +68 -2
- package/src/notifications/home-feed-side-effect.ts +15 -3
- package/src/notifications/schedule-result-producer.ts +275 -0
- package/src/notifications/signal.ts +5 -0
- package/src/oauth/seed-providers.ts +3 -2
- package/src/persistence/conversation-crud.ts +38 -0
- package/src/persistence/embeddings/embedding-backend.test.ts +162 -0
- package/src/persistence/embeddings/embedding-backend.ts +148 -11
- package/src/persistence/embeddings/embedding-openai.test.ts +113 -0
- package/src/persistence/embeddings/embedding-openai.ts +55 -4
- package/src/persistence/embeddings/embedding-types.test.ts +24 -0
- package/src/persistence/embeddings/embedding-types.ts +25 -1
- package/src/persistence/job-utils.ts +6 -2
- package/src/plugin-api/index.ts +2 -1
- package/src/plugin-api/vision-support.test.ts +81 -0
- package/src/plugin-api/vision-support.ts +57 -10
- package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +8 -0
- package/src/plugins/defaults/memory/jobs/__tests__/embed-concept-page.test.ts +1 -0
- package/src/plugins/defaults/memory/jobs/embed-concept-page.ts +11 -3
- package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +20 -0
- package/src/plugins/defaults/memory/src/memory-worker-routes.ts +3 -1
- package/src/plugins/defaults/memory/substrate/__tests__/reembed-job.test.ts +1 -0
- package/src/plugins/defaults/memory/v2/__tests__/backfill-jobs.test.ts +1 -0
- package/src/plugins/defaults/memory/v3/__tests__/section-dense-store.test.ts +82 -0
- package/src/plugins/defaults/memory/v3/section-dense-store.ts +9 -27
- package/src/providers/__tests__/provider-secret-catalog.test.ts +4 -0
- package/src/providers/__tests__/xml-tool-call-salvage.test.ts +172 -0
- package/src/providers/anthropic/client.ts +9 -4
- package/src/providers/inline-audio-support.test.ts +34 -0
- package/src/providers/inline-audio-support.ts +27 -0
- package/src/providers/model-catalog.ts +22 -2
- package/src/providers/openai/__tests__/api-error-normalization.test.ts +50 -0
- package/src/providers/openai/__tests__/chat-completions-extra-body.test.ts +51 -0
- package/src/providers/openai/__tests__/chat-completions-provider-xml-salvage.test.ts +291 -0
- package/src/providers/openai/api-error-normalization.ts +18 -3
- package/src/providers/openai/chat-completions-provider.ts +113 -13
- package/src/providers/openai/responses-provider.ts +10 -8
- package/src/providers/provider-secret-catalog.ts +11 -1
- package/src/providers/vellum/client.ts +14 -1
- package/src/providers/vellum/personality-directions.test.ts +163 -0
- package/src/providers/vellum/personality-directions.ts +120 -0
- package/src/providers/xml-tool-call-salvage.ts +212 -0
- package/src/runtime/__tests__/background-job-runner.test.ts +42 -4
- package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -6
- package/src/runtime/auth/__tests__/route-policy.test.ts +65 -0
- package/src/runtime/auth/same-actor.ts +29 -5
- package/src/runtime/background-job-runner.ts +38 -29
- package/src/runtime/guardian-action-service.ts +20 -1
- package/src/runtime/migrations/vbundle-validator.ts +3 -3
- package/src/runtime/pending-interactions.ts +4 -5
- package/src/runtime/routes/__tests__/heartbeat-routes.test.ts +24 -0
- package/src/runtime/routes/__tests__/platform-credits-routes.test.ts +99 -5
- package/src/runtime/routes/conversation-query-routes.ts +53 -11
- package/src/runtime/routes/document-comments-routes.ts +8 -2
- package/src/runtime/routes/guardian-action-routes.ts +7 -0
- package/src/runtime/routes/heartbeat-routes.ts +41 -53
- package/src/runtime/routes/host-app-control-routes.ts +10 -36
- package/src/runtime/routes/host-bash-routes.ts +11 -45
- package/src/runtime/routes/host-browser-routes.ts +24 -0
- package/src/runtime/routes/host-cu-routes.ts +11 -45
- package/src/runtime/routes/host-file-routes.ts +11 -44
- package/src/runtime/routes/host-proxy-result-binding.ts +57 -0
- package/src/runtime/routes/host-transfer-routes.ts +33 -25
- package/src/runtime/routes/index.ts +0 -2
- package/src/runtime/routes/integrations/a2a.ts +12 -3
- package/src/runtime/routes/integrations/vercel.ts +13 -3
- package/src/runtime/routes/platform-routes.ts +121 -24
- package/src/runtime/routes/schedule-routes.ts +15 -1
- package/src/schedule/__tests__/schedule-timezone.test.ts +21 -3
- package/src/schedule/__tests__/worker-mcp-tools.test.ts +88 -0
- package/src/schedule/schedule-timezone.ts +25 -0
- package/src/schedule/scheduler.ts +19 -0
- package/src/schedule/worker.ts +119 -2
- package/src/skills/catalog-install.ts +40 -5
- package/src/tools/browser/__tests__/browser-status.test.ts +3 -0
- package/src/tools/browser/browser-execution.ts +117 -53
- package/src/tools/browser/browser-status-constants.ts +6 -6
- package/src/tools/capability-offer.test.ts +87 -0
- package/src/tools/capability-offer.ts +93 -0
- package/src/tools/terminal/__tests__/safe-env.test.ts +17 -0
- package/src/tools/terminal/safe-env.ts +3 -2
- package/src/util/provider-error-patterns.ts +34 -0
- package/src/watcher/__tests__/engine.test.ts +5 -2
- package/src/watcher/engine.ts +7 -4
- package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +0 -487
- 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.
|
|
228
|
-
*
|
|
229
|
-
*
|
|
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
|
|
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
|
|
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
|
-
"-
|
|
237
|
-
"-
|
|
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
|
|
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
|
|
242
|
-
"- Body 'The nightly backup job failed on db-primary at 02:14' -> 'Nightly
|
|
243
|
-
"- Body 'Alice replied about the Q3 pricing deck and wants your notes' -> 'Q3
|
|
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 {
|
|
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
|
-
|
|
452
|
-
|
|
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
|
-
//
|
|
1290
|
-
// Stripe mark
|
|
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,
|