@vellumai/assistant 0.11.1 → 0.11.2-staging.2
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/Dockerfile +1 -3
- package/README.md +1 -1
- package/eslint.config.mjs +28 -8
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/slack-text/src/index.test.ts +48 -0
- package/node_modules/@vellumai/slack-text/src/index.ts +25 -6
- package/openapi.yaml +292 -5
- package/package.json +1 -1
- package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
- package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
- package/src/__tests__/cli-logger-boundary-guard.test.ts +87 -0
- package/src/__tests__/compaction-events.test.ts +139 -2
- package/src/__tests__/config-loader-backfill.test.ts +35 -3
- package/src/__tests__/config-schema.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
- package/src/__tests__/conversation-agent-loop.test.ts +180 -7
- package/src/__tests__/conversation-error.test.ts +211 -4
- package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
- package/src/__tests__/conversation-queue.test.ts +126 -0
- package/src/__tests__/conversation-retry-route.test.ts +46 -7
- package/src/__tests__/conversation-slash.test.ts +4 -2
- package/src/__tests__/conversation-summarize-route.test.ts +7 -1
- package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
- package/src/__tests__/document-create-dedupe.test.ts +2 -1
- package/src/__tests__/document-find-replace.test.ts +2 -1
- package/src/__tests__/document-tool-security.test.ts +2 -1
- package/src/__tests__/document-update-default-surface.test.ts +2 -1
- package/src/__tests__/document-workspace-file.test.ts +467 -0
- package/src/__tests__/emit-signal-routing-intent.test.ts +290 -49
- package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
- package/src/__tests__/http-user-message-parity.test.ts +66 -0
- package/src/__tests__/list-messages-attachments.test.ts +87 -0
- package/src/__tests__/list-messages-provider-error.test.ts +143 -0
- package/src/__tests__/list-messages-system-card.test.ts +100 -0
- package/src/__tests__/llm-resolver.test.ts +19 -9
- package/src/__tests__/managed-profile-guard.test.ts +23 -0
- package/src/__tests__/notification-platform-adapter.test.ts +130 -2
- package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
- package/src/__tests__/notification-vellum-adapter.test.ts +45 -0
- package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
- package/src/__tests__/provider-error-scenarios.test.ts +140 -0
- package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
- package/src/__tests__/run-conversation-turn-persistence.test.ts +66 -3
- package/src/__tests__/scripted-turn-metadata-persistence.test.ts +209 -0
- package/src/__tests__/skills.test.ts +27 -0
- package/src/__tests__/slack-channels-routes.test.ts +0 -2
- package/src/__tests__/slack-share-routes.test.ts +0 -3
- package/src/__tests__/slack-users-routes.test.ts +0 -2
- package/src/__tests__/subagent-tools.test.ts +11 -0
- package/src/__tests__/tool-preview-lifecycle.test.ts +58 -0
- package/src/__tests__/tool-result-spool.test.ts +5 -4
- package/src/__tests__/turn-boundary-resolution.test.ts +53 -0
- package/src/__tests__/turn-events-store.test.ts +26 -0
- package/src/__tests__/ui-visual-surface.test.ts +695 -0
- package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
- package/src/__tests__/visible-app-context.test.ts +189 -0
- package/src/__tests__/workspace-git-service.test.ts +173 -33
- package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +157 -0
- package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
- package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
- package/src/agent/loop.ts +36 -7
- package/src/api/events/context-window-usage.ts +31 -0
- package/src/api/events/notification-intent.ts +8 -0
- package/src/api/events/ui-surface-pending.ts +35 -0
- package/src/api/index.ts +14 -0
- package/src/api/responses/conversation-message.ts +25 -4
- package/src/api/surfaces.ts +90 -1
- package/src/approvals/guardian-card-withdrawal.ts +66 -31
- package/src/approvals/guardian-decision-primitive.ts +4 -0
- package/src/calls/__tests__/call-setup-router.test.ts +156 -26
- package/src/calls/__tests__/voice-session-bridge.test.ts +201 -12
- package/src/calls/call-setup-router.ts +80 -36
- package/src/calls/voice-session-bridge.ts +173 -26
- package/src/cli/commands/inference.help.ts +3 -3
- package/src/cli/commands/notifications.help.ts +3 -2
- package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +42 -128
- package/src/cli/commands/platform/__tests__/credits.test.ts +9 -78
- package/src/cli/commands/platform/__tests__/helpers.ts +90 -0
- package/src/cli/commands/platform/__tests__/invoices.test.ts +238 -0
- package/src/cli/commands/platform/__tests__/plans.test.ts +9 -88
- package/src/cli/commands/platform/__tests__/status.test.ts +12 -87
- package/src/cli/commands/platform/__tests__/subscription.test.ts +9 -86
- package/src/cli/commands/platform/index.help.ts +92 -0
- package/src/cli/commands/platform/index.ts +7 -0
- package/src/cli/commands/platform/invoices.ts +132 -0
- package/src/cli/commands/usage.help.ts +1 -1
- package/src/cli/lib/list-installed-plugins.ts +2 -1
- package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
- package/src/config/__tests__/deployment-context-defaults.test.ts +27 -5
- package/src/config/assistant-feature-flags.ts +7 -2
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -2
- package/src/config/bundled-skills/subagent/SKILL.md +3 -1
- package/src/config/bundled-skills/subagent/TOOLS.json +3 -3
- package/src/config/bundled-skills/visualize/SKILL.md +163 -0
- package/src/config/call-site-defaults.ts +3 -2
- package/src/config/default-profile-catalog.ts +75 -70
- package/src/config/default-profile-names.ts +13 -28
- package/src/config/env-registry.ts +1 -0
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/llm-resolver.ts +21 -5
- package/src/config/loader.ts +18 -11
- package/src/config/schemas/llm.ts +1 -9
- package/src/config/schemas/memory-retrospective.ts +9 -0
- package/src/config/schemas/monitoring.ts +28 -2
- package/src/config/schemas/workspace-git.ts +15 -0
- package/src/config/seed-inference-profiles.ts +19 -5
- package/src/context/post-turn-tool-result-truncation.ts +2 -2
- package/src/conversations/__tests__/message-consolidation.test.ts +54 -0
- package/src/conversations/message-consolidation.ts +17 -15
- package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +182 -0
- package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +181 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +90 -7
- package/src/daemon/conversation-agent-loop.ts +107 -22
- package/src/daemon/conversation-error.ts +169 -48
- package/src/daemon/conversation-messaging.ts +87 -4
- package/src/daemon/conversation-process.ts +57 -45
- package/src/daemon/conversation-runtime-assembly.ts +57 -0
- package/src/daemon/conversation-store.ts +36 -1
- package/src/daemon/conversation-surfaces.ts +68 -6
- package/src/daemon/conversation-turn-finalize.ts +76 -18
- package/src/daemon/conversation.ts +122 -27
- package/src/daemon/lifecycle.ts +9 -0
- package/src/daemon/message-types/conversations.ts +8 -0
- package/src/daemon/message-types/surfaces.ts +3 -0
- package/src/documents/document-store.ts +247 -10
- package/src/home/__tests__/feed-types.test.ts +43 -8
- package/src/home/__tests__/feed-writer.test.ts +59 -0
- package/src/home/feed-types.ts +34 -7
- package/src/home/feed-writer.ts +15 -6
- package/src/live-voice/__tests__/activity-label.test.ts +95 -0
- package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +310 -25
- package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
- package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
- package/src/live-voice/activity-label.ts +169 -0
- package/src/live-voice/live-activity-reporter.ts +35 -6
- package/src/live-voice/live-voice-session.ts +282 -41
- package/src/live-voice/protocol.ts +35 -0
- package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +84 -2
- package/src/messaging/providers/slack/adapter.ts +86 -19
- package/src/messaging/providers/slack/api.test.ts +85 -1
- package/src/messaging/providers/slack/api.ts +121 -288
- package/src/messaging/providers/slack/client.ts +20 -251
- package/src/messaging/providers/slack/send.test.ts +4 -9
- package/src/messaging/providers/slack/send.ts +1 -1
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/messaging/providers/slack/web-api-transport.test.ts +181 -0
- package/src/messaging/providers/slack/web-api-transport.ts +367 -0
- package/src/messaging/providers/slack/withdraw.ts +6 -14
- package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
- package/src/messaging/providers/telegram-bot/send.ts +23 -2
- package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
- package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
- package/src/monitoring/__tests__/db-integrity-sample.test.ts +18 -4
- package/src/monitoring/__tests__/file-descriptors.test.ts +144 -0
- package/src/monitoring/file-descriptors.ts +262 -0
- package/src/monitoring/process-memory.ts +4 -11
- package/src/monitoring/resource-sampler.ts +5 -0
- package/src/monitoring/worker.ts +12 -0
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +740 -0
- package/src/notifications/__tests__/broadcaster.test.ts +347 -7
- package/src/notifications/__tests__/copy-composer.test.ts +53 -3
- package/src/notifications/__tests__/decision-engine.test.ts +349 -29
- package/src/notifications/__tests__/deterministic-checks.test.ts +21 -0
- package/src/notifications/__tests__/edit-notification.test.ts +330 -0
- package/src/notifications/__tests__/guardian-delivery-recorder.test.ts +71 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
- package/src/notifications/adapters/macos.ts +3 -0
- package/src/notifications/adapters/platform.ts +90 -14
- package/src/notifications/adapters/telegram.ts +6 -4
- package/src/notifications/assistant-reply-producer.ts +219 -0
- package/src/notifications/broadcaster.ts +523 -317
- package/src/notifications/copy-composer.ts +27 -6
- package/src/notifications/decision-engine.ts +154 -77
- package/src/notifications/deterministic-checks.ts +6 -7
- package/src/notifications/edit-notification.ts +8 -4
- package/src/notifications/emit-signal.ts +49 -16
- package/src/notifications/guardian-delivery-recorder.ts +8 -2
- package/src/notifications/home-feed-side-effect.ts +43 -6
- package/src/notifications/notification-utils.ts +39 -4
- package/src/notifications/signal.ts +10 -0
- package/src/notifications/types.ts +17 -0
- package/src/persistence/bookmark-crud.ts +18 -9
- package/src/persistence/conversation-attention-store.ts +26 -0
- package/src/persistence/conversation-crud.ts +79 -32
- package/src/persistence/conversation-queries.ts +30 -15
- package/src/persistence/conversation-title-service.ts +5 -170
- package/src/persistence/conversation-types.ts +192 -1
- package/src/persistence/db-init.ts +14 -1
- package/src/persistence/db-maintenance.ts +5 -4
- package/src/persistence/embeddings/__tests__/plugin-index-qdrant-init.test.ts +219 -0
- package/src/persistence/embeddings/__tests__/plugin-index.test.ts +22 -0
- package/src/persistence/embeddings/__tests__/worker-script-version.test.ts +76 -0
- package/src/persistence/embeddings/embedding-local.ts +2 -1
- package/src/persistence/embeddings/embedding-runtime-manager.ts +29 -5
- package/src/persistence/embeddings/plugin-index.ts +84 -12
- package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
- package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
- package/src/persistence/planner-statistics.ts +14 -0
- package/src/persistence/schema/documents.ts +26 -11
- package/src/persistence/steps.ts +2 -0
- package/src/platform/client.test.ts +172 -2
- package/src/platform/client.ts +178 -55
- package/src/plugin-api/conversation-turn.ts +13 -0
- package/src/plugin-api/model-profiles.test.ts +6 -2
- package/src/plugins/defaults/memory/__tests__/bookmark-crud.test.ts +34 -0
- package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +22 -0
- package/src/plugins/defaults/memory/__tests__/jobs-store-enqueue-gate.test.ts +11 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +199 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +98 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +105 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +24 -2
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +524 -0
- package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +1 -1
- package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +9 -4
- package/src/plugins/defaults/memory/host-utils.ts +5 -0
- package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +105 -1
- package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +64 -5
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +36 -1
- package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +12 -3
- package/src/plugins/defaults/memory/src/__tests__/memory-v2-simulate-route.test.ts +1 -0
- package/src/plugins/defaults/memory/substrate/__tests__/skill-store.test.ts +195 -1
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +1 -1
- package/src/plugins/defaults/memory/substrate/skill-store.ts +167 -29
- package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +131 -0
- package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +1 -0
- package/src/plugins/defaults/memory/v2/activation-log-store.ts +4 -0
- package/src/plugins/defaults/memory/v2/injection.ts +48 -3
- package/src/plugins/defaults/memory/v2/rerank-local.ts +6 -2
- package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -1
- package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
- package/src/plugins/defaults/turn-context/injectors.ts +1 -0
- package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
- package/src/plugins/types.ts +17 -0
- package/src/prompts/templates/system-sections.ts +2 -2
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
- package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
- package/src/providers/call-site-routing.ts +77 -14
- package/src/providers/connection-resolution.ts +42 -2
- package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
- package/src/providers/inference/adapter-factory.ts +70 -12
- package/src/providers/model-catalog.ts +0 -11
- package/src/providers/model-intents.ts +11 -0
- package/src/providers/registry.ts +8 -0
- package/src/providers/retry.ts +85 -17
- package/src/providers/types.ts +8 -1
- package/src/runtime/__tests__/agent-wake.test.ts +181 -0
- package/src/runtime/agent-wake.ts +132 -9
- package/src/runtime/channel-approval-types.ts +25 -0
- package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
- package/src/runtime/routes/__tests__/platform-invoice-routes.test.ts +392 -0
- package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +7 -0
- package/src/runtime/routes/__tests__/workspace-commit-routes.test.ts +63 -0
- package/src/runtime/routes/consolidation-routes.ts +1 -1
- package/src/runtime/routes/conversation-management-routes.ts +18 -6
- package/src/runtime/routes/conversation-query-routes.ts +14 -23
- package/src/runtime/routes/conversation-routes.ts +113 -8
- package/src/runtime/routes/credential-routes.ts +1 -5
- package/src/runtime/routes/documents-routes.ts +207 -12
- package/src/runtime/routes/identity-routes.ts +3 -93
- package/src/runtime/routes/inbound-stages/admission-policy.test.ts +31 -1
- package/src/runtime/routes/inbound-stages/admission-policy.ts +18 -0
- package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
- package/src/runtime/routes/notification-routes.ts +4 -1
- package/src/runtime/routes/platform-routes.ts +238 -3
- package/src/runtime/routes/playground/guard.ts +1 -2
- package/src/runtime/routes/slack-channel-routes.ts +2 -4
- package/src/runtime/routes/workspace-commit-routes.ts +49 -2
- package/src/runtime/routes/workspace-routes.ts +11 -12
- package/src/runtime/routes/workspace-utils.ts +49 -2
- package/src/runtime/services/conversation-serializer.ts +2 -4
- package/src/subagent/__tests__/consult-context-gating.test.ts +98 -0
- package/src/subagent/__tests__/consult-context-skills.test.ts +82 -0
- package/src/subagent/__tests__/consult-context.test.ts +84 -0
- package/src/subagent/__tests__/consult-prompt.test.ts +36 -0
- package/src/subagent/consult-context.ts +410 -0
- package/src/subagent/consult-prompt.ts +38 -5
- package/src/subagent/manager.ts +9 -4
- package/src/subagent/types.ts +8 -0
- package/src/telemetry/telemetry-event-sources.ts +7 -0
- package/src/telemetry/telemetry-wire-source.json +1 -1
- package/src/telemetry/telemetry-wire.generated.ts +1 -0
- package/src/telemetry/turn-events-store.ts +30 -1
- package/src/telemetry/types.ts +24 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
- package/src/tools/browser/pinned-tabs.ts +3 -1
- package/src/tools/subagent/spawn.ts +23 -1
- package/src/tools/terminal/safe-env.ts +1 -0
- package/src/tools/ui-surface/definitions.ts +39 -1
- package/src/tools/ui-surface/surface-shape-docs.ts +38 -4
- package/src/tools/ui-surface/visual-validation.ts +787 -0
- package/src/tools/workflows/run-workflow.test.ts +1 -0
- package/src/util/__tests__/short-title.test.ts +229 -0
- package/src/util/__tests__/worker-compute.test.ts +65 -0
- package/src/util/cgroup-cpu.ts +93 -0
- package/src/util/errors.ts +27 -0
- package/src/util/process-tree.ts +19 -0
- package/src/util/short-title.ts +189 -0
- package/src/util/worker-compute.ts +83 -0
- package/src/workspace/byok-default-profile-ensure.ts +25 -15
- package/src/workspace/git-service.ts +79 -30
- package/src/workspace/migrations/137-repair-retired-fireworks-minimax-model-id.ts +131 -0
- package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
- package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
- package/src/workspace/migrations/registry.ts +6 -0
- package/src/plugins/defaults/memory/substrate/constants.ts +0 -8
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* values from the context payload.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { normalizeTitle } from "../util/short-title.js";
|
|
12
13
|
import {
|
|
13
14
|
accessRequestCardTitle,
|
|
14
15
|
buildAccessRequestContractText,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
} from "./guardian-question-mode.js";
|
|
27
28
|
import {
|
|
28
29
|
nonEmpty,
|
|
30
|
+
NOTIFICATION_TITLE_MAX_LENGTH,
|
|
29
31
|
readPayloadString,
|
|
30
32
|
sanitizeIdentityField,
|
|
31
33
|
} from "./notification-utils.js";
|
|
@@ -48,17 +50,35 @@ function str(value: unknown, fallback: string): string {
|
|
|
48
50
|
/**
|
|
49
51
|
* Derive a short notification title from a message body. Trims to the
|
|
50
52
|
* first sentence terminator when present, then caps the result at
|
|
51
|
-
*
|
|
53
|
+
* `NOTIFICATION_TITLE_MAX_LENGTH` characters with an ellipsis.
|
|
52
54
|
*/
|
|
53
55
|
export function deriveTitle(body: string): string {
|
|
54
56
|
const firstSentenceEnd = body.search(/[.!?](\s|$)/);
|
|
55
57
|
const candidate =
|
|
56
58
|
firstSentenceEnd > 0 ? body.slice(0, firstSentenceEnd + 1) : body;
|
|
57
|
-
return candidate.length >
|
|
58
|
-
? candidate.slice(0,
|
|
59
|
+
return candidate.length > NOTIFICATION_TITLE_MAX_LENGTH
|
|
60
|
+
? candidate.slice(0, NOTIFICATION_TITLE_MAX_LENGTH).trim() + "\u2026"
|
|
59
61
|
: candidate.trim();
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Clean a producer-authored title, falling back to one derived from the body
|
|
66
|
+
* when `normalizeTitle` returns its empty-string rejection signal.
|
|
67
|
+
*
|
|
68
|
+
* The decision engine's pass-through path applies the same clamp, so a signal
|
|
69
|
+
* carrying `requestedTitle` renders the same headline whether or not the LLM
|
|
70
|
+
* classifier was reachable.
|
|
71
|
+
*
|
|
72
|
+
* The two branches carry different budgets. An accepted authored title is
|
|
73
|
+
* bounded by `normalizeTitle` at 40 characters. A rejected one falls through to
|
|
74
|
+
* `deriveTitle`, which never sees the normalizer and applies
|
|
75
|
+
* `NOTIFICATION_TITLE_MAX_LENGTH` (60) plus a trailing ellipsis, so a derived
|
|
76
|
+
* title can reach 61 characters.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveTitle(raw: string | undefined, body: string): string {
|
|
79
|
+
return normalizeTitle(raw ?? "") || deriveTitle(body);
|
|
80
|
+
}
|
|
81
|
+
|
|
62
82
|
/**
|
|
63
83
|
* Build a display label for a trusted-contact actor (requester or decider),
|
|
64
84
|
* preferring the display name, then a Slack `<@id>` mention for raw Slack user
|
|
@@ -290,9 +310,10 @@ export function composeFallbackCopy(
|
|
|
290
310
|
readPayloadString(signal.contextPayload, "requestedMessage"),
|
|
291
311
|
);
|
|
292
312
|
if (msg) {
|
|
293
|
-
const title =
|
|
294
|
-
|
|
295
|
-
|
|
313
|
+
const title = resolveTitle(
|
|
314
|
+
readPayloadString(signal.contextPayload, "requestedTitle"),
|
|
315
|
+
msg,
|
|
316
|
+
);
|
|
296
317
|
const baseCopy: RenderedChannelCopy = {
|
|
297
318
|
title,
|
|
298
319
|
body: msg,
|
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
type ConversationCandidateSet,
|
|
45
45
|
serializeCandidatesForPrompt,
|
|
46
46
|
} from "./conversation-candidates.js";
|
|
47
|
-
import { composeFallbackCopy,
|
|
47
|
+
import { composeFallbackCopy, resolveTitle } from "./copy-composer.js";
|
|
48
48
|
import { createDecision } from "./decisions-store.js";
|
|
49
49
|
import {
|
|
50
50
|
buildGuardianRequestCodeInstruction,
|
|
@@ -54,7 +54,11 @@ import {
|
|
|
54
54
|
stripConflictingGuardianRequestInstructions,
|
|
55
55
|
stripGuardianRequestCodeInstructions,
|
|
56
56
|
} from "./guardian-question-mode.js";
|
|
57
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
nonEmpty,
|
|
59
|
+
readPayloadObject,
|
|
60
|
+
readPayloadString,
|
|
61
|
+
} from "./notification-utils.js";
|
|
58
62
|
import { getPreferenceSummary } from "./preference-summary.js";
|
|
59
63
|
import type { NotificationSignal, RoutingIntent } from "./signal.js";
|
|
60
64
|
import type {
|
|
@@ -78,6 +82,16 @@ const PROMPT_VERSION = "v4";
|
|
|
78
82
|
*/
|
|
79
83
|
const MAX_IDENTITY_CONTEXT_CHARS = 2000;
|
|
80
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Delivery scope for `chat.assistant_reply` signals: the platform channel is a
|
|
87
|
+
* push_only relay that never materializes a conversation, so these signals
|
|
88
|
+
* reach the user exclusively as a push. Adding a channel here widens delivery
|
|
89
|
+
* to it.
|
|
90
|
+
*/
|
|
91
|
+
const ASSISTANT_REPLY_CHANNELS = [
|
|
92
|
+
"platform",
|
|
93
|
+
] as const satisfies readonly NotificationChannel[];
|
|
94
|
+
|
|
81
95
|
// ── System prompt ──────────────────────────────────────────────────────
|
|
82
96
|
|
|
83
97
|
function buildSystemPrompt(
|
|
@@ -215,6 +229,26 @@ function buildUserPrompt(signal: NotificationSignal): string {
|
|
|
215
229
|
|
|
216
230
|
// ── Tool definition ────────────────────────────────────────────────────
|
|
217
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Spec for the per-channel notification title. Mirrors the conversation title
|
|
234
|
+
* prompt (`persistence/conversation-title-service.ts`), which gets clean
|
|
235
|
+
* noun-phrase headlines out of this same model profile.
|
|
236
|
+
*/
|
|
237
|
+
const TITLE_FIELD_DESCRIPTION = [
|
|
238
|
+
"Scannable headline naming the TOPIC of this notification, not a summary of it.",
|
|
239
|
+
"Rules:",
|
|
240
|
+
"- 2 to 5 words. Longer titles are unacceptable, ruthlessly compress",
|
|
241
|
+
"- 40 characters absolute maximum, longer titles get truncated and look broken",
|
|
242
|
+
"- A noun phrase naming the topic, never a sentence, question, or greeting (e.g. 'Platform Standup', 'Nightly Backup Failure')",
|
|
243
|
+
"- Do NOT restate, summarize, or echo the body. The title and the body must carry different information",
|
|
244
|
+
"- No quotes, no markdown, no trailing punctuation",
|
|
245
|
+
"- Never describe missing or thin context. Titles like 'Notification', 'Update', 'Missing Context' are forbidden. Extract a topic from the words that ARE present",
|
|
246
|
+
"Examples:",
|
|
247
|
+
"- Body 'Your 9am standup with the platform team starts in 5 minutes' -> 'Platform Standup', NOT 'Standup Starts In 5 Minutes'",
|
|
248
|
+
"- Body 'The nightly backup job failed on db-primary at 02:14' -> 'Nightly Backup Failure', NOT 'Nightly Backup Job Failed On db-primary'",
|
|
249
|
+
"- Body 'Alice replied about the Q3 pricing deck and wants your notes' -> 'Q3 Pricing Deck', NOT 'Alice Replied About The Pricing Deck'",
|
|
250
|
+
].join("\n");
|
|
251
|
+
|
|
218
252
|
function buildDecisionTool(availableChannels: NotificationChannel[]) {
|
|
219
253
|
return {
|
|
220
254
|
name: "record_notification_decision",
|
|
@@ -250,7 +284,7 @@ function buildDecisionTool(availableChannels: NotificationChannel[]) {
|
|
|
250
284
|
properties: {
|
|
251
285
|
title: {
|
|
252
286
|
type: "string",
|
|
253
|
-
description:
|
|
287
|
+
description: TITLE_FIELD_DESCRIPTION,
|
|
254
288
|
},
|
|
255
289
|
body: {
|
|
256
290
|
type: "string",
|
|
@@ -431,7 +465,7 @@ function validateDecisionOutput(
|
|
|
431
465
|
);
|
|
432
466
|
}
|
|
433
467
|
renderedCopy[ch] = {
|
|
434
|
-
title: c.title,
|
|
468
|
+
title: resolveTitle(c.title, c.body),
|
|
435
469
|
body: c.body,
|
|
436
470
|
deliveryText:
|
|
437
471
|
typeof c.deliveryText === "string" ? c.deliveryText : undefined,
|
|
@@ -855,6 +889,70 @@ function ensureInviteFlowDirectiveInCopy(
|
|
|
855
889
|
};
|
|
856
890
|
}
|
|
857
891
|
|
|
892
|
+
// ── Producer pass-through decisions ────────────────────────────────────
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Build, guard, and persist a decision whose copy came verbatim from the
|
|
896
|
+
* producer, bypassing the LLM classifier. The title falls back to one derived
|
|
897
|
+
* from the body when the producer supplies none or supplies one that fails
|
|
898
|
+
* normalization.
|
|
899
|
+
*
|
|
900
|
+
* `renderedCopy` and `conversationActions` are populated for every available
|
|
901
|
+
* channel, not just `selectedChannels`: downstream guards (routing-intent
|
|
902
|
+
* expansion in `enforceRoutingIntent`, urgency-forced vellum prepending in
|
|
903
|
+
* `emit-signal`) may widen `selectedChannels` afterwards, and pre-seeding
|
|
904
|
+
* keeps the verbatim message from degrading to an empty
|
|
905
|
+
* `composeFallbackCopy` body.
|
|
906
|
+
*/
|
|
907
|
+
function buildPassThroughDecision(params: {
|
|
908
|
+
signal: NotificationSignal;
|
|
909
|
+
availableChannels: NotificationChannel[];
|
|
910
|
+
selectedChannels: NotificationChannel[];
|
|
911
|
+
body: string;
|
|
912
|
+
reasoningSummary: string;
|
|
913
|
+
}): NotificationDecision {
|
|
914
|
+
const { availableChannels, body, signal } = params;
|
|
915
|
+
const title = resolveTitle(
|
|
916
|
+
readPayloadString(signal.contextPayload, "requestedTitle"),
|
|
917
|
+
body,
|
|
918
|
+
);
|
|
919
|
+
const deepLinkTarget = readPayloadObject(
|
|
920
|
+
signal.contextPayload,
|
|
921
|
+
"deepLinkMetadata",
|
|
922
|
+
);
|
|
923
|
+
let decision: NotificationDecision = {
|
|
924
|
+
shouldNotify: params.selectedChannels.length > 0,
|
|
925
|
+
selectedChannels: params.selectedChannels,
|
|
926
|
+
reasoningSummary: params.reasoningSummary,
|
|
927
|
+
renderedCopy: Object.fromEntries(
|
|
928
|
+
availableChannels.map((ch) => [
|
|
929
|
+
ch,
|
|
930
|
+
{ title, body, conversationSeedMessage: body },
|
|
931
|
+
]),
|
|
932
|
+
) as NotificationDecision["renderedCopy"],
|
|
933
|
+
conversationActions: Object.fromEntries(
|
|
934
|
+
availableChannels.map((ch) => [ch, { action: "start_new" as const }]),
|
|
935
|
+
) as NotificationDecision["conversationActions"],
|
|
936
|
+
// A producer-supplied key names what it wants collapsed; the signal id is
|
|
937
|
+
// a per-emit fallback that collapses nothing.
|
|
938
|
+
dedupeKey: signal.dedupeKey ?? signal.signalId,
|
|
939
|
+
confidence: 1.0,
|
|
940
|
+
fallbackUsed: false,
|
|
941
|
+
verbatimCopy: true,
|
|
942
|
+
...(deepLinkTarget ? { deepLinkTarget } : {}),
|
|
943
|
+
};
|
|
944
|
+
decision = enforceGuardianRequestCode(decision, signal);
|
|
945
|
+
decision = enforceToolApprovalSeedBlocks(decision, signal);
|
|
946
|
+
decision = enforceAccessRequestInstructions(decision, signal);
|
|
947
|
+
decision = enforceGuardianRequestConversationAffinity(decision, signal);
|
|
948
|
+
decision = enforceConversationAffinity(
|
|
949
|
+
decision,
|
|
950
|
+
signal.conversationAffinityHint,
|
|
951
|
+
);
|
|
952
|
+
decision.persistedDecisionId = persistDecision(signal, decision);
|
|
953
|
+
return decision;
|
|
954
|
+
}
|
|
955
|
+
|
|
858
956
|
// ── Core evaluation function ───────────────────────────────────────────
|
|
859
957
|
|
|
860
958
|
export async function evaluateSignal(
|
|
@@ -862,50 +960,20 @@ export async function evaluateSignal(
|
|
|
862
960
|
availableChannels: NotificationChannel[],
|
|
863
961
|
preferenceContext?: string,
|
|
864
962
|
): Promise<NotificationDecision> {
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
let resolvedPreferenceContext = preferenceContext;
|
|
869
|
-
if (resolvedPreferenceContext === undefined) {
|
|
870
|
-
try {
|
|
871
|
-
resolvedPreferenceContext = getPreferenceSummary() ?? undefined;
|
|
872
|
-
} catch (err) {
|
|
873
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
874
|
-
log.warn(
|
|
875
|
-
{ err: errMsg },
|
|
876
|
-
"Failed to load preference summary, proceeding without preferences",
|
|
877
|
-
);
|
|
878
|
-
resolvedPreferenceContext = undefined;
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
// Build conversation candidate set for reuse decisions. Wrapped in try/catch
|
|
883
|
-
// so candidate lookup failures do not block the decision path.
|
|
884
|
-
let candidateSet: ConversationCandidateSet | undefined;
|
|
885
|
-
try {
|
|
886
|
-
candidateSet = await buildConversationCandidates(availableChannels);
|
|
887
|
-
} catch (err) {
|
|
888
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
889
|
-
log.warn(
|
|
890
|
-
{ err: errMsg },
|
|
891
|
-
"Failed to build conversation candidates, proceeding without candidates",
|
|
892
|
-
);
|
|
893
|
-
}
|
|
963
|
+
// Pass-through branches run before the preference and candidate loads
|
|
964
|
+
// below: their copy and routing are producer-supplied, so neither DB
|
|
965
|
+
// lookup can influence the outcome.
|
|
894
966
|
|
|
895
967
|
// Assistant-tool pass-through: when a producer hands us a verbatim
|
|
896
968
|
// message body via contextPayload.requestedMessage, skip the LLM
|
|
897
969
|
// classifier entirely. The producer has already done the routing and
|
|
898
|
-
// copy decisions
|
|
970
|
+
// copy decisions, we just enforce the standard post-decision guards
|
|
899
971
|
// and persist the result.
|
|
900
972
|
const requestedBody = nonEmpty(
|
|
901
973
|
readPayloadString(signal.contextPayload, "requestedMessage"),
|
|
902
974
|
);
|
|
903
975
|
if (signal.sourceChannel === "assistant_tool" && requestedBody) {
|
|
904
976
|
const payload = signal.contextPayload as Record<string, unknown>;
|
|
905
|
-
const body = requestedBody;
|
|
906
|
-
const title =
|
|
907
|
-
nonEmpty(readPayloadString(signal.contextPayload, "requestedTitle")) ??
|
|
908
|
-
deriveTitle(body);
|
|
909
977
|
const isUrgent =
|
|
910
978
|
signal.attentionHints.urgency === "critical" ||
|
|
911
979
|
signal.attentionHints.urgency === "high";
|
|
@@ -939,48 +1007,57 @@ export async function evaluateSignal(
|
|
|
939
1007
|
);
|
|
940
1008
|
}
|
|
941
1009
|
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
typeof payload.deepLinkMetadata === "object" &&
|
|
946
|
-
!Array.isArray(payload.deepLinkMetadata)
|
|
947
|
-
? (payload.deepLinkMetadata as Record<string, unknown>)
|
|
948
|
-
: undefined;
|
|
949
|
-
// Populate renderedCopy and conversationActions for every available
|
|
950
|
-
// channel — not just `selectedChannels`. Downstream guards
|
|
951
|
-
// (routing-intent expansion in `enforceRoutingIntent`, urgency-forced
|
|
952
|
-
// vellum prepending in `emit-signal`) may widen `selectedChannels`
|
|
953
|
-
// beyond what we picked here. Pre-seeding copy for all channels ensures
|
|
954
|
-
// the verbatim message survives those expansions rather than falling
|
|
955
|
-
// back to an empty `composeFallbackCopy` body.
|
|
956
|
-
let decision: NotificationDecision = {
|
|
957
|
-
shouldNotify: selectedChannels.length > 0,
|
|
1010
|
+
return buildPassThroughDecision({
|
|
1011
|
+
signal,
|
|
1012
|
+
availableChannels,
|
|
958
1013
|
selectedChannels,
|
|
1014
|
+
body: requestedBody,
|
|
959
1015
|
reasoningSummary: "assistant_tool pass-through",
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Assistant-reply pass-through: the delivery scope is fixed
|
|
1020
|
+
// (ASSISTANT_REPLY_CHANNELS), so the LLM classifier has nothing to decide.
|
|
1021
|
+
if (signal.sourceEventName === "chat.assistant_reply" && requestedBody) {
|
|
1022
|
+
return buildPassThroughDecision({
|
|
1023
|
+
signal,
|
|
1024
|
+
availableChannels,
|
|
1025
|
+
selectedChannels: ASSISTANT_REPLY_CHANNELS.filter((ch) =>
|
|
1026
|
+
availableChannels.includes(ch),
|
|
1027
|
+
),
|
|
1028
|
+
body: requestedBody,
|
|
1029
|
+
reasoningSummary: "assistant_reply pass-through",
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// When no explicit preference context is provided, load the user's
|
|
1034
|
+
// stored notification preferences from the memory-backed store.
|
|
1035
|
+
// Wrapped in try/catch so a DB failure doesn't break the decision path.
|
|
1036
|
+
let resolvedPreferenceContext = preferenceContext;
|
|
1037
|
+
if (resolvedPreferenceContext === undefined) {
|
|
1038
|
+
try {
|
|
1039
|
+
resolvedPreferenceContext = getPreferenceSummary() ?? undefined;
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1042
|
+
log.warn(
|
|
1043
|
+
{ err: errMsg },
|
|
1044
|
+
"Failed to load preference summary, proceeding without preferences",
|
|
1045
|
+
);
|
|
1046
|
+
resolvedPreferenceContext = undefined;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Build conversation candidate set for reuse decisions. Wrapped in try/catch
|
|
1051
|
+
// so candidate lookup failures do not block the decision path.
|
|
1052
|
+
let candidateSet: ConversationCandidateSet | undefined;
|
|
1053
|
+
try {
|
|
1054
|
+
candidateSet = await buildConversationCandidates(availableChannels);
|
|
1055
|
+
} catch (err) {
|
|
1056
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1057
|
+
log.warn(
|
|
1058
|
+
{ err: errMsg },
|
|
1059
|
+
"Failed to build conversation candidates, proceeding without candidates",
|
|
981
1060
|
);
|
|
982
|
-
decision.persistedDecisionId = persistDecision(signal, decision);
|
|
983
|
-
return decision;
|
|
984
1061
|
}
|
|
985
1062
|
|
|
986
1063
|
const provider = await getConfiguredProvider("notificationDecision");
|
|
@@ -269,10 +269,10 @@ function checkDedupe(
|
|
|
269
269
|
* case, require `composeFallbackCopy` to yield a non-empty body for
|
|
270
270
|
* at least one selected channel; otherwise fail-closed.
|
|
271
271
|
*
|
|
272
|
-
* The event-name-match branch is skipped for
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
272
|
+
* The event-name-match branch is skipped for decisions flagged
|
|
273
|
+
* `verbatimCopy` because the producer supplied the body verbatim, so a
|
|
274
|
+
* coincidental match with the event name is the producer's intent, not a
|
|
275
|
+
* fallback leak.
|
|
276
276
|
*/
|
|
277
277
|
function checkRenderedCopyQuality(
|
|
278
278
|
signal: NotificationSignal,
|
|
@@ -282,8 +282,7 @@ function checkRenderedCopyQuality(
|
|
|
282
282
|
return { passed: true };
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
-
const
|
|
286
|
-
decision.reasoningSummary === "assistant_tool pass-through";
|
|
285
|
+
const isPassthrough = decision.verbatimCopy === true;
|
|
287
286
|
const normalizedEventName = signal.sourceEventName
|
|
288
287
|
.replace(/[._]/g, " ")
|
|
289
288
|
.toLowerCase()
|
|
@@ -304,7 +303,7 @@ function checkRenderedCopyQuality(
|
|
|
304
303
|
reason: "rendered copy body is empty",
|
|
305
304
|
};
|
|
306
305
|
}
|
|
307
|
-
if (
|
|
306
|
+
if (isPassthrough) {
|
|
308
307
|
continue;
|
|
309
308
|
}
|
|
310
309
|
const normalizedBody = trimmedBody.toLowerCase();
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
updateDeliveryRenderedCopy,
|
|
23
23
|
} from "./deliveries-store.js";
|
|
24
24
|
import { getBroadcaster } from "./emit-signal.js";
|
|
25
|
+
import { nonEmpty } from "./notification-utils.js";
|
|
25
26
|
import type { NotificationChannel } from "./types.js";
|
|
26
27
|
|
|
27
28
|
const log = getLogger("edit-notification");
|
|
@@ -88,8 +89,12 @@ export async function editNotification(
|
|
|
88
89
|
): Promise<EditNotificationResult | null> {
|
|
89
90
|
const feedItemId = normalizeFeedItemId(params.id);
|
|
90
91
|
|
|
92
|
+
// Titles are never cleared, so a blank one is dropped before it can
|
|
93
|
+
// reach the feed patch or a channel update.
|
|
94
|
+
const title = nonEmpty(params.title);
|
|
95
|
+
|
|
91
96
|
const feedItem = await patchFeedItemContent(feedItemId, {
|
|
92
|
-
title
|
|
97
|
+
title,
|
|
93
98
|
summary: params.body,
|
|
94
99
|
urgency: params.urgency,
|
|
95
100
|
status: params.status,
|
|
@@ -102,8 +107,7 @@ export async function editNotification(
|
|
|
102
107
|
// Only edit channel messages when the user-visible text changed.
|
|
103
108
|
// Urgency/status are feed-only — pushing a channel update for those
|
|
104
109
|
// alone would re-deliver the same body and confuse the recipient.
|
|
105
|
-
const shouldUpdateChannels =
|
|
106
|
-
params.title !== undefined || params.body !== undefined;
|
|
110
|
+
const shouldUpdateChannels = title !== undefined || params.body !== undefined;
|
|
107
111
|
if (!shouldUpdateChannels) {
|
|
108
112
|
return { feedItem, channels: [] };
|
|
109
113
|
}
|
|
@@ -120,7 +124,7 @@ export async function editNotification(
|
|
|
120
124
|
|
|
121
125
|
const deliveries = findDeliveriesByDecisionId(decision.id);
|
|
122
126
|
const channels = await updateChannelDeliveries(deliveries, {
|
|
123
|
-
title
|
|
127
|
+
title,
|
|
124
128
|
body: params.body,
|
|
125
129
|
});
|
|
126
130
|
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
guardianForChannel,
|
|
19
19
|
} from "../contacts/guardian-delivery-reader.js";
|
|
20
20
|
import type { ConversationCreateType } from "../persistence/conversation-types.js";
|
|
21
|
+
import { isPlatformClientConfigured } from "../platform/client.js";
|
|
21
22
|
import { broadcastMessage } from "../runtime/assistant-event-hub.js";
|
|
22
23
|
import { getLogger } from "../util/logger.js";
|
|
23
24
|
import { VellumAdapter } from "./adapters/macos.js";
|
|
@@ -253,6 +254,7 @@ export async function emitNotificationSignal<TEventName extends string>(
|
|
|
253
254
|
contextPayload: (params.contextPayload ??
|
|
254
255
|
{}) as NotificationContextPayload<TEventName>,
|
|
255
256
|
attentionHints: params.attentionHints,
|
|
257
|
+
dedupeKey: params.dedupeKey,
|
|
256
258
|
routingIntent: params.routingIntent,
|
|
257
259
|
routingHints: params.routingHints,
|
|
258
260
|
conversationAffinityHint: params.conversationAffinityHint,
|
|
@@ -320,25 +322,55 @@ export async function emitNotificationSignal<TEventName extends string>(
|
|
|
320
322
|
|
|
321
323
|
let decision = await evaluateSignal(signal, connectedChannels);
|
|
322
324
|
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
325
|
+
// Baseline for the re-persist check below. Captured before the policy
|
|
326
|
+
// steps (2.5a/2.5b/2.5c) so any of them replacing the decision triggers
|
|
327
|
+
// the re-persist and the stored row matches what is dispatched.
|
|
328
|
+
const prePolicyDecision = decision;
|
|
329
|
+
|
|
330
|
+
// Step 2.5a: High/critical urgency signals always get both the in-app
|
|
331
|
+
// system notification (vellum) and the remote push (platform),
|
|
332
|
+
// regardless of what the decision engine selected. macOS surfaces a
|
|
333
|
+
// banner even when the app is focused, and a suspended iOS device is
|
|
334
|
+
// only reachable via APNs. Platform is only forced when the daemon has
|
|
335
|
+
// platform credentials and an assistant id -- on unbound daemons the
|
|
336
|
+
// dispatch can never succeed and would write a failed delivery row per
|
|
337
|
+
// signal. The probe is deadline-bounded internally, so a slow credential
|
|
338
|
+
// backend cannot stall the urgent dispatch.
|
|
339
|
+
//
|
|
340
|
+
// Vellum PREPENDS and platform APPENDS: the broadcaster re-sorts by
|
|
341
|
+
// dispatch rank, so selection order only matters to single_channel
|
|
342
|
+
// enforcement's first-selected fallback (step 2.5b), which must keep
|
|
343
|
+
// the in-app banner rather than a push the server may skip.
|
|
327
344
|
const urgency = signal.attentionHints.urgency;
|
|
328
345
|
if (
|
|
329
346
|
(urgency === "high" || urgency === "critical") &&
|
|
330
|
-
decision.shouldNotify
|
|
331
|
-
!decision.selectedChannels.includes("vellum")
|
|
347
|
+
decision.shouldNotify
|
|
332
348
|
) {
|
|
333
|
-
|
|
334
|
-
...decision,
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
349
|
+
const selectedChannels: NotificationChannel[] = [
|
|
350
|
+
...decision.selectedChannels,
|
|
351
|
+
];
|
|
352
|
+
const forcedChannels: NotificationChannel[] = [];
|
|
353
|
+
if (!selectedChannels.includes("vellum")) {
|
|
354
|
+
selectedChannels.unshift("vellum");
|
|
355
|
+
forcedChannels.push("vellum");
|
|
356
|
+
}
|
|
357
|
+
if (
|
|
358
|
+
!selectedChannels.includes("platform") &&
|
|
359
|
+
(await isPlatformClientConfigured())
|
|
360
|
+
) {
|
|
361
|
+
selectedChannels.push("platform");
|
|
362
|
+
forcedChannels.push("platform");
|
|
363
|
+
}
|
|
364
|
+
if (forcedChannels.length > 0) {
|
|
365
|
+
decision = {
|
|
366
|
+
...decision,
|
|
367
|
+
selectedChannels,
|
|
368
|
+
reasoningSummary: `${decision.reasoningSummary} (${forcedChannels.join(", ")} forced: ${urgency} urgency)`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
338
371
|
}
|
|
339
372
|
|
|
340
373
|
// Step 2.5b: Enforce routing intent policy (fire-time guard)
|
|
341
|
-
const preEnforcementDecision = decision;
|
|
342
374
|
decision = enforceRoutingIntent(
|
|
343
375
|
decision,
|
|
344
376
|
signal.routingIntent,
|
|
@@ -368,9 +400,10 @@ export async function emitNotificationSignal<TEventName extends string>(
|
|
|
368
400
|
};
|
|
369
401
|
}
|
|
370
402
|
|
|
371
|
-
// Re-persist the decision if
|
|
372
|
-
//
|
|
373
|
-
|
|
403
|
+
// Re-persist the decision if any policy step changed it (urgency channel
|
|
404
|
+
// forcing, routing intent enforcement, or the access-request vellum
|
|
405
|
+
// floor), so the stored decision row matches what is actually dispatched.
|
|
406
|
+
if (decision !== prePolicyDecision && decision.persistedDecisionId) {
|
|
374
407
|
try {
|
|
375
408
|
updateDecision(decision.persistedDecisionId, {
|
|
376
409
|
selectedChannels: decision.selectedChannels,
|
|
@@ -384,7 +417,7 @@ export async function emitNotificationSignal<TEventName extends string>(
|
|
|
384
417
|
} catch (err) {
|
|
385
418
|
log.warn(
|
|
386
419
|
{ err, signalId },
|
|
387
|
-
"Failed to re-persist decision after
|
|
420
|
+
"Failed to re-persist decision after policy enforcement",
|
|
388
421
|
);
|
|
389
422
|
}
|
|
390
423
|
}
|
|
@@ -91,8 +91,10 @@ export async function recordApprovalCardDelivery(
|
|
|
91
91
|
* that row's id as `vellumDeliveryId` and it is reused (only its status applied)
|
|
92
92
|
* — otherwise the vellum row is created here from the result.
|
|
93
93
|
*
|
|
94
|
-
* Every result records the internal `conversationId` the card is
|
|
95
|
-
* conversation's pending cards can be found uniformly
|
|
94
|
+
* Every addressable result records the internal `conversationId` the card is
|
|
95
|
+
* shown in, so a conversation's pending cards can be found uniformly
|
|
96
|
+
* regardless of channel. Platform push results are skipped entirely: a push
|
|
97
|
+
* has no channel-native card surface to address back to.
|
|
96
98
|
* Channel results additionally carry the chat (`destination`) and channel-native
|
|
97
99
|
* id (`messageId`) used to match inbound replies/reactions; a blank `destination`
|
|
98
100
|
* is recorded as unknown rather than persisting the literal channel name as a
|
|
@@ -112,6 +114,10 @@ export async function recordGuardianRequestDeliveries(params: {
|
|
|
112
114
|
let vellumDeliveryId = params.vellumDeliveryId;
|
|
113
115
|
|
|
114
116
|
for (const result of deliveryResults) {
|
|
117
|
+
if (result.channel === "platform") {
|
|
118
|
+
// A push is not an independently addressable card surface -- no row.
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
115
121
|
let deliveryId: string | undefined;
|
|
116
122
|
if (result.channel === "vellum") {
|
|
117
123
|
if (!vellumDeliveryId) {
|
|
@@ -20,7 +20,9 @@ import { appendFeedItem } from "../home/feed-writer.js";
|
|
|
20
20
|
import { getConversation } from "../persistence/conversation-crud.js";
|
|
21
21
|
import { isBackgroundConversationType } from "../persistence/conversation-types.js";
|
|
22
22
|
import { getLogger } from "../util/logger.js";
|
|
23
|
+
import { normalizeTitle, stripMarkdown } from "../util/short-title.js";
|
|
23
24
|
import { isConversationSeedSane } from "./conversation-seed-composer.js";
|
|
25
|
+
import { deriveTitle } from "./copy-composer.js";
|
|
24
26
|
import { readPayloadString } from "./notification-utils.js";
|
|
25
27
|
import type { NotificationSignal } from "./signal.js";
|
|
26
28
|
import type { NotificationDecision, RenderedChannelCopy } from "./types.js";
|
|
@@ -66,11 +68,6 @@ export async function writeHomeFeedItemForSignal(
|
|
|
66
68
|
readPayloadString(signal.contextPayload, "body") ??
|
|
67
69
|
readPayloadString(signal.contextPayload, "requestedMessage");
|
|
68
70
|
|
|
69
|
-
// Source the title from the payload only. The LLM's `renderedCopy.title`
|
|
70
|
-
// often echoes the body when no explicit title was passed, which stutters
|
|
71
|
-
// against `summary` in the row. Leave undefined when absent; renderers
|
|
72
|
-
// fall back to `summary`.
|
|
73
|
-
const resolvedTitle = payloadTitle?.trim() || undefined;
|
|
74
71
|
// Prefer conversationSeedMessage over body for the home feed: the seed
|
|
75
72
|
// message is richer and may contain structured markdown (lists, headers,
|
|
76
73
|
// bold) that the detail panel renders. The popup-oriented `body` is
|
|
@@ -91,6 +88,15 @@ export async function writeHomeFeedItemForSignal(
|
|
|
91
88
|
return null;
|
|
92
89
|
}
|
|
93
90
|
|
|
91
|
+
// Title order: payload, then the rendered copy, then a headline derived
|
|
92
|
+
// from the summary. `normalizeTitle` returns "" for empty, prose-shaped, and
|
|
93
|
+
// meta-failure candidates; the derivation always yields something, so every
|
|
94
|
+
// feed item lands with a title.
|
|
95
|
+
const resolvedTitle =
|
|
96
|
+
normalizeTitle(payloadTitle ?? "") ||
|
|
97
|
+
normalizeTitle(renderedCopy?.title ?? "") ||
|
|
98
|
+
deriveFallbackTitle(resolvedSummary);
|
|
99
|
+
|
|
94
100
|
const urgency = signal.attentionHints.urgency;
|
|
95
101
|
const now = new Date().toISOString();
|
|
96
102
|
|
|
@@ -122,7 +128,7 @@ export async function writeHomeFeedItemForSignal(
|
|
|
122
128
|
id: `notif:${signal.signalId}`,
|
|
123
129
|
type: "notification",
|
|
124
130
|
priority: 50,
|
|
125
|
-
|
|
131
|
+
title: resolvedTitle,
|
|
126
132
|
summary: resolvedSummary,
|
|
127
133
|
timestamp: now,
|
|
128
134
|
createdAt: now,
|
|
@@ -150,6 +156,37 @@ export async function writeHomeFeedItemForSignal(
|
|
|
150
156
|
return item;
|
|
151
157
|
}
|
|
152
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Derive the terminal fallback headline from the summary.
|
|
161
|
+
*
|
|
162
|
+
* The summary is preferentially a conversation seed, which carries structured
|
|
163
|
+
* markdown and hard line breaks, so flatten it to plain single-line text
|
|
164
|
+
* before slicing a headline off the front. A non-empty summary always yields a
|
|
165
|
+
* non-empty title.
|
|
166
|
+
*/
|
|
167
|
+
function deriveFallbackTitle(summary: string): string {
|
|
168
|
+
// Block markers are line-anchored, so they have to go before the whitespace
|
|
169
|
+
// collapse, and before `stripMarkdown` (whose inline-code rule would chew a
|
|
170
|
+
// fence into a stray backtick). Ordered markers matter most: the seed prompt
|
|
171
|
+
// asks the model for numbered lists, and a leading `1.` also reads as a
|
|
172
|
+
// sentence terminator, which would truncate the title to the first digit.
|
|
173
|
+
// The leading-space allowance and rule set mirror `flattenToPlainText` in
|
|
174
|
+
// workspace migration 138, which must stay self-contained and so cannot share
|
|
175
|
+
// this code. A backfilled title and a freshly written one have to match.
|
|
176
|
+
const deblocked = summary
|
|
177
|
+
.replace(/^\s{0,3}(?:```|~~~).*$/gm, "")
|
|
178
|
+
.replace(/^\s{0,3}#{1,6}\s+/gm, "")
|
|
179
|
+
.replace(/^\s{0,3}>\s?/gm, "")
|
|
180
|
+
.replace(/^\s{0,3}(?:[-*+]|\d+[.)])\s+/gm, "");
|
|
181
|
+
const plain = flattenWhitespace(stripMarkdown(deblocked));
|
|
182
|
+
return deriveTitle(plain || flattenWhitespace(summary));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Collapse whitespace runs, including hard line breaks, into single spaces. */
|
|
186
|
+
function flattenWhitespace(text: string): string {
|
|
187
|
+
return text.replace(/\s+/g, " ").trim();
|
|
188
|
+
}
|
|
189
|
+
|
|
153
190
|
// ── Category & detail-panel derivation ────────────────────────────────
|
|
154
191
|
|
|
155
192
|
const EVENT_CATEGORY_MAP: Record<string, FeedItemCategory> = {
|