@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
|
@@ -25,6 +25,8 @@ import { ensureDisplayOrderMigration } from "./conversation-display-order-migrat
|
|
|
25
25
|
import { ensureGroupMigration } from "./conversation-group-migration.js";
|
|
26
26
|
import { searchMessageIdsLexical } from "./conversation-search-lexical.js";
|
|
27
27
|
import {
|
|
28
|
+
ASSISTANT_INITIATED_GROUP_ID,
|
|
29
|
+
ASSISTANT_INITIATED_SOURCE,
|
|
28
30
|
type ConversationType,
|
|
29
31
|
NATIVE_ORIGIN_CHANNEL,
|
|
30
32
|
PINNED_GROUP_ID,
|
|
@@ -213,6 +215,58 @@ function notBackgroundVisibilitySql(alias = "conversations"): string {
|
|
|
213
215
|
);
|
|
214
216
|
}
|
|
215
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Raw SQL predicate for "a thread the assistant started on its own": the rows
|
|
220
|
+
* stamped {@link ASSISTANT_INITIATED_SOURCE} at creation by a producer that
|
|
221
|
+
* opted the thread into the section.
|
|
222
|
+
*
|
|
223
|
+
* Membership rides `source` rather than `group_id` because those rows are
|
|
224
|
+
* filed nowhere — see {@link ASSISTANT_INITIATED_GROUP_ID} for why the section
|
|
225
|
+
* is still named as a group id. It is a dedicated source rather than the
|
|
226
|
+
* notification pipeline's `'notification'`, whose rows are the transactional
|
|
227
|
+
* request trails (guardian approvals, confirmation / access / question /
|
|
228
|
+
* tool-grant requests, channel deliveries) that belong to the bell and to
|
|
229
|
+
* Chats - see {@link ASSISTANT_INITIATED_SOURCE} for the full rationale.
|
|
230
|
+
*/
|
|
231
|
+
function assistantInitiatedSql(alias = "conversations"): string {
|
|
232
|
+
return `(${alias}.source = '${ASSISTANT_INITIATED_SOURCE}' AND ${ungroupedSql(alias)})`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The complement of {@link assistantInitiatedSql}, spelled out rather than
|
|
237
|
+
* wrapped in `NOT`.
|
|
238
|
+
*
|
|
239
|
+
* `source` is nullable and the overwhelming majority of rows carry NULL, so
|
|
240
|
+
* `NOT (source = '...')` evaluates to NULL for them under SQL's three-valued
|
|
241
|
+
* logic and the WHERE clause drops them. Negating the column naively would
|
|
242
|
+
* therefore not withhold one section from Chats - it would empty Chats.
|
|
243
|
+
*/
|
|
244
|
+
function notAssistantInitiatedSql(alias = "conversations"): string {
|
|
245
|
+
return `(${alias}.source IS NULL OR ${alias}.source != '${ASSISTANT_INITIATED_SOURCE}' OR NOT ${ungroupedSql(alias)})`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Raw SQL predicate for {@link UNGROUPED_GROUP_ID}: every row the sidebar's
|
|
250
|
+
* flat list shows — not pinned and not filed in a custom group.
|
|
251
|
+
*
|
|
252
|
+
* It deliberately admits the system buckets, because a surfaced background or
|
|
253
|
+
* scheduled conversation keeps its `system:background` / `system:scheduled`
|
|
254
|
+
* group id (surfacing writes only `surfaced_at`) while the standard listing
|
|
255
|
+
* renders it in Recents. Whether such a row is actually visible stays with
|
|
256
|
+
* {@link conversationTypeClause}, which admits the surfaced ones and excludes
|
|
257
|
+
* the rest; matching on group id alone here would drop them.
|
|
258
|
+
*
|
|
259
|
+
* Named because two sections are carved out of this same set — Chats and the
|
|
260
|
+
* assistant-initiated threads — and they have to agree on what it contains or
|
|
261
|
+
* a row lands in both or neither.
|
|
262
|
+
*/
|
|
263
|
+
function ungroupedSql(alias = "conversations"): string {
|
|
264
|
+
return (
|
|
265
|
+
`(${alias}.group_id IS NULL` +
|
|
266
|
+
` OR (${alias}.group_id LIKE 'system:%' AND ${alias}.group_id != '${PINNED_GROUP_ID}'))`
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
216
270
|
/**
|
|
217
271
|
* SQL predicate selecting which bucket {@link listConversations} and
|
|
218
272
|
* {@link countConversations} return, keyed by the canonical
|
|
@@ -293,6 +347,21 @@ export interface ConversationListFilter {
|
|
|
293
347
|
* row instead of paging past runs it would skip.
|
|
294
348
|
*/
|
|
295
349
|
foregroundOnly?: true;
|
|
350
|
+
/**
|
|
351
|
+
* Withhold the threads the assistant started on its own
|
|
352
|
+
* ({@link assistantInitiatedSql}) from the result.
|
|
353
|
+
*
|
|
354
|
+
* The exact complement of `groupId: ASSISTANT_INITIATED_GROUP_ID`, and the
|
|
355
|
+
* other half of splitting that section out of Chats: without it those rows
|
|
356
|
+
* would be counted and listed twice, once in their own section and once in
|
|
357
|
+
* the leftover bucket that never excluded them. Being the exact complement
|
|
358
|
+
* matters as much as the exclusion — a thread the user pinned or filed into
|
|
359
|
+
* a custom group is not in the assistant section, so this must not withhold
|
|
360
|
+
* it from the section that does hold it. Only `true` narrows; omit to keep
|
|
361
|
+
* every row, which is what every caller does while the
|
|
362
|
+
* `assistant-initiated-threads` flag is off.
|
|
363
|
+
*/
|
|
364
|
+
excludeAssistantInitiated?: true;
|
|
296
365
|
}
|
|
297
366
|
|
|
298
367
|
export interface ConversationListQuery extends ConversationListFilter {
|
|
@@ -313,16 +382,15 @@ export interface ConversationListQuery extends ConversationListFilter {
|
|
|
313
382
|
* declared in `schema/conversations.ts`.
|
|
314
383
|
*/
|
|
315
384
|
function groupIdClause(groupId: string) {
|
|
385
|
+
if (groupId === ASSISTANT_INITIATED_GROUP_ID) {
|
|
386
|
+
// Not a `group_id` match: `source` decides membership. See
|
|
387
|
+
// ASSISTANT_INITIATED_GROUP_ID. Still narrowed to the ungrouped set,
|
|
388
|
+
// because filing one of these threads into a custom group — or pinning it
|
|
389
|
+
// — must move it there rather than leave it in two sections at once.
|
|
390
|
+
return sql.raw(assistantInitiatedSql());
|
|
391
|
+
}
|
|
316
392
|
if (groupId === UNGROUPED_GROUP_ID) {
|
|
317
|
-
|
|
318
|
-
// and not filed in a custom group. It deliberately admits the system
|
|
319
|
-
// buckets, because a surfaced background or scheduled conversation keeps
|
|
320
|
-
// its `system:background` / `system:scheduled` group id (surfacing writes
|
|
321
|
-
// only `surfaced_at`) while the standard listing renders it in Recents.
|
|
322
|
-
// Whether such a row is actually visible stays with
|
|
323
|
-
// `conversationTypeClause`, which admits the surfaced ones and excludes
|
|
324
|
-
// the rest; matching on group id alone here would drop them.
|
|
325
|
-
return sql`(group_id IS NULL OR (group_id LIKE 'system:%' AND group_id != ${PINNED_GROUP_ID}))`;
|
|
393
|
+
return sql.raw(ungroupedSql());
|
|
326
394
|
}
|
|
327
395
|
// `group_id` is single-valued and authoritative, so a row belongs to
|
|
328
396
|
// exactly one section by construction. `is_pinned` is a derived duplicate
|
|
@@ -376,6 +444,7 @@ function conversationListWhere(filter: ConversationListFilter) {
|
|
|
376
444
|
groupId,
|
|
377
445
|
needsAttention,
|
|
378
446
|
foregroundOnly,
|
|
447
|
+
excludeAssistantInitiated,
|
|
379
448
|
} = filter;
|
|
380
449
|
return and(
|
|
381
450
|
conversationTypeClause(conversationType),
|
|
@@ -384,6 +453,7 @@ function conversationListWhere(filter: ConversationListFilter) {
|
|
|
384
453
|
groupId ? groupIdClause(groupId) : undefined,
|
|
385
454
|
...(needsAttention ? unseenAttentionStateConditions() : []),
|
|
386
455
|
foregroundOnly ? sql.raw(notBackgroundVisibilitySql()) : undefined,
|
|
456
|
+
excludeAssistantInitiated ? sql.raw(notAssistantInitiatedSql()) : undefined,
|
|
387
457
|
);
|
|
388
458
|
}
|
|
389
459
|
|
|
@@ -699,11 +769,34 @@ export interface ConversationSectionCount {
|
|
|
699
769
|
export interface ConversationSectionCounts {
|
|
700
770
|
groups: Array<{ groupId: string } & ConversationSectionCount>;
|
|
701
771
|
channels: Array<{ channel: string } & ConversationSectionCount>;
|
|
772
|
+
/**
|
|
773
|
+
* The assistant-initiated section, present only when the caller asked for
|
|
774
|
+
* the split ({@link CountConversationSectionsOptions.splitAssistantInitiated}).
|
|
775
|
+
* Absent — not zero — when it did not, so a caller that never asked cannot
|
|
776
|
+
* mistake "not split out" for "split out and empty".
|
|
777
|
+
*
|
|
778
|
+
* When present, these rows are withheld from `channels`, keeping the two
|
|
779
|
+
* axes disjoint the way `groups` and `channels` already are.
|
|
780
|
+
*/
|
|
781
|
+
assistantInitiated?: ConversationSectionCount;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export interface CountConversationSectionsOptions {
|
|
785
|
+
/**
|
|
786
|
+
* Count the threads the assistant started on its own as their own section
|
|
787
|
+
* and withhold them from the channel buckets. Off by default: the caller
|
|
788
|
+
* that knows about the `assistant-initiated-threads` flag turns it on, and
|
|
789
|
+
* every other caller keeps today's shape.
|
|
790
|
+
*/
|
|
791
|
+
splitAssistantInitiated?: boolean;
|
|
702
792
|
}
|
|
703
793
|
|
|
704
|
-
export function countConversationSections(
|
|
794
|
+
export function countConversationSections(
|
|
795
|
+
options: CountConversationSectionsOptions = {},
|
|
796
|
+
): ConversationSectionCounts {
|
|
705
797
|
ensureGroupMigration();
|
|
706
798
|
const db = getDb();
|
|
799
|
+
const { splitAssistantInitiated = false } = options;
|
|
707
800
|
|
|
708
801
|
/* `countUnreadConversations` gets the same effect with an INNER JOIN and
|
|
709
802
|
the unseen conditions in its WHERE; here every row must be counted and
|
|
@@ -747,12 +840,48 @@ export function countConversationSections(): ConversationSectionCounts {
|
|
|
747
840
|
conversationTypeClause("standard"),
|
|
748
841
|
isNull(conversations.archivedAt),
|
|
749
842
|
groupIdClause(UNGROUPED_GROUP_ID),
|
|
843
|
+
/* The split's other half. These rows are ungrouped and natively
|
|
844
|
+
attributed, so without this they stay in the Chats bucket and get
|
|
845
|
+
counted in both sections at once. */
|
|
846
|
+
splitAssistantInitiated
|
|
847
|
+
? sql.raw(notAssistantInitiatedSql())
|
|
848
|
+
: undefined,
|
|
750
849
|
),
|
|
751
850
|
)
|
|
752
851
|
.groupBy(effectiveChannel)
|
|
753
852
|
.all();
|
|
754
853
|
|
|
755
|
-
|
|
854
|
+
if (!splitAssistantInitiated) {
|
|
855
|
+
return { groups, channels };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/* Composed from the same predicates as the buckets above rather than
|
|
859
|
+
restated, so this section's badge cannot drift from theirs: same standard
|
|
860
|
+
listing visibility, same active-rows-only rule, same unread CASE. */
|
|
861
|
+
const [assistantInitiated] = db
|
|
862
|
+
.select({ total: count(), unread })
|
|
863
|
+
.from(conversations)
|
|
864
|
+
.leftJoin(conversationAssistantAttentionState, attentionJoin)
|
|
865
|
+
.where(
|
|
866
|
+
and(
|
|
867
|
+
conversationTypeClause("standard"),
|
|
868
|
+
isNull(conversations.archivedAt),
|
|
869
|
+
groupIdClause(ASSISTANT_INITIATED_GROUP_ID),
|
|
870
|
+
),
|
|
871
|
+
)
|
|
872
|
+
.all();
|
|
873
|
+
|
|
874
|
+
return {
|
|
875
|
+
groups,
|
|
876
|
+
channels,
|
|
877
|
+
/* The aggregate is over no GROUP BY, so it always yields one row — but it
|
|
878
|
+
yields `unread: null` rather than 0 when no row matched, because SUM
|
|
879
|
+
over an empty set is NULL. */
|
|
880
|
+
assistantInitiated: {
|
|
881
|
+
total: assistantInitiated?.total ?? 0,
|
|
882
|
+
unread: assistantInitiated?.unread ?? 0,
|
|
883
|
+
},
|
|
884
|
+
};
|
|
756
885
|
}
|
|
757
886
|
|
|
758
887
|
/**
|
|
@@ -1250,10 +1379,6 @@ function wrapRecallEvidenceExcerpt(
|
|
|
1250
1379
|
: wrapUntrustedContent(excerpt, { source });
|
|
1251
1380
|
}
|
|
1252
1381
|
|
|
1253
|
-
function escapeRegExp(value: string): string {
|
|
1254
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
1382
|
/**
|
|
1258
1383
|
* Earliest case-insensitive match of the query in `text`: the contiguous
|
|
1259
1384
|
* query when present, otherwise the earliest of its lexical tokens (same
|
|
@@ -1275,7 +1400,7 @@ function findEarliestMatch(
|
|
|
1275
1400
|
).exec(text);
|
|
1276
1401
|
};
|
|
1277
1402
|
|
|
1278
|
-
const whole = execAnchored(
|
|
1403
|
+
const whole = execAnchored(RegExp.escape(query));
|
|
1279
1404
|
if (whole) {
|
|
1280
1405
|
return { index: whole.index, length: whole[0].length };
|
|
1281
1406
|
}
|
|
@@ -1285,7 +1410,9 @@ function findEarliestMatch(
|
|
|
1285
1410
|
if (tokens.length === 0) {
|
|
1286
1411
|
return null;
|
|
1287
1412
|
}
|
|
1288
|
-
const match = execAnchored(
|
|
1413
|
+
const match = execAnchored(
|
|
1414
|
+
tokens.map((token) => RegExp.escape(token)).join("|"),
|
|
1415
|
+
);
|
|
1289
1416
|
return match ? { index: match.index, length: match[0].length } : null;
|
|
1290
1417
|
}
|
|
1291
1418
|
|
|
@@ -82,10 +82,7 @@ export function isBackgroundConversationType(
|
|
|
82
82
|
* columns into one label.
|
|
83
83
|
*/
|
|
84
84
|
export type ConversationKind =
|
|
85
|
-
| "
|
|
86
|
-
| "background"
|
|
87
|
-
| "background_memory_consolidation"
|
|
88
|
-
| "scheduled";
|
|
85
|
+
"user" | "background" | "background_memory_consolidation" | "scheduled";
|
|
89
86
|
|
|
90
87
|
/**
|
|
91
88
|
* Single classifier shared by the LLM-context routes and the notification
|
|
@@ -363,7 +360,9 @@ export function isDesktopOriginatedUserMessage(
|
|
|
363
360
|
return false;
|
|
364
361
|
}
|
|
365
362
|
const clientOs = parseClientOs((client as Record<string, unknown>).os);
|
|
366
|
-
return
|
|
363
|
+
return (
|
|
364
|
+
clientOs === "macos" || clientOs === "windows" || clientOs === "linux"
|
|
365
|
+
);
|
|
367
366
|
}
|
|
368
367
|
|
|
369
368
|
/**
|
|
@@ -449,6 +448,41 @@ export function isReplyPushIneligibleUserMessage(
|
|
|
449
448
|
export const UNGROUPED_GROUP_ID = "system:all";
|
|
450
449
|
export const PINNED_GROUP_ID = "system:pinned";
|
|
451
450
|
|
|
451
|
+
/**
|
|
452
|
+
* The `conversations.source` that makes a thread a member of the
|
|
453
|
+
* assistant-initiated section ({@link ASSISTANT_INITIATED_GROUP_ID}).
|
|
454
|
+
*
|
|
455
|
+
* Opt-in and stamped at creation, by a producer declaring "this is a thread
|
|
456
|
+
* I started because it is worth the user's time" - a heartbeat realization,
|
|
457
|
+
* an observation the user did not ask for. Deliberately NOT the notification
|
|
458
|
+
* pipeline's `'notification'`: the conversations that stamp carries are the
|
|
459
|
+
* transactional flows (guardian call approvals, confirmation / access /
|
|
460
|
+
* question / tool-grant requests, channel delivery trails - the
|
|
461
|
+
* `requiresConversation` producers), which belong to the bell and to Chats,
|
|
462
|
+
* and matching on it would also sweep every historical row into the section
|
|
463
|
+
* the day the flag turns on. A dedicated source makes membership all-new by
|
|
464
|
+
* construction: nothing stamps it until a producer passes it through
|
|
465
|
+
* `conversationMetadata.source` on its notification signal (see
|
|
466
|
+
* `notifications/conversation-pairing.ts`), so the section holds exactly the
|
|
467
|
+
* threads written for it and nothing retroactively.
|
|
468
|
+
*/
|
|
469
|
+
// FROZEN: persisted `conversations.source` value. Never rename it.
|
|
470
|
+
export const ASSISTANT_INITIATED_SOURCE = "assistant_initiated";
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The section holding the conversations the assistant started on its own:
|
|
474
|
+
* the threads stamped {@link ASSISTANT_INITIATED_SOURCE} at creation.
|
|
475
|
+
*
|
|
476
|
+
* A pseudo-group in the same sense as {@link UNGROUPED_GROUP_ID}: no row
|
|
477
|
+
* carries it in `group_id`. Those conversations are filed nowhere (NULL
|
|
478
|
+
* `group_id`), so without the split they land in Chats; membership is
|
|
479
|
+
* decided by `source`, not by the column this id names. It is spelled as a
|
|
480
|
+
* group id anyway so a client selects the section through the `groupId`
|
|
481
|
+
* parameter every other section already uses, rather than through a filter
|
|
482
|
+
* only this one section knows about.
|
|
483
|
+
*/
|
|
484
|
+
export const ASSISTANT_INITIATED_GROUP_ID = "system:assistant";
|
|
485
|
+
|
|
452
486
|
/**
|
|
453
487
|
* The `origin_channel` value for a conversation started in Vellum itself
|
|
454
488
|
* rather than arriving from an external channel.
|
|
@@ -5,10 +5,21 @@
|
|
|
5
5
|
* finding messages by source identifiers, and managing raw payload storage.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
and,
|
|
10
|
+
desc,
|
|
11
|
+
eq,
|
|
12
|
+
isNotNull,
|
|
13
|
+
like,
|
|
14
|
+
ne,
|
|
15
|
+
notExists,
|
|
16
|
+
or,
|
|
17
|
+
sql,
|
|
18
|
+
} from "drizzle-orm";
|
|
9
19
|
import { v4 as uuid } from "uuid";
|
|
10
20
|
|
|
11
21
|
import type { ChannelId } from "../channels/types.js";
|
|
22
|
+
import type { ProviderMessageMetadata } from "../messaging/provider-message-metadata.js";
|
|
12
23
|
import { readProviderMetadata } from "../messaging/read-provider-metadata.js";
|
|
13
24
|
import type { SlackInboundMessageMetadata } from "../runtime/http-types.js";
|
|
14
25
|
import { parseJsonSafe } from "../util/json.js";
|
|
@@ -23,6 +34,7 @@ import type { NonScheduledConversationType } from "./conversation-types.js";
|
|
|
23
34
|
import { getDb } from "./db-connection.js";
|
|
24
35
|
import {
|
|
25
36
|
channelInboundEvents,
|
|
37
|
+
channelOutboundPosts,
|
|
26
38
|
conversationKeys,
|
|
27
39
|
conversations,
|
|
28
40
|
messages,
|
|
@@ -55,8 +67,26 @@ const SLACK_LEGACY_THREAD_EVIDENCE_MAX_SCAN = 500;
|
|
|
55
67
|
* Bounded on purpose: the scan runs on the inbound path while the gateway
|
|
56
68
|
* waits for its ack, and reactions land on recent messages, so a cap costs
|
|
57
69
|
* almost no recall and keeps the cost flat as the database grows.
|
|
70
|
+
*
|
|
71
|
+
* The candidate set is provenance-bearing rows with no inbound-event link
|
|
72
|
+
* (the NOT EXISTS in the query): inbound user rows carry `providerMeta` too,
|
|
73
|
+
* but the inbound-event index already resolves them, so admitting them here
|
|
74
|
+
* would only shrink the window of assistant posts the cap can reach.
|
|
75
|
+
*/
|
|
76
|
+
const OUTBOUND_MESSAGE_ID_MAX_SCAN = 800;
|
|
77
|
+
const PROVIDER_MESSAGE_ID_SCAN_BATCH_SIZE = 200;
|
|
78
|
+
const UNRECONCILED_OUTBOUND_ROW_SCAN = 25;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Widened `maxScan` for the deletion path's TRANSITIONAL fallback. Posts
|
|
82
|
+
* delivered since `channel_outbound_posts` exists resolve through that
|
|
83
|
+
* index exactly, at any age; this bound serves only rows reconciled before
|
|
84
|
+
* the table. A delete can target an arbitrarily old such post, so recency
|
|
85
|
+
* is not a completeness contract, but deletes are rare and run off the
|
|
86
|
+
* gateway-ack hot path, so a deep batched scan is affordable. Delete this
|
|
87
|
+
* (and the fallback scan) once pre-table rows stop mattering.
|
|
58
88
|
*/
|
|
59
|
-
const
|
|
89
|
+
export const DELETE_PROVIDER_MESSAGE_ID_MAX_SCAN = 20_000;
|
|
60
90
|
|
|
61
91
|
/**
|
|
62
92
|
* Channels where an inbound thread id scopes the conversation: a Slack thread
|
|
@@ -295,33 +325,173 @@ export function findInboundEvent(
|
|
|
295
325
|
}
|
|
296
326
|
|
|
297
327
|
/**
|
|
298
|
-
* The
|
|
299
|
-
*
|
|
328
|
+
* The message row carrying this provider id in its stored envelope, on any
|
|
329
|
+
* channel. Returns the same shape as {@link findMessageBySourceId} so the
|
|
330
|
+
* two resolution legs compose: that one covers every message that arrived
|
|
331
|
+
* as an inbound event, and this one covers what the assistant posted, which
|
|
332
|
+
* opens no inbound event and is only identifiable by the id its row carries.
|
|
300
333
|
*
|
|
301
334
|
* Reads through `readProviderMetadata`, so it matches any channel that
|
|
302
335
|
* describes its rows in the neutral shape as well as Slack's own envelope.
|
|
303
336
|
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
* finding it at all.
|
|
337
|
+
* The search is confined to conversations already bound to the same
|
|
338
|
+
* channel address and, by default, to the most recent
|
|
339
|
+
* {@link OUTBOUND_MESSAGE_ID_MAX_SCAN} rows among them: reactions land on
|
|
340
|
+
* recent messages and the reaction path runs while the gateway waits for
|
|
341
|
+
* its ack. A caller resolving an event that can target arbitrarily old
|
|
342
|
+
* posts off the hot path (a deletion) widens the window via `maxScan`;
|
|
343
|
+
* beyond whichever bound applies the lookup gives up and the caller drops
|
|
344
|
+
* the annotation, which is the same outcome as never finding it at all.
|
|
312
345
|
*/
|
|
313
|
-
export function
|
|
346
|
+
export function findMessageByProviderMessageId(
|
|
314
347
|
sourceChannel: string,
|
|
315
348
|
externalChatId: string,
|
|
316
349
|
providerMessageId: string,
|
|
317
|
-
|
|
350
|
+
opts?: { maxScan?: number },
|
|
351
|
+
): { messageId: string; conversationId: string } | null {
|
|
318
352
|
const db = getDb();
|
|
319
|
-
|
|
320
|
-
|
|
353
|
+
|
|
354
|
+
// The `channel_outbound_posts` index is the resolution contract: exact,
|
|
355
|
+
// unbounded by recency, one indexed read. It covers every post delivered
|
|
356
|
+
// since the table exists; the batched envelope scan below survives only
|
|
357
|
+
// as the transitional fallback for rows reconciled before it.
|
|
358
|
+
const indexed = db
|
|
321
359
|
.select({
|
|
322
|
-
|
|
323
|
-
|
|
360
|
+
messageId: channelOutboundPosts.messageId,
|
|
361
|
+
conversationId: channelOutboundPosts.conversationId,
|
|
324
362
|
})
|
|
363
|
+
.from(channelOutboundPosts)
|
|
364
|
+
.where(
|
|
365
|
+
and(
|
|
366
|
+
eq(channelOutboundPosts.sourceChannel, sourceChannel),
|
|
367
|
+
eq(channelOutboundPosts.externalChatId, externalChatId),
|
|
368
|
+
eq(channelOutboundPosts.providerMessageId, providerMessageId),
|
|
369
|
+
),
|
|
370
|
+
)
|
|
371
|
+
.get();
|
|
372
|
+
if (indexed) {
|
|
373
|
+
return indexed;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const keyPrefix = `${CONVERSATION_KEY_SCOPE}:${sourceChannel}:${externalChatId}`;
|
|
377
|
+
const maxScan = opts?.maxScan ?? OUTBOUND_MESSAGE_ID_MAX_SCAN;
|
|
378
|
+
|
|
379
|
+
let offset = 0;
|
|
380
|
+
while (offset < maxScan) {
|
|
381
|
+
const batchLimit = Math.min(
|
|
382
|
+
PROVIDER_MESSAGE_ID_SCAN_BATCH_SIZE,
|
|
383
|
+
maxScan - offset,
|
|
384
|
+
);
|
|
385
|
+
const rows = db
|
|
386
|
+
.select({
|
|
387
|
+
id: messages.id,
|
|
388
|
+
conversationId: messages.conversationId,
|
|
389
|
+
metadata: messages.metadata,
|
|
390
|
+
})
|
|
391
|
+
.from(messages)
|
|
392
|
+
.innerJoin(
|
|
393
|
+
conversationKeys,
|
|
394
|
+
eq(conversationKeys.conversationId, messages.conversationId),
|
|
395
|
+
)
|
|
396
|
+
.where(
|
|
397
|
+
and(
|
|
398
|
+
or(
|
|
399
|
+
eq(conversationKeys.conversationKey, keyPrefix),
|
|
400
|
+
like(conversationKeys.conversationKey, `${keyPrefix}:thread:%`),
|
|
401
|
+
),
|
|
402
|
+
or(
|
|
403
|
+
like(messages.metadata, '%"providerMeta"%'),
|
|
404
|
+
like(messages.metadata, '%"slackMeta"%'),
|
|
405
|
+
),
|
|
406
|
+
// Rows the inbound-event index resolves are not candidates: this
|
|
407
|
+
// fallback exists for messages `findMessageBySourceId` cannot
|
|
408
|
+
// answer (the assistant's pre-table posts, a crash-window inbound
|
|
409
|
+
// row whose link never landed, and legacy linked rows whose event
|
|
410
|
+
// carries no source_message_id), and admitting resolvable rows
|
|
411
|
+
// would burn the scan budget on messages the primary path already
|
|
412
|
+
// answers.
|
|
413
|
+
notExists(
|
|
414
|
+
db
|
|
415
|
+
.select({ id: channelInboundEvents.id })
|
|
416
|
+
.from(channelInboundEvents)
|
|
417
|
+
.where(
|
|
418
|
+
and(
|
|
419
|
+
eq(channelInboundEvents.messageId, messages.id),
|
|
420
|
+
isNotNull(channelInboundEvents.sourceMessageId),
|
|
421
|
+
),
|
|
422
|
+
),
|
|
423
|
+
),
|
|
424
|
+
),
|
|
425
|
+
)
|
|
426
|
+
.orderBy(desc(messages.createdAt))
|
|
427
|
+
.limit(batchLimit)
|
|
428
|
+
.offset(offset)
|
|
429
|
+
.all();
|
|
430
|
+
|
|
431
|
+
for (const row of rows) {
|
|
432
|
+
const meta = readProviderMetadata(row.metadata, {
|
|
433
|
+
allowFlatLegacy: true,
|
|
434
|
+
});
|
|
435
|
+
if (
|
|
436
|
+
meta?.conversationExternalId === externalChatId &&
|
|
437
|
+
(meta.messageId === providerMessageId ||
|
|
438
|
+
meta.additionalMessageIds?.includes(providerMessageId))
|
|
439
|
+
) {
|
|
440
|
+
return { messageId: row.id, conversationId: row.conversationId };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (rows.length < batchLimit) {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
offset += rows.length;
|
|
447
|
+
}
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Record one provider post the assistant's delivery produced, into the
|
|
453
|
+
* `channel_outbound_posts` index. Idempotent: a redelivered id upserts onto
|
|
454
|
+
* the same triple and changes nothing. Written by the post-send
|
|
455
|
+
* reconciliation alongside the row's `providerMeta` envelope; the envelope
|
|
456
|
+
* stays the row's self-description, this table is the resolution index.
|
|
457
|
+
*/
|
|
458
|
+
export function recordOutboundPost(post: {
|
|
459
|
+
sourceChannel: string;
|
|
460
|
+
externalChatId: string;
|
|
461
|
+
providerMessageId: string;
|
|
462
|
+
messageId: string;
|
|
463
|
+
conversationId: string;
|
|
464
|
+
}): void {
|
|
465
|
+
const db = getDb();
|
|
466
|
+
db.insert(channelOutboundPosts)
|
|
467
|
+
.values({
|
|
468
|
+
sourceChannel: post.sourceChannel,
|
|
469
|
+
externalChatId: post.externalChatId,
|
|
470
|
+
providerMessageId: post.providerMessageId,
|
|
471
|
+
messageId: post.messageId,
|
|
472
|
+
conversationId: post.conversationId,
|
|
473
|
+
createdAt: Date.now(),
|
|
474
|
+
})
|
|
475
|
+
.onConflictDoNothing()
|
|
476
|
+
.run();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Whether the chat has a recent outbound row still awaiting its post-send id
|
|
481
|
+
* reconciliation (a neutral envelope naming no `messageId`). The deletion
|
|
482
|
+
* path's evidence that "unresolvable" may mean "not reconciled yet" rather
|
|
483
|
+
* than "never stored": with no such row, a miss is final and the delete
|
|
484
|
+
* returns without paying a retry window. Bounded to the newest handful of
|
|
485
|
+
* rows: the race it detects is one delivery round-trip wide.
|
|
486
|
+
*/
|
|
487
|
+
export function hasUnreconciledOutboundRow(
|
|
488
|
+
sourceChannel: string,
|
|
489
|
+
externalChatId: string,
|
|
490
|
+
): boolean {
|
|
491
|
+
const db = getDb();
|
|
492
|
+
const keyPrefix = `${CONVERSATION_KEY_SCOPE}:${sourceChannel}:${externalChatId}`;
|
|
493
|
+
const rows = db
|
|
494
|
+
.select({ metadata: messages.metadata })
|
|
325
495
|
.from(messages)
|
|
326
496
|
.innerJoin(
|
|
327
497
|
conversationKeys,
|
|
@@ -333,27 +503,38 @@ export function findConversationByProviderMessageId(
|
|
|
333
503
|
eq(conversationKeys.conversationKey, keyPrefix),
|
|
334
504
|
like(conversationKeys.conversationKey, `${keyPrefix}:thread:%`),
|
|
335
505
|
),
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
506
|
+
like(messages.metadata, '%"providerMeta"%'),
|
|
507
|
+
// Inbound rows carry `providerMeta` too, and a busy chat's newest
|
|
508
|
+
// rows are mostly inbound; excluding rows the inbound-event index
|
|
509
|
+
// resolves keeps this probe's small window counting only rows that
|
|
510
|
+
// could actually be an unreconciled outbound post.
|
|
511
|
+
notExists(
|
|
512
|
+
db
|
|
513
|
+
.select({ id: channelInboundEvents.id })
|
|
514
|
+
.from(channelInboundEvents)
|
|
515
|
+
.where(
|
|
516
|
+
and(
|
|
517
|
+
eq(channelInboundEvents.messageId, messages.id),
|
|
518
|
+
isNotNull(channelInboundEvents.sourceMessageId),
|
|
519
|
+
),
|
|
520
|
+
),
|
|
339
521
|
),
|
|
340
522
|
),
|
|
341
523
|
)
|
|
342
524
|
.orderBy(desc(messages.createdAt))
|
|
343
|
-
.limit(
|
|
525
|
+
.limit(UNRECONCILED_OUTBOUND_ROW_SCAN)
|
|
344
526
|
.all();
|
|
345
|
-
|
|
346
527
|
for (const row of rows) {
|
|
347
|
-
const meta = readProviderMetadata(row.metadata
|
|
528
|
+
const meta = readProviderMetadata(row.metadata);
|
|
348
529
|
if (
|
|
349
530
|
meta?.conversationExternalId === externalChatId &&
|
|
350
|
-
|
|
351
|
-
|
|
531
|
+
meta.eventKind === "message" &&
|
|
532
|
+
meta.messageId === undefined
|
|
352
533
|
) {
|
|
353
|
-
return
|
|
534
|
+
return true;
|
|
354
535
|
}
|
|
355
536
|
}
|
|
356
|
-
return
|
|
537
|
+
return false;
|
|
357
538
|
}
|
|
358
539
|
|
|
359
540
|
export function recordInbound(
|
|
@@ -653,6 +834,21 @@ export function storeInboundSlackMetadata(
|
|
|
653
834
|
mergeRawPayload(eventId, { slackInbound });
|
|
654
835
|
}
|
|
655
836
|
|
|
837
|
+
/**
|
|
838
|
+
* Persist the neutral channel inbound envelope captured at ingress onto the
|
|
839
|
+
* stored payload, the non-Slack counterpart of
|
|
840
|
+
* {@link storeInboundSlackMetadata}: the retry sweep replays the turn with
|
|
841
|
+
* the SAME `channelInbound` the live path used, so the replayed row carries
|
|
842
|
+
* an identical `providerMeta` envelope. No-ops when the payload was cleared
|
|
843
|
+
* (e.g. a secret-bearing ingress), so cleared secrets are never resurrected.
|
|
844
|
+
*/
|
|
845
|
+
export function storeInboundChannelMetadata(
|
|
846
|
+
eventId: string,
|
|
847
|
+
channelInbound: ProviderMessageMetadata,
|
|
848
|
+
): void {
|
|
849
|
+
mergeRawPayload(eventId, { channelInbound });
|
|
850
|
+
}
|
|
851
|
+
|
|
656
852
|
/**
|
|
657
853
|
* Return the `slackStreamMessageTs` durably recorded by any sibling inbound
|
|
658
854
|
* event linked to the given user message (excluding `excludeEventId`).
|
|
@@ -14,8 +14,27 @@ import {
|
|
|
14
14
|
RETRY_MAX_ATTEMPTS,
|
|
15
15
|
retryDelayForAttempt,
|
|
16
16
|
} from "./job-utils.js";
|
|
17
|
+
import type { ChannelDeliveryStatus } from "./schema.js";
|
|
17
18
|
import { channelInboundEvents } from "./schema.js";
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Whether a delivery status means the row owes no further delivery.
|
|
22
|
+
*
|
|
23
|
+
* A total map over the union rather than a `!== "pending"` comparison: adding
|
|
24
|
+
* a status is then a compile error until its terminality is decided, and that
|
|
25
|
+
* decision is load-bearing. `isDeduplicatedDeliveryOwnedBySibling` reads it to
|
|
26
|
+
* decide whether a sibling event already owns this turn's reply, and the
|
|
27
|
+
* stranded-delivery recovery step treats a non-terminal row as a reply still
|
|
28
|
+
* owed. A new status silently defaulting to one side or the other either
|
|
29
|
+
* double-posts a reply or drops one.
|
|
30
|
+
*/
|
|
31
|
+
const DELIVERY_STATUS_IS_TERMINAL: Record<ChannelDeliveryStatus, boolean> = {
|
|
32
|
+
pending: false,
|
|
33
|
+
delivered: true,
|
|
34
|
+
failed: true,
|
|
35
|
+
dead_letter: true,
|
|
36
|
+
};
|
|
37
|
+
|
|
19
38
|
/**
|
|
20
39
|
* How long {@link deferRetryUntilIdle} pushes `retryAfter` forward. Shorter than
|
|
21
40
|
* the retry sweep's own interval so a busy-deferred event is re-evaluated on the
|
|
@@ -327,7 +346,7 @@ export function getRetryableDeliveryEvents(limit = 20): Array<{
|
|
|
327
346
|
export function getSiblingEventDeliveryStatuses(
|
|
328
347
|
messageId: string,
|
|
329
348
|
excludeEventId: string,
|
|
330
|
-
):
|
|
349
|
+
): ChannelDeliveryStatus[] {
|
|
331
350
|
const db = getDb();
|
|
332
351
|
return db
|
|
333
352
|
.select({ deliveryStatus: channelInboundEvents.deliveryStatus })
|
|
@@ -355,7 +374,7 @@ export function isDeduplicatedDeliveryOwnedBySibling(
|
|
|
355
374
|
excludeEventId: string,
|
|
356
375
|
): boolean {
|
|
357
376
|
return getSiblingEventDeliveryStatuses(messageId, excludeEventId).some(
|
|
358
|
-
(status) => status
|
|
377
|
+
(status) => DELIVERY_STATUS_IS_TERMINAL[status],
|
|
359
378
|
);
|
|
360
379
|
}
|
|
361
380
|
|