@vellumai/assistant 0.11.8 → 0.11.9-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/ARCHITECTURE.md +2 -2
- package/Dockerfile +8 -48
- package/docker-entrypoint.sh +5 -1
- package/docker-kata-apt-env.sh +3 -0
- package/docker-kata-apt-shims.sh +127 -0
- package/docker-kata-apt-wrapper.sh +45 -0
- package/docker-kata-chroot-exec.sh +35 -0
- package/docker-kata-pip.sh +8 -2
- package/docs/architecture/memory.md +8 -4
- package/docs/guardian-request-flow.md +18 -14
- package/docs/trusted-contact-access.md +1 -7
- package/node_modules/@vellumai/avatar-catalog/src/catalog.ts +7 -1
- package/node_modules/@vellumai/avatar-catalog/src/index.ts +1 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +4 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -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/platform-credential.ts +41 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +4 -1
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -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/platform-credential.ts +41 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +0 -18
- package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +110 -5
- package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +5 -33
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +3 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +100 -9
- package/node_modules/@vellumai/gateway-client/src/index.ts +1 -2
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +9 -0
- package/node_modules/@vellumai/service-contracts/package.json +4 -1
- package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
- package/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/openapi.yaml +193 -4
- package/package.json +2 -2
- package/src/__tests__/access-request-card-view.test.ts +6 -5
- package/src/__tests__/access-request-seed-content-blocks.test.ts +5 -2
- package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +43 -82
- package/src/__tests__/always-loaded-tools-guard.test.ts +8 -2
- package/src/__tests__/attachment-stored-path-annotation.test.ts +80 -0
- package/src/__tests__/attachments-store.test.ts +27 -0
- package/src/__tests__/attachments.test.ts +43 -0
- package/src/__tests__/canned-reply-release.test.ts +48 -5
- package/src/__tests__/channel-delivery-store.test.ts +129 -18
- package/src/__tests__/channel-reply-delivery.test.ts +553 -93
- package/src/__tests__/channel-retry-sweep.test.ts +199 -0
- package/src/__tests__/client-os-metadata-persistence.test.ts +32 -3
- package/src/__tests__/conversation-error.test.ts +15 -0
- package/src/__tests__/conversation-load-history-repair.test.ts +118 -0
- package/src/__tests__/conversation-pairing.test.ts +141 -1
- package/src/__tests__/conversation-routes-disk-view.test.ts +28 -1
- package/src/__tests__/conversation-routes-slash-commands.test.ts +125 -0
- package/src/__tests__/conversation-runtime-assembly.test.ts +79 -0
- package/src/__tests__/conversation-store-ephemeral.test.ts +323 -10
- package/src/__tests__/conversation-sync-tags.test.ts +2 -37
- package/src/__tests__/credential-health-service.test.ts +98 -10
- package/src/__tests__/delete-propagation.test.ts +469 -0
- package/src/__tests__/dm-persistence.test.ts +16 -0
- package/src/__tests__/docker-kata-apt-shims.test.ts +135 -0
- package/src/__tests__/forbidden-legacy-symbols.test.ts +12 -0
- package/src/__tests__/gemini-provider.test.ts +46 -0
- package/src/__tests__/guardian-card-withdrawal.test.ts +0 -22
- package/src/__tests__/guardian-gateway-sim.ts +0 -23
- package/src/__tests__/guardian-question-mode.test.ts +108 -0
- package/src/__tests__/guardian-reply-router-answer-mode.test.ts +0 -2
- package/src/__tests__/guardian-routing-invariants.test.ts +26 -12
- package/src/__tests__/host-proxy-interface.test.ts +10 -0
- package/src/__tests__/inbound-slack-persistence.test.ts +16 -0
- package/src/__tests__/injector-v3-suppression.test.ts +43 -9
- package/src/__tests__/list-messages-page-latest.test.ts +127 -0
- package/src/__tests__/list-messages-system-card.test.ts +103 -0
- package/src/__tests__/media-resolve-image-validation.test.ts +77 -0
- package/src/__tests__/notification-decision-fallback.test.ts +199 -77
- package/src/__tests__/notification-decision-strategy.test.ts +198 -213
- package/src/__tests__/notification-discord-adapter.test.ts +25 -0
- package/src/__tests__/notification-slack-adapter.test.ts +133 -0
- package/src/__tests__/notification-telegram-adapter.test.ts +36 -0
- package/src/__tests__/openai-provider.test.ts +5 -5
- package/src/__tests__/outbound-slack-persistence.test.ts +85 -72
- package/src/__tests__/persist-user-message-set-processing-failure.test.ts +83 -65
- package/src/__tests__/platform-client-verify-credential.test.ts +100 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -1
- package/src/__tests__/pricing.test.ts +11 -0
- package/src/__tests__/process-message-display-content.test.ts +20 -9
- package/src/__tests__/processing-acquire-fenced-guard.test.ts +56 -0
- package/src/__tests__/provider-meta-persistence.test.ts +67 -0
- package/src/__tests__/reaction-persistence.test.ts +22 -198
- package/src/__tests__/run-conversation-turn-persistence.test.ts +5 -2
- package/src/__tests__/scripted-turn-metadata-persistence.test.ts +16 -0
- package/src/__tests__/skill-load-tool.test.ts +27 -0
- package/src/__tests__/skills.test.ts +34 -1
- package/src/__tests__/strip-memory-injections.test.ts +3 -4
- package/src/__tests__/terminal-tools.test.ts +9 -0
- package/src/__tests__/thread-backfill.test.ts +5 -3
- package/src/__tests__/unified-turn-context-location.test.ts +76 -0
- package/src/__tests__/voice-session-bridge.test.ts +18 -0
- package/src/__tests__/watch-retro-report-payload.test.ts +185 -0
- package/src/__tests__/watch-retro-tool-availability.test.ts +82 -0
- package/src/__tests__/workspace-migration-151-repair-renamed-fireworks-deepseek-pro-model-id.test.ts +235 -0
- package/src/__tests__/workspace-migration-152-repair-retired-fireworks-minimax-m2p7-model-id.test.ts +233 -0
- package/src/agent/attachments.ts +13 -2
- package/src/agent/loop.ts +3 -19
- package/src/api/README.md +9 -5
- package/src/api/index.ts +9 -8
- package/src/api/package.json +1 -0
- package/src/api/responses/conversation-message.ts +8 -0
- package/src/api/responses/home.ts +7 -18
- package/src/api/surfaces.ts +114 -7
- package/src/approvals/AGENTS.md +1 -1
- package/src/channels/__tests__/gateway-guardian-requests.test.ts +1 -23
- package/src/channels/__tests__/types.test.ts +22 -1
- package/src/channels/gateway-guardian-requests.ts +0 -22
- package/src/channels/types.ts +8 -6
- package/src/cli/__tests__/catalog-search-help.test.ts +18 -0
- package/src/cli/commands/channels/__tests__/channels.test.ts +26 -0
- package/src/cli/commands/channels/index.help.ts +19 -6
- package/src/cli/commands/channels/index.ts +6 -2
- package/src/cli/commands/db/__tests__/status.test.ts +22 -0
- package/src/cli/commands/db/index.help.ts +1 -1
- package/src/cli/commands/db/status.ts +172 -1
- package/src/cli/commands/platform/__tests__/connect.test.ts +29 -0
- package/src/cli/commands/platform/connect.ts +14 -5
- package/src/cli/commands/plugins.help.ts +6 -5
- package/src/cli/lib/__tests__/install-from-github.test.ts +0 -8
- package/src/cli/lib/__tests__/install-from-platform.test.ts +72 -0
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +33 -0
- package/src/cli/lib/bundled-marketplace.json +14 -0
- package/src/cli/lib/install-from-github.ts +9 -5
- package/src/cli/lib/install-from-platform.ts +12 -1
- package/src/config/__tests__/assistant-initiated-threads-gate.test.ts +61 -0
- package/src/config/assistant-initiated-threads-gate.ts +50 -0
- package/src/config/bundled-skills/acp/SKILL.md +10 -3
- package/src/config/bundled-skills/schedule/SKILL.md +25 -11
- package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +3 -1
- package/src/config/call-site-defaults.ts +4 -1
- package/src/config/feature-flag-registry.json +21 -4
- package/src/config/schemas/memory-v3.ts +4 -3
- package/src/context/strip-injections.ts +17 -56
- package/src/conversations/__tests__/message-consolidation.test.ts +39 -0
- package/src/conversations/message-consolidation.ts +4 -3
- package/src/credential-health/credential-health-service.ts +130 -0
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +31 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +55 -59
- package/src/daemon/conversation-error.ts +2 -0
- package/src/daemon/conversation-messaging.ts +195 -47
- package/src/daemon/conversation-process.ts +20 -5
- package/src/daemon/conversation-runtime-assembly.ts +48 -39
- package/src/daemon/conversation-store.ts +159 -11
- package/src/daemon/conversation-tool-setup.ts +9 -2
- package/src/daemon/conversation.ts +290 -19
- package/src/daemon/dictation-text-processing.ts +2 -7
- package/src/daemon/handlers/config-channels.ts +33 -3
- package/src/daemon/handlers/config-model.test.ts +1 -0
- package/src/daemon/handlers/shared.ts +9 -0
- package/src/daemon/port-oversized-content.test.ts +117 -0
- package/src/daemon/port-oversized-content.ts +110 -0
- package/src/daemon/process-message.ts +87 -16
- package/src/daemon/reaction-record.test.ts +23 -11
- package/src/daemon/reaction-record.ts +6 -9
- package/src/documents/document-store.ts +1 -5
- package/src/home/conversation-starter-validation.ts +1 -5
- package/src/live-voice/__tests__/live-voice-photo.test.ts +910 -50
- package/src/live-voice/__tests__/live-voice-sight-frame-inline.test.ts +31 -25
- package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +70 -1
- package/src/live-voice/live-voice-photo.ts +544 -61
- package/src/live-voice/live-voice-session.ts +6 -2
- package/src/live-voice/protocol.ts +20 -1
- package/src/messaging/provider-message-metadata.ts +40 -1
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +10 -2
- package/src/messaging/providers/channel-transport.ts +28 -0
- package/src/messaging/providers/discord/send.ts +3 -2
- package/src/messaging/providers/slack/api.ts +11 -1
- package/src/messaging/providers/slack/message-metadata.test.ts +133 -0
- package/src/messaging/providers/slack/message-metadata.ts +141 -1
- package/src/messaging/providers/slack/send.test.ts +58 -0
- package/src/messaging/providers/slack/send.ts +4 -4
- package/src/messaging/providers/slack/transport.ts +28 -2
- package/src/messaging/providers/telegram-bot/send.test.ts +159 -0
- package/src/messaging/providers/telegram-bot/send.ts +93 -0
- package/src/messaging/providers/telegram-bot/transport.ts +71 -0
- package/src/messaging/reaction-envelopes.test.ts +73 -0
- package/src/messaging/reaction-envelopes.ts +33 -5
- package/src/messaging/read-provider-metadata.ts +4 -3
- package/src/monitoring/recovery/__tests__/stranded-delivery-events.test.ts +208 -0
- package/src/monitoring/recovery/db.ts +35 -0
- package/src/monitoring/recovery/orphaned-channel-events.ts +3 -22
- package/src/monitoring/recovery/run-recovery.ts +6 -2
- package/src/monitoring/recovery/stale-processing.ts +3 -20
- package/src/monitoring/recovery/stranded-delivery-events.ts +68 -0
- package/src/notifications/AGENTS.md +2 -2
- package/src/notifications/README.md +2 -2
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +11 -9
- package/src/notifications/__tests__/broadcaster.test.ts +35 -4
- package/src/notifications/__tests__/notification-utils.test.ts +31 -0
- package/src/notifications/access-request-copy.ts +126 -114
- package/src/notifications/adapters/discord.ts +9 -5
- package/src/notifications/adapters/shared.ts +17 -4
- package/src/notifications/adapters/slack.ts +14 -4
- package/src/notifications/adapters/telegram.ts +8 -4
- package/src/notifications/approval-card-data.ts +5 -2
- package/src/notifications/assistant-reply-producer.ts +7 -7
- package/src/notifications/broadcaster.ts +57 -25
- package/src/notifications/conversation-pairing.ts +68 -2
- package/src/notifications/copy-composer.ts +13 -32
- package/src/notifications/decision-engine.ts +56 -237
- package/src/notifications/guardian-delivery-recorder.ts +5 -9
- package/src/notifications/guardian-feed-projection.ts +0 -15
- package/src/notifications/guardian-question-mode.ts +147 -6
- package/src/notifications/notification-utils.ts +117 -1
- package/src/permissions/confirmation-guardian-request.ts +2 -2
- package/src/permissions/prompter.ts +1 -1
- package/src/persistence/conversation-crud.ts +93 -14
- package/src/persistence/conversation-queries.ts +144 -17
- package/src/persistence/conversation-types.ts +39 -5
- package/src/persistence/delivery-crud.ts +224 -28
- package/src/persistence/delivery-status.ts +21 -2
- package/src/persistence/migrations/374-channel-inbound-message-id-index.ts +26 -0
- package/src/persistence/migrations/375-create-channel-outbound-posts.ts +52 -0
- package/src/persistence/migrations/__tests__/375-create-channel-outbound-posts.test.ts +84 -0
- package/src/persistence/schema/conversations.ts +71 -2
- package/src/persistence/schema-contract.test.ts +78 -0
- package/src/persistence/schema-contract.ts +96 -0
- package/src/persistence/steps.ts +4 -0
- package/src/platform/client.ts +55 -0
- package/src/plugins/__tests__/mcp-servers.test.ts +46 -0
- package/src/plugins/defaults/injector-order.ts +2 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +2 -2
- package/src/plugins/defaults/memory/context-search/sources/conversations.ts +13 -15
- package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +10 -0
- package/src/plugins/defaults/memory/injectors.ts +1 -1
- package/src/plugins/defaults/memory/memory-marker.ts +17 -7
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +1 -5
- package/src/plugins/defaults/memory/substrate/__tests__/skill-content.test.ts +17 -0
- package/src/plugins/defaults/memory/substrate/skill-content.ts +7 -0
- package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +54 -41
- package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +3 -3
- package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +22 -0
- package/src/plugins/defaults/memory/v3/injector.ts +14 -15
- package/src/plugins/defaults/memory/v3/prune.test.ts +20 -2
- package/src/plugins/defaults/memory/v3/render-injection.ts +10 -1
- package/src/plugins/defaults/memory/v3/types.ts +15 -4
- package/src/plugins/defaults/turn-context/injectors.ts +3 -0
- package/src/plugins/defaults/turn-context/unified-turn-context.ts +24 -0
- package/src/plugins/mcp-servers.ts +14 -2
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +50 -0
- package/src/providers/__tests__/registry-native-web-search.test.ts +49 -2
- package/src/providers/anthropic/client.ts +21 -24
- package/src/providers/call-site-routing.ts +5 -4
- package/src/providers/connection-resolution.ts +29 -1
- package/src/providers/content-block-size.test.ts +82 -0
- package/src/providers/content-block-size.ts +99 -0
- package/src/providers/file-block-text.test.ts +64 -0
- package/src/providers/file-block-text.ts +28 -0
- package/src/providers/gemini/client.ts +15 -9
- package/src/providers/inference/auth.ts +6 -6
- package/src/providers/media-resolve.ts +14 -0
- package/src/providers/model-catalog.ts +47 -34
- package/src/providers/openai/chat-completions-provider.ts +9 -25
- package/src/providers/openai/responses-provider.ts +11 -18
- package/src/providers/registry.ts +10 -7
- package/src/providers/routing-identity.ts +2 -1
- package/src/providers/types.ts +1 -4
- package/src/providers/vellum-model-routing.ts +3 -2
- package/src/runtime/AGENTS.md +40 -15
- package/src/runtime/approval-message-composer.ts +1 -6
- package/src/runtime/assistant-event-hub.ts +2 -39
- package/src/runtime/channel-approval-types.ts +1 -5
- package/src/runtime/channel-reply-delivery.ts +182 -115
- package/src/runtime/{slack-reply-session.test.ts → channel-reply-session.test.ts} +294 -156
- package/src/runtime/{slack-reply-session.ts → channel-reply-session.ts} +107 -86
- package/src/runtime/channel-retry-sweep.ts +102 -1
- package/src/runtime/finalize-event-delivery.ts +4 -4
- package/src/runtime/guardian-action-message-composer.ts +1 -4
- package/src/runtime/guardian-reply-router.ts +4 -75
- package/src/runtime/http-router.ts +1 -5
- package/src/runtime/question-request-guardian-bridge.ts +2 -3
- package/src/runtime/routes/__tests__/conversation-list-assistant-section.test.ts +359 -0
- package/src/runtime/routes/__tests__/dictation-command-mode.test.ts +120 -0
- package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +487 -0
- package/src/runtime/routes/acp-routes.ts +1 -1
- package/src/runtime/routes/canned-reply-release.ts +19 -9
- package/src/runtime/routes/channel-route-shared.ts +0 -39
- package/src/runtime/routes/channel-verification-routes.ts +4 -1
- package/src/runtime/routes/conversation-list-routes.ts +44 -3
- package/src/runtime/routes/conversation-management-routes.ts +2 -1
- package/src/runtime/routes/conversation-routes.ts +138 -59
- package/src/runtime/routes/diagnostics-routes.ts +111 -60
- package/src/runtime/routes/guardian-action-routes.ts +10 -14
- package/src/runtime/routes/guardian-approval-interception.ts +0 -5
- package/src/runtime/routes/inbound-message-handler.ts +170 -48
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +4 -3
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +169 -8
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +82 -14
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +23 -48
- package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +454 -94
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +309 -102
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/platform-routes.ts +99 -10
- package/src/runtime/routes/sight-frame-routes.ts +136 -0
- package/src/runtime/sync/resource-sync-events.ts +14 -37
- package/src/runtime/sync/sync-publisher.test.ts +5 -4
- package/src/tools/__tests__/tool-input-schemas.test.ts +2 -0
- package/src/tools/client-os.ts +11 -1
- package/src/tools/host-filesystem/edit.ts +2 -2
- package/src/tools/host-filesystem/read.ts +2 -2
- package/src/tools/host-filesystem/transfer.ts +2 -2
- package/src/tools/host-filesystem/write.ts +2 -2
- package/src/tools/host-terminal/host-shell.ts +2 -2
- package/src/tools/skills/load.ts +1 -1
- package/src/tools/terminal/safe-env.ts +4 -0
- package/src/tools/tool-input-schemas.ts +2 -0
- package/src/tools/tool-manifest.ts +2 -0
- package/src/tools/ui-surface/definitions.ts +4 -3
- package/src/tools/watch/watch-retro-report.ts +205 -0
- package/src/watch/__tests__/watch-retro.test.ts +268 -40
- package/src/watch/watch-retro.ts +190 -77
- package/src/workspace/migrations/151-repair-renamed-fireworks-deepseek-pro-model-id.ts +195 -0
- package/src/workspace/migrations/152-repair-retired-fireworks-minimax-m2p7-model-id.ts +198 -0
- package/src/workspace/migrations/__tests__/150-stt-flux-provider-to-model-family.test.ts +0 -10
- package/src/workspace/migrations/registry.ts +4 -0
- package/docker-kata-pip-chroot.sh +0 -22
- package/src/__tests__/slack-reaction-approvals.test.ts +0 -97
- package/src/__tests__/slack-reaction-guardian-approval.test.ts +0 -307
- package/src/api/events/conversation-list-invalidated.ts +0 -38
|
@@ -15,6 +15,8 @@ import { z } from "zod";
|
|
|
15
15
|
import { externalSourceLinkSchema } from "../messaging/channel-binding-schema.js";
|
|
16
16
|
import { isSlackDmConversation } from "../messaging/providers/slack/message-metadata.js";
|
|
17
17
|
import { nonEmpty } from "./notification-utils.js";
|
|
18
|
+
import type { NotificationSignal } from "./signal.js";
|
|
19
|
+
import type { NotificationChannel, RenderedChannelCopy } from "./types.js";
|
|
18
20
|
|
|
19
21
|
// ── Schema primitives ──────────────────────────────────────────────────
|
|
20
22
|
|
|
@@ -630,10 +632,6 @@ export function hasGuardianRequestCodeInstruction(
|
|
|
630
632
|
}
|
|
631
633
|
}
|
|
632
634
|
|
|
633
|
-
function escapeRegExp(value: string): string {
|
|
634
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
635
|
-
}
|
|
636
|
-
|
|
637
635
|
function normalizeInstructionText(value: string): string {
|
|
638
636
|
return value
|
|
639
637
|
.replace(/[ \t]+\n/g, "\n")
|
|
@@ -660,7 +658,7 @@ export function stripConflictingGuardianRequestInstructions(
|
|
|
660
658
|
requestCode: string,
|
|
661
659
|
mode: GuardianQuestionInstructionMode,
|
|
662
660
|
): string {
|
|
663
|
-
const escapedCode =
|
|
661
|
+
const escapedCode = RegExp.escape(requestCode);
|
|
664
662
|
const next =
|
|
665
663
|
mode === "answer"
|
|
666
664
|
? text.replace(buildApprovalInstructionPattern(escapedCode), "")
|
|
@@ -679,7 +677,7 @@ export function stripGuardianRequestCodeInstructions(
|
|
|
679
677
|
text: string,
|
|
680
678
|
requestCode: string,
|
|
681
679
|
): string {
|
|
682
|
-
const escapedCode =
|
|
680
|
+
const escapedCode = RegExp.escape(requestCode);
|
|
683
681
|
const next = text
|
|
684
682
|
.replace(buildApprovalInstructionPattern(escapedCode), "")
|
|
685
683
|
.replace(buildAnswerInstructionPattern(escapedCode), "")
|
|
@@ -712,6 +710,149 @@ export function parseInteractiveApprovalPayload(
|
|
|
712
710
|
return nonEmpty(parsed.requestId) ? parsed : null;
|
|
713
711
|
}
|
|
714
712
|
|
|
713
|
+
// ── Reply mechanics per channel ─────────────────────────────────────────
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Whether a channel's rendered guardian-request copy carries request-code
|
|
717
|
+
* reply mechanics (`Reference code: X. Reply "X <your answer>".` and its
|
|
718
|
+
* approval-mode twin). Decided by where that channel's copy is read:
|
|
719
|
+
*
|
|
720
|
+
* - `vellum`: the notification bell's detail, the OS banner, and the
|
|
721
|
+
* delivery audit row. The conversation gets the card
|
|
722
|
+
* (`seedContentBlocks`), which carries its own text fallback. None of
|
|
723
|
+
* these takes a typed reply; the guardian acts through the card or the
|
|
724
|
+
* conversation it points at.
|
|
725
|
+
* - `platform`: a push banner mirroring the vellum copy.
|
|
726
|
+
* - `slack`, for payloads that render Approve/Reject buttons
|
|
727
|
+
* (`resolveApprovalContext` in broadcaster.ts, gated on the same
|
|
728
|
+
* {@link parseInteractiveApprovalPayload}).
|
|
729
|
+
*
|
|
730
|
+
* Every other channel is a text chat where "CODE <reply>" is how a guardian
|
|
731
|
+
* answers, so its copy has to carry the instruction even when the composed
|
|
732
|
+
* copy left it out. A newly deliverable channel lands here by default,
|
|
733
|
+
* which keeps its text answerable until it grows a card.
|
|
734
|
+
*/
|
|
735
|
+
export function guardianCopyCarriesReplyMechanics(
|
|
736
|
+
channel: NotificationChannel,
|
|
737
|
+
payload: Record<string, unknown>,
|
|
738
|
+
): boolean {
|
|
739
|
+
switch (channel) {
|
|
740
|
+
case "vellum":
|
|
741
|
+
case "platform":
|
|
742
|
+
return false;
|
|
743
|
+
case "slack":
|
|
744
|
+
return parseInteractiveApprovalPayload(payload) == null;
|
|
745
|
+
default:
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function ensureGuardianRequestCodeInCopy(
|
|
751
|
+
copy: RenderedChannelCopy,
|
|
752
|
+
requestCode: string,
|
|
753
|
+
mode: GuardianQuestionInstructionMode,
|
|
754
|
+
): RenderedChannelCopy {
|
|
755
|
+
const instruction = buildGuardianRequestCodeInstruction(requestCode, mode);
|
|
756
|
+
|
|
757
|
+
const ensureText = (text: string | undefined): string => {
|
|
758
|
+
const base = typeof text === "string" ? text.trim() : "";
|
|
759
|
+
const sanitized = stripConflictingGuardianRequestInstructions(
|
|
760
|
+
base,
|
|
761
|
+
requestCode,
|
|
762
|
+
mode,
|
|
763
|
+
);
|
|
764
|
+
if (hasGuardianRequestCodeInstruction(sanitized, requestCode, mode)) {
|
|
765
|
+
return sanitized;
|
|
766
|
+
}
|
|
767
|
+
return sanitized.length > 0
|
|
768
|
+
? `${sanitized}\n\n${instruction}`
|
|
769
|
+
: instruction;
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
return {
|
|
773
|
+
...copy,
|
|
774
|
+
body: ensureText(copy.body),
|
|
775
|
+
deliveryText: copy.deliveryText
|
|
776
|
+
? ensureText(copy.deliveryText)
|
|
777
|
+
: copy.deliveryText,
|
|
778
|
+
conversationSeedMessage: copy.conversationSeedMessage
|
|
779
|
+
? ensureText(copy.conversationSeedMessage)
|
|
780
|
+
: copy.conversationSeedMessage,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* A field that was nothing but the instruction becomes the request's own
|
|
786
|
+
* question text, the producer's human-readable ask, so a card surface never
|
|
787
|
+
* shows the mechanics it exists to avoid. Without one it keeps its original
|
|
788
|
+
* text rather than becoming empty: downstream treats an empty body as
|
|
789
|
+
* missing copy.
|
|
790
|
+
*/
|
|
791
|
+
function stripGuardianRequestCodeInCopy(
|
|
792
|
+
copy: RenderedChannelCopy,
|
|
793
|
+
requestCode: string,
|
|
794
|
+
fallbackText: string | undefined,
|
|
795
|
+
): RenderedChannelCopy {
|
|
796
|
+
const strip = (text: string): string => {
|
|
797
|
+
const stripped = stripGuardianRequestCodeInstructions(text, requestCode);
|
|
798
|
+
return stripped.length > 0 ? stripped : (fallbackText ?? text);
|
|
799
|
+
};
|
|
800
|
+
// A title is a headline, never the ask, so a code-only title keeps its
|
|
801
|
+
// text rather than taking the question.
|
|
802
|
+
const strippedTitle = stripGuardianRequestCodeInstructions(
|
|
803
|
+
copy.title,
|
|
804
|
+
requestCode,
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
return {
|
|
808
|
+
...copy,
|
|
809
|
+
title: strippedTitle.length > 0 ? strippedTitle : copy.title,
|
|
810
|
+
body: strip(copy.body),
|
|
811
|
+
deliveryText: copy.deliveryText
|
|
812
|
+
? strip(copy.deliveryText)
|
|
813
|
+
: copy.deliveryText,
|
|
814
|
+
conversationSeedMessage: copy.conversationSeedMessage
|
|
815
|
+
? strip(copy.conversationSeedMessage)
|
|
816
|
+
: copy.conversationSeedMessage,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Apply the reply-mechanics rule to one channel's copy of a
|
|
822
|
+
* `guardian.question` signal: enforce the request-code instruction into
|
|
823
|
+
* copy the guardian answers by typing, strip it (model-authored code
|
|
824
|
+
* phrasing included) from copy that acts through a card. Any other signal
|
|
825
|
+
* passes through untouched. Idempotent, so the template composer and the
|
|
826
|
+
* decision engine can both apply it and the result is the same whichever
|
|
827
|
+
* path rendered the copy.
|
|
828
|
+
*/
|
|
829
|
+
export function applyGuardianReplyMechanics(
|
|
830
|
+
copy: RenderedChannelCopy,
|
|
831
|
+
channel: NotificationChannel,
|
|
832
|
+
signal: Pick<NotificationSignal, "sourceEventName" | "contextPayload">,
|
|
833
|
+
): RenderedChannelCopy {
|
|
834
|
+
if (signal.sourceEventName !== "guardian.question") {
|
|
835
|
+
return copy;
|
|
836
|
+
}
|
|
837
|
+
const rawCode = signal.contextPayload.requestCode;
|
|
838
|
+
if (typeof rawCode !== "string" || rawCode.trim().length === 0) {
|
|
839
|
+
return copy;
|
|
840
|
+
}
|
|
841
|
+
const requestCode = rawCode.trim().toUpperCase();
|
|
842
|
+
if (!guardianCopyCarriesReplyMechanics(channel, signal.contextPayload)) {
|
|
843
|
+
const questionText = signal.contextPayload.questionText;
|
|
844
|
+
return stripGuardianRequestCodeInCopy(
|
|
845
|
+
copy,
|
|
846
|
+
requestCode,
|
|
847
|
+
typeof questionText === "string" ? nonEmpty(questionText) : undefined,
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
const { mode } = resolveGuardianQuestionInstructionMode(
|
|
851
|
+
signal.contextPayload,
|
|
852
|
+
);
|
|
853
|
+
return ensureGuardianRequestCodeInCopy(copy, requestCode, mode);
|
|
854
|
+
}
|
|
855
|
+
|
|
715
856
|
/**
|
|
716
857
|
* Resolve guardian reply instruction mode from a raw context payload.
|
|
717
858
|
*
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
import type { Root, RootContent } from "mdast";
|
|
10
10
|
|
|
11
11
|
import { parseMarkdown } from "../messaging/content/parse.js";
|
|
12
|
-
import { stripAnsiAndControlChars } from "../util/ansi.js";
|
|
12
|
+
import { stripAnsiAndControlChars, stripAnsiSequences } from "../util/ansi.js";
|
|
13
13
|
import { isPlainObject } from "../util/object.js";
|
|
14
|
+
import type { RenderedChannelCopy } from "./types.js";
|
|
14
15
|
|
|
15
16
|
// ── String helpers ──────────────────────────────────────────────────────────
|
|
16
17
|
|
|
@@ -237,6 +238,40 @@ function sanitize(value: string, maxLength: number): string {
|
|
|
237
238
|
return truncate(stripAnsiAndControlChars(value, " ").trim(), maxLength);
|
|
238
239
|
}
|
|
239
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Collapse horizontal whitespace and blank-line runs while keeping paragraph
|
|
243
|
+
* breaks. Tabs become spaces. Three or more consecutive newlines collapse to
|
|
244
|
+
* one blank line so padding does not eat the preview budget.
|
|
245
|
+
*/
|
|
246
|
+
export function collapseHorizontalWhitespace(value: string): string {
|
|
247
|
+
return value
|
|
248
|
+
.replace(/[^\S\n]+/g, " ")
|
|
249
|
+
.replace(/ *\n */g, "\n")
|
|
250
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
251
|
+
.trim();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Sanitize a notification body that should keep its line breaks.
|
|
256
|
+
*
|
|
257
|
+
* ANSI sequences and non-newline control characters are stripped; `\r\n` and
|
|
258
|
+
* `\r` become `\n`. Horizontal whitespace is collapsed. The result is clamped
|
|
259
|
+
* to {@link MESSAGE_PREVIEW_MAX_LENGTH}.
|
|
260
|
+
*
|
|
261
|
+
* Single-line surfaces (titles, identity fields, channel previews that must
|
|
262
|
+
* not wrap) should keep using {@link sanitizeMessagePreview}.
|
|
263
|
+
*/
|
|
264
|
+
export function sanitizeMultilineMessagePreview(value: string): string {
|
|
265
|
+
const normalized = stripAnsiSequences(value)
|
|
266
|
+
.replace(/\r\n/g, "\n")
|
|
267
|
+
.replace(/\r/g, "\n")
|
|
268
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]+/g, " ");
|
|
269
|
+
return truncate(
|
|
270
|
+
collapseHorizontalWhitespace(normalized),
|
|
271
|
+
MESSAGE_PREVIEW_MAX_LENGTH,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
240
275
|
/**
|
|
241
276
|
* Sanitize an untrusted identity field for inclusion in notification copy.
|
|
242
277
|
* Strips control characters and clamps to 120 characters.
|
|
@@ -271,3 +306,84 @@ export const NOTIFICATION_TITLE_MAX_LENGTH = 60;
|
|
|
271
306
|
export function sanitizeNotificationTitle(value: string): string {
|
|
272
307
|
return sanitize(value, NOTIFICATION_TITLE_MAX_LENGTH);
|
|
273
308
|
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Tidy copy after a sentence was cut out of it: trailing spaces before a
|
|
312
|
+
* line break go, runs of blank lines collapse to one, and the ends trim.
|
|
313
|
+
*/
|
|
314
|
+
export function normalizeStrippedText(value: string): string {
|
|
315
|
+
return value
|
|
316
|
+
.replace(/(?<=\S)[ \t]{2,}(?=\S)/g, " ")
|
|
317
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
318
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
319
|
+
.trim();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const DIRECTIVE_VERBS = "approve|reject|verify|trust|block|<your\\s+answer>";
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Remove request-code reply mechanics from copy: every sentence that pairs
|
|
326
|
+
* the code with a reply verb (`Reply "X approve" or "X reject"`, a
|
|
327
|
+
* paraphrase, a negated form like `Do not reply "X reject"`, and the same
|
|
328
|
+
* with backticks, smart quotes, or no quotes at all), and every sentence
|
|
329
|
+
* that names a reference, approval, or request code together with the code
|
|
330
|
+
* (`Reference code: X.`, `Use reference code X for this request.`).
|
|
331
|
+
*
|
|
332
|
+
* Whole sentences go, never a suffix from "reply" onward, so no fragment
|
|
333
|
+
* like "Alice wants access. Do not" survives. A sentence is text between
|
|
334
|
+
* line breaks or `.!?` terminators. Sentences that mention other codes or
|
|
335
|
+
* merely contain the word "reply" are untouched.
|
|
336
|
+
*/
|
|
337
|
+
export function stripRequestCodeDirectives(
|
|
338
|
+
text: string,
|
|
339
|
+
requestCode: string,
|
|
340
|
+
): string {
|
|
341
|
+
const code = RegExp.escape(requestCode);
|
|
342
|
+
const sentence = (core: string): RegExp =>
|
|
343
|
+
new RegExp(`[^.!?\\n]*${core}[^.!?\\n]*[.!?]?(?:[ \\t]*\\n)?`, "gi");
|
|
344
|
+
const next = text
|
|
345
|
+
.replace(
|
|
346
|
+
sentence(`(?<![A-Z0-9])${code}\\s+(?:${DIRECTIVE_VERBS})(?![A-Za-z])`),
|
|
347
|
+
"",
|
|
348
|
+
)
|
|
349
|
+
.replace(
|
|
350
|
+
sentence(
|
|
351
|
+
`\\b(?:reference|approval|request)\\s+code\\b[^.!?\\n]*?(?<![A-Z0-9])${code}(?![A-Z0-9])`,
|
|
352
|
+
),
|
|
353
|
+
"",
|
|
354
|
+
);
|
|
355
|
+
return normalizeStrippedText(next);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Run a reply-mechanics strip over every text field of a channel's copy.
|
|
360
|
+
* A body, deliveryText, or conversationSeedMessage left empty by the strip
|
|
361
|
+
* becomes `ask`, the request's own deterministic text, so a card surface
|
|
362
|
+
* never shows the mechanics it exists to avoid; without one it keeps its
|
|
363
|
+
* text rather than becoming empty, which downstream reads as missing copy.
|
|
364
|
+
* A title is a headline, never the ask, so a mechanics-only title keeps
|
|
365
|
+
* its text.
|
|
366
|
+
*/
|
|
367
|
+
export function stripReplyMechanicsFromCopy(
|
|
368
|
+
copy: RenderedChannelCopy,
|
|
369
|
+
strip: (text: string) => string,
|
|
370
|
+
ask: string | undefined,
|
|
371
|
+
): RenderedChannelCopy {
|
|
372
|
+
const fallback = ask === undefined ? undefined : nonEmpty(ask);
|
|
373
|
+
const stripField = (text: string): string => {
|
|
374
|
+
const stripped = strip(text);
|
|
375
|
+
return stripped.length > 0 ? stripped : (fallback ?? text);
|
|
376
|
+
};
|
|
377
|
+
const strippedTitle = strip(copy.title);
|
|
378
|
+
return {
|
|
379
|
+
...copy,
|
|
380
|
+
title: strippedTitle.length > 0 ? strippedTitle : copy.title,
|
|
381
|
+
body: stripField(copy.body),
|
|
382
|
+
deliveryText: copy.deliveryText
|
|
383
|
+
? stripField(copy.deliveryText)
|
|
384
|
+
: copy.deliveryText,
|
|
385
|
+
conversationSeedMessage: copy.conversationSeedMessage
|
|
386
|
+
? stripField(copy.conversationSeedMessage)
|
|
387
|
+
: copy.conversationSeedMessage,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* generic event hub — lets the hub stay a pure pub/sub primitive with no
|
|
9
9
|
* dependency on the conversation registry or guardian bridge.
|
|
10
10
|
*
|
|
11
|
-
* Channel guardian decisions (
|
|
11
|
+
* Channel guardian decisions (buttons, text) all route through the
|
|
12
12
|
* guardian-request pipeline, so without this record none of them can resolve the
|
|
13
13
|
* confirmation.
|
|
14
14
|
*
|
|
@@ -135,7 +135,7 @@ export async function createGuardianRequestForConfirmation(
|
|
|
135
135
|
if (err instanceof IntegrityError) {
|
|
136
136
|
// The confirmation could not be promoted to a guardian request
|
|
137
137
|
// (e.g. its trust context resolved no guardianPrincipalId). Channel
|
|
138
|
-
// guardian decisions
|
|
138
|
+
// guardian decisions (buttons and text) all route through
|
|
139
139
|
// the guardian-request pipeline, so without this record none of them can resolve
|
|
140
140
|
// the confirmation. Surface it rather than swallowing: for a guardian's
|
|
141
141
|
// own confirmation a bound principal should always be present.
|
|
@@ -173,7 +173,7 @@ export class PermissionPrompter {
|
|
|
173
173
|
this.sendToClient(confirmationMsg);
|
|
174
174
|
|
|
175
175
|
// Promote the confirmation to a guardian request so channel
|
|
176
|
-
// guardian decisions (
|
|
176
|
+
// guardian decisions (buttons, text) can resolve it.
|
|
177
177
|
// The prompter runs inside the emitting turn, so the request binds
|
|
178
178
|
// to that turn's trust snapshot.
|
|
179
179
|
if (conversationId) {
|
|
@@ -32,6 +32,7 @@ import { conversationMetadataSyncTag } from "../daemon/message-types/sync.js";
|
|
|
32
32
|
import type { TrustContext } from "../daemon/trust-context-types.js";
|
|
33
33
|
import { clearAllConversationIds } from "../home/feed-writer.js";
|
|
34
34
|
import type { ConversationDeletedInputContext } from "../hooks/types.js";
|
|
35
|
+
import { readProviderMetadata } from "../messaging/read-provider-metadata.js";
|
|
35
36
|
import { HOOKS } from "../plugin-api/constants.js";
|
|
36
37
|
import { forkConversationMemory } from "../plugins/defaults/memory/fork-conversation-memory.js";
|
|
37
38
|
import { indexMessageNow } from "../plugins/defaults/memory/indexer.js";
|
|
@@ -309,7 +310,7 @@ export const messageMetadataSchema = z
|
|
|
309
310
|
/**
|
|
310
311
|
* Optional client-side metadata bag attached to user messages at persist
|
|
311
312
|
* time. `os` carries the client-reported OS surface ("web" | "ios" |
|
|
312
|
-
* "macos" | "windows" | "android") from the request body's `clientOs`
|
|
313
|
+
* "macos" | "windows" | "linux" | "android") from the request body's `clientOs`
|
|
313
314
|
* field, stamped by `persistQueuedMessageBody`. The transport
|
|
314
315
|
* `userMessageInterface` is
|
|
315
316
|
* "web" for the web, mobile, and desktop apps alike, so this is the only
|
|
@@ -422,6 +423,12 @@ export const messageMetadataSchema = z
|
|
|
422
423
|
* as a literal here (like `memoryInjectedBlock`) so the storage schema does
|
|
423
424
|
* not import the memory feature. */
|
|
424
425
|
memoryV3InjectedBlock: z.string().optional(),
|
|
426
|
+
/** Memory-v3 per-turn `<memory_spotlight>` block (wrapped). Rehydrated
|
|
427
|
+
* by `loadFromDb` so historical turns keep the spotlight they were sent
|
|
428
|
+
* with. The key matches the memory plugin's
|
|
429
|
+
* `MEMORY_V3_SPOTLIGHT_BLOCK_METADATA_KEY`, kept as a literal here so
|
|
430
|
+
* the storage schema does not import the memory feature. */
|
|
431
|
+
memoryV3SpotlightBlock: z.string().optional(),
|
|
425
432
|
turnContextBlock: z.string().optional(),
|
|
426
433
|
pkbSystemReminderBlock: z.string().optional(),
|
|
427
434
|
workspaceBlock: z.string().optional(),
|
|
@@ -491,8 +498,9 @@ export function isProviderErrorMetadata(
|
|
|
491
498
|
}
|
|
492
499
|
|
|
493
500
|
/**
|
|
494
|
-
* True when an assistant row is a standalone display turn: a system card
|
|
495
|
-
*
|
|
501
|
+
* True when an assistant row is a standalone display turn: a system card, a
|
|
502
|
+
* provider-error notice, a deliberate-silence marker, a reaction, or a row
|
|
503
|
+
* deleted on its channel. Standalone rows never merge with adjacent
|
|
496
504
|
* assistant rows, and turn grouping closes on them, so display merging and
|
|
497
505
|
* the turn resolver agree on boundaries. Takes the raw persisted `metadata`
|
|
498
506
|
* JSON string; malformed JSON and non-assistant roles are never standalone.
|
|
@@ -510,13 +518,28 @@ export function isStandaloneAssistantMessage(
|
|
|
510
518
|
isSystemCardMetadata(parsed) ||
|
|
511
519
|
isProviderErrorMetadata(parsed) ||
|
|
512
520
|
isNoResponseMetadata(parsed) ||
|
|
513
|
-
isReactionMessageMetadata(parsed)
|
|
521
|
+
isReactionMessageMetadata(parsed) ||
|
|
522
|
+
isChannelDeletedMetadata(metadata)
|
|
514
523
|
);
|
|
515
524
|
} catch {
|
|
516
525
|
return false;
|
|
517
526
|
}
|
|
518
527
|
}
|
|
519
528
|
|
|
529
|
+
/**
|
|
530
|
+
* True when the row was deleted on its channel after it was stored. The
|
|
531
|
+
* marker lives in the provider envelope rather than in `messageKind`, so a
|
|
532
|
+
* merged run would take the anchor's envelope and either drop the deletion
|
|
533
|
+
* or claim it over text that is still visible. The substring guard keeps the
|
|
534
|
+
* envelope parse off rows that cannot carry it.
|
|
535
|
+
*/
|
|
536
|
+
function isChannelDeletedMetadata(metadata: string): boolean {
|
|
537
|
+
return (
|
|
538
|
+
metadata.includes("deletedAt") &&
|
|
539
|
+
readProviderMetadata(metadata)?.deletedAt !== undefined
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
520
543
|
/**
|
|
521
544
|
* Parse a persisted message's metadata JSON against {@link messageMetadataSchema}
|
|
522
545
|
* — the single source of truth for its shape — returning the validated fields,
|
|
@@ -753,11 +776,17 @@ const parseMessage = createRowMapper<typeof messages.$inferSelect, MessageRow>({
|
|
|
753
776
|
});
|
|
754
777
|
|
|
755
778
|
/**
|
|
756
|
-
* Monotonic timestamp source for message ordering
|
|
757
|
-
* the same millisecond (e.g., tool_results user
|
|
758
|
-
* message_complete) would get the same
|
|
759
|
-
* non-deterministic. This counter
|
|
760
|
-
* increasing value so insertion order is
|
|
779
|
+
* Monotonic timestamp source for message ordering and conversation creation.
|
|
780
|
+
* Two messages saved within the same millisecond (e.g., tool_results user
|
|
781
|
+
* message + assistant message in message_complete) would get the same
|
|
782
|
+
* Date.now(), making their reload order non-deterministic. This counter
|
|
783
|
+
* ensures every call returns a strictly increasing value so insertion order is
|
|
784
|
+
* always preserved.
|
|
785
|
+
*
|
|
786
|
+
* Conversation rows draw from it for a second reason: `created_at` is what
|
|
787
|
+
* tells one incarnation of an id from another, so two creations in one
|
|
788
|
+
* millisecond must not be able to collide. Sharing the counter with messages
|
|
789
|
+
* costs nothing, both wanting the same "never twice the same value" guarantee.
|
|
761
790
|
*/
|
|
762
791
|
let lastTimestamp = 0;
|
|
763
792
|
function monotonicNow(): number {
|
|
@@ -780,6 +809,24 @@ interface InsertedMessage {
|
|
|
780
809
|
deduplicated: boolean;
|
|
781
810
|
}
|
|
782
811
|
|
|
812
|
+
/**
|
|
813
|
+
* Thrown by an insert whose caller's `insertPrecondition` reads false.
|
|
814
|
+
*
|
|
815
|
+
* No row was written, so a caller holding resources for the message it asked
|
|
816
|
+
* for (an uploaded attachment, a pending client receipt) is free to give them
|
|
817
|
+
* up on this error. Carries no SQLite code, which is what keeps
|
|
818
|
+
* {@link withSqliteRetry} from mistaking it for contention and retrying an
|
|
819
|
+
* abort that will only abort again.
|
|
820
|
+
*/
|
|
821
|
+
export class MessageInsertPreconditionError extends Error {
|
|
822
|
+
constructor(conversationId: string) {
|
|
823
|
+
super(
|
|
824
|
+
`Message insert precondition failed for conversation ${conversationId}`,
|
|
825
|
+
);
|
|
826
|
+
this.name = "MessageInsertPreconditionError";
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
783
830
|
interface InsertMessageCoreParams {
|
|
784
831
|
conversationId: string;
|
|
785
832
|
role: MessageRole;
|
|
@@ -793,6 +840,9 @@ interface InsertMessageCoreParams {
|
|
|
793
840
|
* `requestId` for user turns) can pass it here so the persisted
|
|
794
841
|
* row ID matches the runtime request ID. */
|
|
795
842
|
id?: string;
|
|
843
|
+
/** Answered synchronously at the top of every insert attempt. See
|
|
844
|
+
* {@link AddMessageOptions.insertPrecondition}. */
|
|
845
|
+
insertPrecondition?: () => boolean;
|
|
796
846
|
}
|
|
797
847
|
|
|
798
848
|
/**
|
|
@@ -879,6 +929,7 @@ async function insertMessageCore(
|
|
|
879
929
|
metadata,
|
|
880
930
|
clientMessageId,
|
|
881
931
|
id,
|
|
932
|
+
insertPrecondition,
|
|
882
933
|
} = params;
|
|
883
934
|
warnOnModelInvisibleContent(content, conversationId);
|
|
884
935
|
const db = getDb();
|
|
@@ -905,8 +956,17 @@ async function insertMessageCore(
|
|
|
905
956
|
// The timestamp is recomputed each attempt so a late retry doesn't persist a
|
|
906
957
|
// stale `updatedAt`.
|
|
907
958
|
return withSqliteRetry(
|
|
908
|
-
(): InsertedMessage =>
|
|
909
|
-
|
|
959
|
+
(): InsertedMessage => {
|
|
960
|
+
// Asked at the top of EVERY attempt, and synchronously, because that is
|
|
961
|
+
// the scope the answer holds for. Contention retries this function after
|
|
962
|
+
// an awaited backoff, so an answer given once for the call would be
|
|
963
|
+
// reporting on the world as it stood before a sleep the caller cannot
|
|
964
|
+
// see. From here to the statement below there is nothing async, so the
|
|
965
|
+
// answer and the row this attempt writes share one tick.
|
|
966
|
+
if (insertPrecondition && !insertPrecondition()) {
|
|
967
|
+
throw new MessageInsertPreconditionError(conversationId);
|
|
968
|
+
}
|
|
969
|
+
return timeSyncSection(
|
|
910
970
|
"messages:insert",
|
|
911
971
|
(): InsertedMessage => {
|
|
912
972
|
const now = monotonicNow();
|
|
@@ -1016,7 +1076,8 @@ async function insertMessageCore(
|
|
|
1016
1076
|
contentBytes:
|
|
1017
1077
|
typeof content === "string" ? content.length : undefined,
|
|
1018
1078
|
}),
|
|
1019
|
-
)
|
|
1079
|
+
);
|
|
1080
|
+
},
|
|
1020
1081
|
{ op: "insertMessageCore", context: { conversationId } },
|
|
1021
1082
|
);
|
|
1022
1083
|
}
|
|
@@ -1073,7 +1134,12 @@ export function createConversation(
|
|
|
1073
1134
|
},
|
|
1074
1135
|
) {
|
|
1075
1136
|
const db = getDb();
|
|
1076
|
-
|
|
1137
|
+
// Monotonic, because `created_at` is a conversation's incarnation identity:
|
|
1138
|
+
// callers holding work for an id compare against the stamp they were
|
|
1139
|
+
// accepted for, so a row deleted and written back under that id has to carry
|
|
1140
|
+
// a later one even when both land in the same millisecond. Per process is
|
|
1141
|
+
// the scope that matters, the holders being in-memory and gone on a restart.
|
|
1142
|
+
const now = monotonicNow();
|
|
1077
1143
|
const initialSeq = getCurrentSeq();
|
|
1078
1144
|
const opts =
|
|
1079
1145
|
typeof titleOrOpts === "string"
|
|
@@ -2361,6 +2427,17 @@ export interface AddMessageOptions {
|
|
|
2361
2427
|
* internally. Pass the same value as `requestId` for user turns so
|
|
2362
2428
|
* the persisted row ID matches the runtime correlation ID. */
|
|
2363
2429
|
id?: string;
|
|
2430
|
+
/**
|
|
2431
|
+
* Answered synchronously at the top of every insert attempt, immediately
|
|
2432
|
+
* before that attempt's statement. False aborts with a
|
|
2433
|
+
* {@link MessageInsertPreconditionError} and writes nothing.
|
|
2434
|
+
*
|
|
2435
|
+
* For a caller whose right to write can lapse while the insert is in
|
|
2436
|
+
* flight. Per attempt rather than per call because contention retries the
|
|
2437
|
+
* insert after an awaited backoff, and the world can move under a caller
|
|
2438
|
+
* during that sleep.
|
|
2439
|
+
*/
|
|
2440
|
+
insertPrecondition?: () => boolean;
|
|
2364
2441
|
}
|
|
2365
2442
|
|
|
2366
2443
|
/**
|
|
@@ -2374,7 +2451,8 @@ export async function addMessage(
|
|
|
2374
2451
|
content: string,
|
|
2375
2452
|
options?: AddMessageOptions,
|
|
2376
2453
|
) {
|
|
2377
|
-
const { metadata, skipIndexing, clientMessageId, id } =
|
|
2454
|
+
const { metadata, skipIndexing, clientMessageId, id, insertPrecondition } =
|
|
2455
|
+
options ?? {};
|
|
2378
2456
|
const inserted = await insertMessageCore({
|
|
2379
2457
|
conversationId,
|
|
2380
2458
|
role,
|
|
@@ -2382,6 +2460,7 @@ export async function addMessage(
|
|
|
2382
2460
|
metadata,
|
|
2383
2461
|
clientMessageId,
|
|
2384
2462
|
id,
|
|
2463
|
+
...(insertPrecondition ? { insertPrecondition } : {}),
|
|
2385
2464
|
});
|
|
2386
2465
|
|
|
2387
2466
|
if (inserted.deduplicated) {
|