@vellumai/assistant 0.11.3 → 0.11.4-staging.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +11 -6
- package/docs/architecture/memory.md +11 -0
- package/docs/architecture/turn-actor.md +70 -0
- package/docs/flux-turn-detection-spike.md +243 -0
- package/docs/stt-provider-onboarding.md +3 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +34 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/openapi.yaml +140 -38
- package/package.json +1 -1
- package/scripts/voice-ttft-spike.ts +3 -3
- package/src/__tests__/app-compiler.test.ts +38 -3
- package/src/__tests__/attachments-store.test.ts +21 -12
- package/src/__tests__/byok-default-profile-ensure.test.ts +2 -0
- package/src/__tests__/call-setup-flow-name-capture.test.ts +0 -1
- package/src/__tests__/call-site-routing-provider.test.ts +1 -1
- package/src/__tests__/channel-availability-routes.test.ts +14 -1
- package/src/__tests__/channel-capabilities-dedupe.test.ts +214 -0
- package/src/__tests__/channel-delivery-store.test.ts +14 -14
- package/src/__tests__/config-loader-backfill.test.ts +3 -3
- package/src/__tests__/config-schema.test.ts +25 -10
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +8 -11
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +8 -11
- package/src/__tests__/conversation-agent-loop.test.ts +28 -20
- package/src/__tests__/conversation-attention-store.test.ts +63 -0
- package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +0 -4
- package/src/__tests__/conversation-fork-crud.test.ts +69 -0
- package/src/__tests__/conversation-fork-referential.test.ts +67 -0
- package/src/__tests__/conversation-fork-retrospective.test.ts +24 -0
- package/src/__tests__/conversation-notifiers-provenance.test.ts +59 -0
- package/src/__tests__/conversation-queue.test.ts +55 -4
- package/src/__tests__/conversation-runtime-assembly.test.ts +134 -102
- package/src/__tests__/conversation-runtime-workspace.test.ts +14 -10
- package/src/__tests__/credential-prompt-route.test.ts +7 -10
- package/src/__tests__/custom-profile-ensure.test.ts +5 -1
- package/src/__tests__/discord-access-request-privacy.test.ts +5 -1
- package/src/__tests__/discord-requester-notice-privacy.test.ts +3 -3
- package/src/__tests__/document-append-idempotency.test.ts +233 -0
- package/src/__tests__/edit-propagation.test.ts +0 -7
- package/src/__tests__/helpers/mock-actor-context.ts +49 -0
- package/src/__tests__/helpers/mock-conversation.ts +13 -1
- package/src/__tests__/injector-chain.test.ts +63 -41
- package/src/__tests__/injector-disk-pressure.test.ts +11 -23
- package/src/__tests__/llm-context-resolution.test.ts +73 -1
- package/src/__tests__/llm-schema.test.ts +5 -2
- package/src/__tests__/mcp-list-plugin-servers.test.ts +250 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +6 -5
- package/src/__tests__/messages-read-boundary-guard.test.ts +134 -0
- package/src/__tests__/mtime-cache.test.ts +1 -1
- package/src/__tests__/non-member-access-request.test.ts +0 -20
- package/src/__tests__/outbound-slack-persistence.test.ts +40 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +5 -0
- package/src/__tests__/plugin-secret-pattern-contribution.test.ts +1 -1
- package/src/__tests__/post-compaction-reinjection-idempotency.test.ts +14 -7
- package/src/__tests__/provider-commit-message-generator.test.ts +20 -0
- package/src/__tests__/run-conversation-turn-persistence.test.ts +434 -105
- package/src/__tests__/scoped-approval-grants.test.ts +11 -6
- package/src/__tests__/secret-ingress-channel.test.ts +0 -1
- package/src/__tests__/skills.test.ts +32 -0
- package/src/__tests__/slack-edit-ordering-characterization.test.ts +0 -1
- package/src/__tests__/subagent-call-site-routing.test.ts +31 -19
- package/src/__tests__/subagent-spawn-and-await.test.ts +14 -10
- package/src/__tests__/turn-events-store.test.ts +43 -0
- package/src/__tests__/user-plugin-loader.test.ts +1 -1
- package/src/__tests__/visible-app-context.test.ts +16 -9
- package/src/__tests__/worker-entrypoint-guards.test.ts +54 -0
- package/src/__tests__/worker-plugin-surface.test.ts +77 -0
- package/src/__tests__/workspace-migration-142-consolidate-voice-front-door.test.ts +158 -0
- package/src/__tests__/workspace-migration-143-repair-deprecated-codex-model-id.test.ts +133 -0
- package/src/__tests__/workspace-migration-144-convert-stranded-subscription-openai-profiles.test.ts +316 -0
- package/src/__tests__/workspace-migration-145-collapse-profile-bindings-to-entries.test.ts +325 -0
- package/src/acp/__tests__/acp-claude-oauth.test.ts +10 -2
- package/src/acp/__tests__/auth-required.test.ts +161 -0
- package/src/acp/acp-claude-oauth.ts +19 -2
- package/src/acp/agent-process.test.ts +100 -0
- package/src/acp/agent-process.ts +29 -26
- package/src/acp/auth-required.ts +102 -0
- package/src/acp/session-manager.test.ts +119 -0
- package/src/acp/session-manager.ts +68 -2
- package/src/api/events/acp-auth-required.ts +55 -0
- package/src/api/index.ts +7 -0
- package/src/apps/app-store.ts +3 -0
- package/src/bundler/package-resolver.ts +2 -30
- package/src/calls/__tests__/voice-session-bridge.test.ts +173 -1
- package/src/calls/__tests__/voice-triage-escalate.test.ts +94 -0
- package/src/calls/call-controller.ts +9 -2
- package/src/calls/call-setup-flow.ts +0 -1
- package/src/calls/media-stream-stt-session.ts +15 -0
- package/src/calls/voice-session-bridge.ts +71 -16
- package/src/calls/voice-triage-escalate.ts +104 -2
- package/src/channels/__tests__/plugin-channel-declarations.test.ts +161 -0
- package/src/channels/config.ts +13 -0
- package/src/channels/plugin-channel-declarations.ts +108 -0
- package/src/channels/types.ts +30 -0
- package/src/cli/AGENTS.md +5 -2
- package/src/cli/commands/credentials.help.ts +2 -2
- package/src/cli/commands/inference-providers.ts +1 -1
- package/src/cli/commands/mcp.help.ts +13 -4
- package/src/cli/commands/mcp.ts +9 -0
- package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
- package/src/cli/commands/memory/index.help.ts +43 -1
- package/src/cli/commands/memory/memory-v3.ts +64 -0
- package/src/cli/commands/stt.help.ts +27 -2
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +39 -0
- package/src/cli/lib/bundled-marketplace.json +13 -0
- package/src/cli/lib/upgrade-plugin.ts +42 -0
- package/src/config/__tests__/default-profile-catalog.test.ts +34 -2
- package/src/config/__tests__/default-provider.test.ts +6 -1
- package/src/config/__tests__/profile-materialization.test.ts +75 -19
- package/src/config/bundled-skills/acp/SKILL.md +6 -7
- package/src/config/bundled-skills/document-editor/SKILL.md +2 -2
- package/src/config/bundled-skills/document-editor/TOOLS.json +2 -2
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +14 -4
- package/src/config/bundled-skills/settings/TOOLS.json +3 -3
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +22 -1
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +9 -2
- package/src/config/call-site-defaults.ts +4 -5
- package/src/config/default-profile-catalog.ts +82 -11
- package/src/config/default-profile-names.ts +4 -1
- package/src/config/default-provider-resolution.ts +4 -0
- package/src/config/llm-context-resolution.ts +11 -3
- package/src/config/llm-resolver.ts +28 -1
- package/src/config/profile-materialization.ts +70 -22
- package/src/config/schemas/__tests__/live-voice.test.ts +107 -4
- package/src/config/schemas/call-site-catalog.ts +4 -4
- package/src/config/schemas/live-voice.ts +57 -23
- package/src/config/schemas/llm.ts +59 -32
- package/src/config/schemas/mcp.ts +23 -0
- package/src/config/schemas/plugin-updates.ts +6 -2
- package/src/config/schemas/stt.ts +1 -0
- package/src/context/outbound-sanitize.ts +96 -1
- package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +15 -10
- package/src/daemon/conversation-agent-loop.ts +17 -6
- package/src/daemon/conversation-messaging.ts +5 -1
- package/src/daemon/conversation-notifiers.ts +9 -1
- package/src/daemon/conversation-process.ts +9 -6
- package/src/daemon/conversation-runtime-assembly.ts +3 -4
- package/src/daemon/conversation-tool-setup.ts +1 -2
- package/src/daemon/conversation.ts +48 -0
- package/src/daemon/mcp-reload-service.ts +36 -6
- package/src/daemon/process-message.ts +13 -3
- package/src/daemon/providers-setup.ts +6 -3
- package/src/daemon/trust-context-types.ts +29 -0
- package/src/daemon/wake-conversation-ops.ts +3 -2
- package/src/documents/document-store.ts +138 -5
- package/src/hooks/hook-loader.ts +3 -3
- package/src/hooks/registry.ts +50 -6
- package/src/inbound/__tests__/oauth-callback-url.test.ts +83 -0
- package/src/inbound/oauth-callback-url.ts +61 -0
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +1 -104
- package/src/live-voice/__tests__/live-voice-events.test.ts +7 -8
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +932 -0
- package/src/live-voice/__tests__/live-voice-metrics.test.ts +115 -8
- package/src/live-voice/__tests__/live-voice-photo.test.ts +100 -0
- package/src/live-voice/__tests__/live-voice-progress.test.ts +60 -194
- package/src/live-voice/__tests__/live-voice-stt.test.ts +14 -0
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +29 -0
- package/src/live-voice/__tests__/live-voice-tts-session.test.ts +0 -483
- package/src/live-voice/__tests__/live-voice-vad.test.ts +0 -16
- package/src/live-voice/__tests__/progress-narration.test.ts +214 -0
- package/src/live-voice/live-voice-archive.ts +2 -0
- package/src/live-voice/live-voice-metrics.ts +57 -32
- package/src/live-voice/live-voice-photo.ts +1 -2
- package/src/live-voice/live-voice-session.ts +535 -314
- package/src/live-voice/progress-narration.ts +277 -0
- package/src/live-voice/protocol.ts +21 -1
- package/src/mcp/__tests__/effective-config.test.ts +238 -0
- package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +0 -1
- package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +200 -0
- package/src/mcp/__tests__/mcp-oauth-provider.test.ts +9 -9
- package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
- package/src/mcp/client.ts +16 -11
- package/src/mcp/effective-config.ts +113 -0
- package/src/mcp/manager.ts +11 -6
- package/src/mcp/mcp-auth-orchestrator.ts +13 -22
- package/src/mcp/mcp-oauth-provider.ts +205 -240
- package/src/monitoring/__tests__/plugin-auto-update.test.ts +166 -3
- package/src/monitoring/plugin-auto-update.ts +128 -24
- package/src/notifications/signal.ts +1 -0
- package/src/permissions/confirmation-guardian-request.test.ts +15 -11
- package/src/permissions/confirmation-guardian-request.ts +2 -2
- package/src/permissions/question-guardian-request.test.ts +14 -6
- package/src/permissions/question-guardian-request.ts +1 -2
- package/src/persistence/attachments-store.ts +8 -1
- package/src/persistence/bookmark-crud.ts +3 -7
- package/src/persistence/conversation-attention-store.ts +16 -45
- package/src/persistence/conversation-crud.ts +33 -4
- package/src/persistence/conversation-lineage.ts +9 -0
- package/src/persistence/conversation-queries.ts +108 -41
- package/src/persistence/delivery-crud.ts +38 -29
- package/src/persistence/external-conversation-store.ts +32 -4
- package/src/persistence/llm-request-log-store.ts +4 -10
- package/src/persistence/llm-usage-store.ts +8 -3
- package/src/persistence/message-reads.test.ts +197 -0
- package/src/persistence/message-reads.ts +211 -0
- package/src/persistence/migrations/366-chatgpt-subscription-row-identity.test.ts +120 -0
- package/src/persistence/migrations/366-chatgpt-subscription-row-identity.ts +62 -0
- package/src/persistence/real-user-turn-filter.ts +27 -3
- package/src/persistence/steps.ts +9 -0
- package/src/plugin-api/__tests__/oauth-callback-url-export.test.ts +29 -0
- package/src/plugin-api/conversation-turn.ts +168 -5
- package/src/plugin-api/index.ts +21 -5
- package/src/plugins/__tests__/mcp-servers.test.ts +371 -0
- package/src/plugins/defaults/memory/AGENTS.md +4 -0
- package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +204 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +72 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +4 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +4 -1
- package/src/plugins/defaults/memory/buffer-format.ts +165 -0
- package/src/plugins/defaults/memory/context-search/sources/conversations.ts +6 -0
- package/src/plugins/defaults/memory/graph/image-ref-utils.ts +3 -0
- package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -30
- package/src/plugins/defaults/memory/graph-topology/pending-buffer.test.ts +34 -0
- package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +8 -12
- package/src/plugins/defaults/memory/hooks/post-compact.ts +1 -4
- package/src/plugins/defaults/memory/indexer.ts +3 -1
- package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +19 -7
- package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
- package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +33 -0
- package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +199 -2
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +25 -18
- package/src/plugins/defaults/memory/substrate/skill-content.ts +8 -1
- package/src/plugins/defaults/memory/substrate/static-context.ts +160 -4
- package/src/plugins/defaults/memory/substrate/sweep-job.ts +2 -4
- package/src/plugins/defaults/memory/v1/graph/extraction.ts +3 -1
- package/src/plugins/defaults/memory/v3/prune.ts +2 -0
- package/src/plugins/defaults/memory/v3/selection-log-store.ts +2 -0
- package/src/plugins/defaults/memory/worker.ts +6 -3
- package/src/plugins/external-plugin-loader.ts +47 -0
- package/src/plugins/mcp-servers.ts +361 -0
- package/src/plugins/mtime-cache.ts +23 -49
- package/src/plugins/worker-plugin-surface.ts +33 -0
- package/src/providers/__tests__/connection-model-compat.test.ts +1 -1
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +214 -2
- package/src/providers/__tests__/preflight-resolved-config.test.ts +57 -0
- package/src/providers/__tests__/retry-callsite.test.ts +5 -2
- package/src/providers/call-site-routing.ts +30 -3
- package/src/providers/connection-resolution.ts +194 -11
- package/src/providers/inference/auth.ts +6 -0
- package/src/providers/inference/connection-availability.ts +24 -2
- package/src/providers/inference/connections.ts +2 -0
- package/src/providers/model-intents.ts +26 -6
- package/src/providers/openai/codex-models.ts +2 -1
- package/src/providers/provider-send-message.ts +32 -3
- package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +433 -0
- package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +620 -0
- package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +34 -0
- package/src/providers/speech-to-text/__tests__/resolve.test.ts +285 -6
- package/src/providers/speech-to-text/deepgram-flux-frames.ts +395 -0
- package/src/providers/speech-to-text/deepgram-flux-realtime.ts +719 -0
- package/src/providers/speech-to-text/provider-catalog.ts +99 -8
- package/src/providers/speech-to-text/resolve.ts +25 -2
- package/src/routes/worker.ts +17 -5
- package/src/runtime/access-request-helper.ts +9 -12
- package/src/runtime/agent-wake.ts +3 -3
- package/src/runtime/pre-first-message-gate.ts +4 -0
- package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +12 -4
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +219 -1
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +52 -0
- package/src/runtime/routes/__tests__/default-provider-routes.test.ts +61 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +44 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +102 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +44 -0
- package/src/runtime/routes/__tests__/stt-routes.test.ts +25 -0
- package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +62 -1
- package/src/runtime/routes/channel-availability-routes.ts +32 -14
- package/src/runtime/routes/channel-route-shared.ts +0 -6
- package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +6 -6
- package/src/runtime/routes/conversation-list-routes.ts +112 -1
- package/src/runtime/routes/conversation-query-routes.ts +40 -27
- package/src/runtime/routes/credential-prompt-routes.ts +4 -7
- package/src/runtime/routes/default-provider-routes.ts +15 -0
- package/src/runtime/routes/inbound-message-handler.ts +17 -41
- package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -9
- package/src/runtime/routes/inbound-stages/admission-policy.ts +1 -17
- package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +2 -3
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +1 -3
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +3 -4
- package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +11 -20
- package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +2 -3
- package/src/runtime/routes/inference-profiles-routes.ts +20 -11
- package/src/runtime/routes/inference-provider-connection-routes.ts +77 -15
- package/src/runtime/routes/log-export-routes.ts +3 -0
- package/src/runtime/routes/mcp-auth-routes.ts +148 -57
- package/src/runtime/routes/plugins-routes.ts +21 -3
- package/src/runtime/routes/stt-routes.ts +31 -25
- package/src/runtime/routes/surface-conversation-resolver.ts +3 -0
- package/src/runtime/routes/user-route-dispatcher.ts +39 -14
- package/src/runtime/routes/user-route-import.ts +108 -0
- package/src/schedule/worker.ts +6 -0
- package/src/security/oauth2.ts +6 -22
- package/src/stt/__tests__/daemon-batch-transcriber.test.ts +22 -0
- package/src/stt/__tests__/types.test.ts +94 -0
- package/src/stt/daemon-batch-transcriber.ts +10 -0
- package/src/stt/stt-stream-session.ts +8 -4
- package/src/stt/types.ts +103 -0
- package/src/subagent/manager.ts +1 -3
- package/src/subagent/types.ts +7 -6
- package/src/tools/acp/spawn.test.ts +97 -0
- package/src/tools/acp/spawn.ts +32 -0
- package/src/tools/document/document-tool.ts +12 -3
- package/src/tools/registry.ts +2 -1
- package/src/tools/workflows/run-workflow.ts +1 -2
- package/src/tts/__tests__/reasoning-tag-filter.test.ts +63 -0
- package/src/tts/reasoning-tag-filter.ts +110 -0
- package/src/workspace/byok-default-profile-ensure.ts +61 -18
- package/src/workspace/custom-profile-ensure.ts +4 -24
- package/src/workspace/migrations/142-consolidate-voice-front-door.ts +70 -0
- package/src/workspace/migrations/143-repair-deprecated-codex-model-id.ts +134 -0
- package/src/workspace/migrations/144-convert-stranded-subscription-openai-profiles.ts +265 -0
- package/src/workspace/migrations/145-collapse-profile-bindings-to-entries.ts +328 -0
- package/src/workspace/migrations/__tests__/141-stt-english-default-to-multilingual.test.ts +0 -10
- package/src/workspace/migrations/registry.ts +8 -0
- package/src/workspace/provider-commit-message-generator.ts +7 -5
- package/src/live-voice/__tests__/front-decision.test.ts +0 -645
- package/src/live-voice/front-decision.ts +0 -476
|
@@ -3,6 +3,7 @@ import { v4 as uuid } from "uuid";
|
|
|
3
3
|
|
|
4
4
|
import type { DrizzleDb } from "./db-connection.js";
|
|
5
5
|
import { stringifyMessageContent } from "./message-content.js";
|
|
6
|
+
import { messageConversationId } from "./message-reads.js";
|
|
6
7
|
import { conversations, messageBookmarks, messages } from "./schema.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -138,15 +139,10 @@ export function createBookmark(
|
|
|
138
139
|
params: { messageId: string },
|
|
139
140
|
): CreateBookmarkResult {
|
|
140
141
|
const { messageId } = params;
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
.from(messages)
|
|
144
|
-
.where(eq(messages.id, messageId))
|
|
145
|
-
.get();
|
|
146
|
-
if (!message) {
|
|
142
|
+
const conversationId = messageConversationId(messageId, { db });
|
|
143
|
+
if (conversationId === null) {
|
|
147
144
|
throw new Error(`Message ${messageId} not found`);
|
|
148
145
|
}
|
|
149
|
-
const conversationId = message.conversationId;
|
|
150
146
|
|
|
151
147
|
const existing = db
|
|
152
148
|
.select({ id: messageBookmarks.id })
|
|
@@ -21,11 +21,14 @@ import { v4 as uuid } from "uuid";
|
|
|
21
21
|
|
|
22
22
|
import { UserError } from "../util/errors.js";
|
|
23
23
|
import { getDb } from "./db-connection.js";
|
|
24
|
+
import {
|
|
25
|
+
latestAssistantMessage,
|
|
26
|
+
latestAssistantMessageBefore,
|
|
27
|
+
} from "./message-reads.js";
|
|
24
28
|
import {
|
|
25
29
|
conversationAssistantAttentionState,
|
|
26
30
|
conversationAttentionEvents,
|
|
27
31
|
conversations,
|
|
28
|
-
messages,
|
|
29
32
|
} from "./schema/index.js";
|
|
30
33
|
|
|
31
34
|
// ── Types ────────────────────────────────────────────────────────────
|
|
@@ -361,19 +364,11 @@ export function recordConversationSeenSignal(params: {
|
|
|
361
364
|
if (!state) {
|
|
362
365
|
// No state row yet — look up the conversation's latest assistant message so
|
|
363
366
|
// upgraded databases (with existing messages but no attention row) correctly
|
|
364
|
-
// initialize the full state on the first seen signal.
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
and(
|
|
370
|
-
eq(messages.conversationId, conversationId),
|
|
371
|
-
eq(messages.role, "assistant"),
|
|
372
|
-
),
|
|
373
|
-
)
|
|
374
|
-
.orderBy(desc(messages.createdAt))
|
|
375
|
-
.limit(1)
|
|
376
|
-
.get();
|
|
367
|
+
// initialize the full state on the first seen signal. Finalized-only via
|
|
368
|
+
// the accessor: a still-streaming reply is not a reply the user has seen,
|
|
369
|
+
// and anchoring the watermark on it would classify the finished reply as
|
|
370
|
+
// seen the moment it completes.
|
|
371
|
+
const latestMsg = latestAssistantMessage(conversationId, { db: tx });
|
|
377
372
|
|
|
378
373
|
const latestMsgId = latestMsg?.id ?? null;
|
|
379
374
|
const latestMsgAt = latestMsg?.createdAt ?? null;
|
|
@@ -461,26 +456,11 @@ function resolveAssistantCursor(params: {
|
|
|
461
456
|
latestAssistantMessageAt,
|
|
462
457
|
} = params;
|
|
463
458
|
|
|
464
|
-
// Unread classification compares timestamps strictly, so rewinding to a
|
|
465
|
-
// same-timestamp sibling would leave the latest reply classified as seen.
|
|
466
|
-
const previousAssistantMessageBefore = (before: number) =>
|
|
467
|
-
db
|
|
468
|
-
.select({ id: messages.id, createdAt: messages.createdAt })
|
|
469
|
-
.from(messages)
|
|
470
|
-
.where(
|
|
471
|
-
and(
|
|
472
|
-
eq(messages.conversationId, conversationId),
|
|
473
|
-
eq(messages.role, "assistant"),
|
|
474
|
-
lt(messages.createdAt, before),
|
|
475
|
-
),
|
|
476
|
-
)
|
|
477
|
-
.orderBy(desc(messages.createdAt), desc(messages.id))
|
|
478
|
-
.limit(1)
|
|
479
|
-
.get();
|
|
480
|
-
|
|
481
459
|
if (latestAssistantMessageId && latestAssistantMessageAt != null) {
|
|
482
|
-
const previousMessage =
|
|
460
|
+
const previousMessage = latestAssistantMessageBefore(
|
|
461
|
+
conversationId,
|
|
483
462
|
latestAssistantMessageAt,
|
|
463
|
+
{ db },
|
|
484
464
|
);
|
|
485
465
|
|
|
486
466
|
return {
|
|
@@ -491,25 +471,16 @@ function resolveAssistantCursor(params: {
|
|
|
491
471
|
};
|
|
492
472
|
}
|
|
493
473
|
|
|
494
|
-
const latestMessage = db
|
|
495
|
-
.select({ id: messages.id, createdAt: messages.createdAt })
|
|
496
|
-
.from(messages)
|
|
497
|
-
.where(
|
|
498
|
-
and(
|
|
499
|
-
eq(messages.conversationId, conversationId),
|
|
500
|
-
eq(messages.role, "assistant"),
|
|
501
|
-
),
|
|
502
|
-
)
|
|
503
|
-
.orderBy(desc(messages.createdAt), desc(messages.id))
|
|
504
|
-
.limit(1)
|
|
505
|
-
.get();
|
|
474
|
+
const latestMessage = latestAssistantMessage(conversationId, { db });
|
|
506
475
|
|
|
507
476
|
if (!latestMessage) {
|
|
508
477
|
return null;
|
|
509
478
|
}
|
|
510
479
|
|
|
511
|
-
const previousMessage =
|
|
480
|
+
const previousMessage = latestAssistantMessageBefore(
|
|
481
|
+
conversationId,
|
|
512
482
|
latestMessage.createdAt,
|
|
483
|
+
{ db },
|
|
513
484
|
);
|
|
514
485
|
return {
|
|
515
486
|
latestAssistantMessageId: latestMessage.id,
|
|
@@ -498,6 +498,25 @@ export function parseMessageMetadata(
|
|
|
498
498
|
}
|
|
499
499
|
}
|
|
500
500
|
|
|
501
|
+
/**
|
|
502
|
+
* Rows a fork may copy: finalized ones. A `finalized = 0` row is mid-write,
|
|
503
|
+
* its content a `{ ref }` into the source turn's in-flight delta file, which
|
|
504
|
+
* the source deletes when the turn completes. A copied row would point at a
|
|
505
|
+
* file another conversation is about to delete, leaving a permanently empty
|
|
506
|
+
* message that reads as final and is invisible to crash recovery (which scans
|
|
507
|
+
* `finalized = 0` only). Unfinalized is not "unsent": the message was sent
|
|
508
|
+
* and is still being written, so there is no completed content to copy. A
|
|
509
|
+
* fork reflects the conversation at a completed point.
|
|
510
|
+
*
|
|
511
|
+
* Callers apply this to `messagesToCopy` itself, upstream of the boundary
|
|
512
|
+
* timestamp, compaction inheritance, `forkParentMessageId`, and the id map,
|
|
513
|
+
* so every derivation describes the rows actually copied and the lineage
|
|
514
|
+
* anchor can never reference a row the fork does not hold.
|
|
515
|
+
*/
|
|
516
|
+
function dropUnfinalizedRows(rows: MessageRow[]): MessageRow[] {
|
|
517
|
+
return rows.filter((message) => message.finalized === 1);
|
|
518
|
+
}
|
|
519
|
+
|
|
501
520
|
function cloneForkMessageMetadata(
|
|
502
521
|
metadata: string | null,
|
|
503
522
|
sourceMessageId: string,
|
|
@@ -1336,7 +1355,7 @@ export function forkConversation(params: {
|
|
|
1336
1355
|
|
|
1337
1356
|
const messagesToCopy =
|
|
1338
1357
|
copyBoundaryIndex >= 0
|
|
1339
|
-
? sourceMessages.slice(0, copyBoundaryIndex + 1)
|
|
1358
|
+
? dropUnfinalizedRows(sourceMessages.slice(0, copyBoundaryIndex + 1))
|
|
1340
1359
|
: ([] as MessageRow[]);
|
|
1341
1360
|
|
|
1342
1361
|
// Inherit the history-strip marker only when the fork boundary is at-or-
|
|
@@ -1461,7 +1480,13 @@ export function forkConversation(params: {
|
|
|
1461
1480
|
messagesToCopy,
|
|
1462
1481
|
forkedMessageIds,
|
|
1463
1482
|
latestForkedAssistant,
|
|
1464
|
-
|
|
1483
|
+
// The wholesale memory-state carry requires the fork's window to equal
|
|
1484
|
+
// the source's rendered window; a boundary at the tip no longer
|
|
1485
|
+
// guarantees that when the unfinalized filter dropped rows from the
|
|
1486
|
+
// slice, so both conditions gate it.
|
|
1487
|
+
isFullHistoryFork:
|
|
1488
|
+
copyBoundaryIndex === sourceMessages.length - 1 &&
|
|
1489
|
+
messagesToCopy.length === copyBoundaryIndex + 1,
|
|
1465
1490
|
inheritedCompactedMessageCount:
|
|
1466
1491
|
inheritedCompaction?.compactedMessageCount ?? 0,
|
|
1467
1492
|
diskSyncQueue,
|
|
@@ -1772,7 +1797,7 @@ export async function forkConversationForRetrospective(params: {
|
|
|
1772
1797
|
);
|
|
1773
1798
|
const messagesToCopy =
|
|
1774
1799
|
copyBoundaryIndex >= 0
|
|
1775
|
-
? sourceMessages.slice(0, copyBoundaryIndex + 1)
|
|
1800
|
+
? dropUnfinalizedRows(sourceMessages.slice(0, copyBoundaryIndex + 1))
|
|
1776
1801
|
: ([] as MessageRow[]);
|
|
1777
1802
|
|
|
1778
1803
|
const sourceHistoryStrippedAt = sourceConversation.historyStrippedAt ?? null;
|
|
@@ -1936,8 +1961,12 @@ export async function forkConversationForRetrospective(params: {
|
|
|
1936
1961
|
messagesToCopy: rowsToCopy,
|
|
1937
1962
|
forkedMessageIds,
|
|
1938
1963
|
latestForkedAssistant,
|
|
1964
|
+
// Both conditions, for the same reason as `forkConversation`:
|
|
1965
|
+
// a tip boundary no longer implies an equal window once the
|
|
1966
|
+
// unfinalized filter dropped rows from the slice.
|
|
1939
1967
|
isFullHistoryFork:
|
|
1940
|
-
copyBoundaryIndex === sourceMessages.length - 1
|
|
1968
|
+
copyBoundaryIndex === sourceMessages.length - 1 &&
|
|
1969
|
+
messagesToCopy.length === copyBoundaryIndex + 1,
|
|
1941
1970
|
// The copied range already starts at the visible window.
|
|
1942
1971
|
inheritedCompactedMessageCount: forkCompactedMessageCount,
|
|
1943
1972
|
skipCompactionLedgerCopy: inheritedCompaction != null,
|
|
@@ -184,6 +184,14 @@ export function isSingleSegmentLineage(segments: LineageSegment[]): boolean {
|
|
|
184
184
|
* strictly-earlier `createdAt`, or an equal `createdAt` with an id that does
|
|
185
185
|
* not sort after the bound. The fork message itself is included, because a
|
|
186
186
|
* fork is taken THROUGH that message.
|
|
187
|
+
*
|
|
188
|
+
* Ancestor segments additionally exclude unfinalized rows. An ancestor's
|
|
189
|
+
* `finalized = 0` row is a message its own live turn is still writing; a
|
|
190
|
+
* descendant reading it would see partial content that the ancestor rewrites
|
|
191
|
+
* inline when the turn completes. The row enters the window on its own once
|
|
192
|
+
* it finalizes. A segment with no bound is the conversation's own tail and
|
|
193
|
+
* keeps its unfinalized rows: a conversation always sees its own in-flight
|
|
194
|
+
* message.
|
|
187
195
|
*/
|
|
188
196
|
export function lineageMessageFilter(segments: LineageSegment[]): SQL {
|
|
189
197
|
const clauses = segments.map((segment) => {
|
|
@@ -194,6 +202,7 @@ export function lineageMessageFilter(segments: LineageSegment[]): SQL {
|
|
|
194
202
|
const { createdAt, id } = segment.through;
|
|
195
203
|
return and(
|
|
196
204
|
owner,
|
|
205
|
+
eq(messages.finalized, 1),
|
|
197
206
|
or(
|
|
198
207
|
lt(messages.createdAt, createdAt),
|
|
199
208
|
and(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { and, count, desc, eq,
|
|
1
|
+
import { and, count, desc, eq, isNull, lt, sql } from "drizzle-orm";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
parseExternalContentEnvelope,
|
|
@@ -26,11 +26,14 @@ import {
|
|
|
26
26
|
parseContentRef,
|
|
27
27
|
resolveMessageContentBlocks,
|
|
28
28
|
} from "./message-content-file.js";
|
|
29
|
+
import {
|
|
30
|
+
countMessagesByRoleForConversations,
|
|
31
|
+
latestUserMessageRawContent,
|
|
32
|
+
} from "./message-reads.js";
|
|
29
33
|
import { rawAll } from "./raw-query.js";
|
|
30
34
|
import {
|
|
31
35
|
conversationAssistantAttentionState,
|
|
32
36
|
conversations,
|
|
33
|
-
messages,
|
|
34
37
|
} from "./schema/index.js";
|
|
35
38
|
|
|
36
39
|
const log = getLogger("conversation-store");
|
|
@@ -438,31 +441,7 @@ export function getMessageRoleStatsByConversation(
|
|
|
438
441
|
conversationIds: string[],
|
|
439
442
|
role: string = "assistant",
|
|
440
443
|
): Map<string, { count: number; lastAt: number }> {
|
|
441
|
-
|
|
442
|
-
return new Map();
|
|
443
|
-
}
|
|
444
|
-
const db = getDb();
|
|
445
|
-
const rows = db
|
|
446
|
-
.select({
|
|
447
|
-
conversationId: messages.conversationId,
|
|
448
|
-
count: sql<number>`COUNT(*)`.as("count"),
|
|
449
|
-
lastAt: sql<number>`MAX(${messages.createdAt})`.as("last_at"),
|
|
450
|
-
})
|
|
451
|
-
.from(messages)
|
|
452
|
-
.where(
|
|
453
|
-
and(
|
|
454
|
-
inArray(messages.conversationId, conversationIds),
|
|
455
|
-
eq(messages.role, role),
|
|
456
|
-
),
|
|
457
|
-
)
|
|
458
|
-
.groupBy(messages.conversationId)
|
|
459
|
-
.all();
|
|
460
|
-
return new Map(
|
|
461
|
-
rows.map((r) => [
|
|
462
|
-
r.conversationId,
|
|
463
|
-
{ count: Number(r.count), lastAt: Number(r.lastAt) },
|
|
464
|
-
]),
|
|
465
|
-
);
|
|
444
|
+
return countMessagesByRoleForConversations(conversationIds, role);
|
|
466
445
|
}
|
|
467
446
|
|
|
468
447
|
export function listPinnedConversations(
|
|
@@ -535,6 +514,8 @@ export function listConversationsByTitlePrefix(
|
|
|
535
514
|
const rows = rawAll<Row>(
|
|
536
515
|
"conversation:listByTitlePrefix",
|
|
537
516
|
`SELECT c.id, c.title,
|
|
517
|
+
-- Any-state count (streaming rows included): a list-surface size
|
|
518
|
+
-- hint where a transient +1 during a live turn is immaterial.
|
|
538
519
|
(SELECT COUNT(*) FROM messages WHERE conversation_id = c.id) AS message_count,
|
|
539
520
|
c.created_at
|
|
540
521
|
FROM conversations c
|
|
@@ -617,32 +598,114 @@ export function countUnreadConversations(): number {
|
|
|
617
598
|
return total;
|
|
618
599
|
}
|
|
619
600
|
|
|
601
|
+
/** Totals for one sidebar section: every member, and the unread among them. */
|
|
602
|
+
export interface ConversationSectionCount {
|
|
603
|
+
total: number;
|
|
604
|
+
unread: number;
|
|
605
|
+
}
|
|
606
|
+
|
|
620
607
|
/**
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
608
|
+
* Row counts for every non-empty sidebar section, with no rows fetched.
|
|
609
|
+
*
|
|
610
|
+
* Two axes, disjoint by construction because `group_id` is single-valued:
|
|
611
|
+
*
|
|
612
|
+
* - `groups`: rows filed somewhere, bucketed by `group_id`. Contains
|
|
613
|
+
* `'system:pinned'` and custom-group ids; the background/scheduled system
|
|
614
|
+
* buckets are excluded because no section renders them.
|
|
615
|
+
* - `channels`: rows filed nowhere, bucketed by effective origin channel,
|
|
616
|
+
* where NULL reads as {@link NATIVE_ORIGIN_CHANNEL} exactly as
|
|
617
|
+
* {@link originChannelClause} reads it. The native bucket is the Chats
|
|
618
|
+
* section; each other bucket is that channel's section.
|
|
619
|
+
*
|
|
620
|
+
* Only buckets with at least one row appear: GROUP BY cannot emit an empty
|
|
621
|
+
* bucket, and an empty section renders no card.
|
|
622
|
+
*
|
|
623
|
+
* `total` follows the standard-listing visibility
|
|
624
|
+
* ({@link conversationTypeClause}), active rows only, so it counts exactly
|
|
625
|
+
* the rows `GET /v1/conversations` would return for that section's filter.
|
|
626
|
+
* `unread` applies the same rules as {@link countUnreadConversations} on top
|
|
627
|
+
* (not an unsurfaced background/scheduled row, unseen per the attention
|
|
628
|
+
* projection), composed from the same predicates rather than restated, so
|
|
629
|
+
* the per-section numbers always sum against the global count.
|
|
624
630
|
*/
|
|
625
|
-
export
|
|
631
|
+
export interface ConversationSectionCounts {
|
|
632
|
+
groups: Array<{ groupId: string } & ConversationSectionCount>;
|
|
633
|
+
channels: Array<{ channel: string } & ConversationSectionCount>;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export function countConversationSections(): ConversationSectionCounts {
|
|
637
|
+
ensureGroupMigration();
|
|
626
638
|
const db = getDb();
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
639
|
+
|
|
640
|
+
/* `countUnreadConversations` gets the same effect with an INNER JOIN and
|
|
641
|
+
the unseen conditions in its WHERE; here every row must be counted and
|
|
642
|
+
only unread rows summed, so the join is LEFT and the conditions move
|
|
643
|
+
into the CASE. A row with no attention projection has NULL columns,
|
|
644
|
+
fails the conditions, and sums 0: identical membership. */
|
|
645
|
+
const unread = sql<number>`SUM(CASE WHEN ${and(
|
|
646
|
+
sql.raw(notBackgroundVisibilitySql()),
|
|
647
|
+
...unseenAttentionStateConditions(),
|
|
648
|
+
)} THEN 1 ELSE 0 END)`;
|
|
649
|
+
|
|
650
|
+
const attentionJoin = eq(
|
|
651
|
+
conversationAssistantAttentionState.conversationId,
|
|
652
|
+
conversations.id,
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
const groups = db
|
|
656
|
+
.select({
|
|
657
|
+
groupId: sql<string>`group_id`,
|
|
658
|
+
total: count(),
|
|
659
|
+
unread,
|
|
660
|
+
})
|
|
661
|
+
.from(conversations)
|
|
662
|
+
.leftJoin(conversationAssistantAttentionState, attentionJoin)
|
|
630
663
|
.where(
|
|
631
664
|
and(
|
|
632
|
-
|
|
633
|
-
|
|
665
|
+
conversationTypeClause("standard"),
|
|
666
|
+
isNull(conversations.archivedAt),
|
|
667
|
+
sql`(group_id = ${PINNED_GROUP_ID} OR (group_id IS NOT NULL AND group_id NOT LIKE 'system:%'))`,
|
|
634
668
|
),
|
|
635
669
|
)
|
|
636
|
-
.
|
|
637
|
-
.
|
|
638
|
-
|
|
670
|
+
.groupBy(sql`group_id`)
|
|
671
|
+
.all();
|
|
672
|
+
|
|
673
|
+
const effectiveChannel = sql<string>`COALESCE(${conversations.originChannel}, ${NATIVE_ORIGIN_CHANNEL})`;
|
|
674
|
+
const channels = db
|
|
675
|
+
.select({
|
|
676
|
+
channel: effectiveChannel,
|
|
677
|
+
total: count(),
|
|
678
|
+
unread,
|
|
679
|
+
})
|
|
680
|
+
.from(conversations)
|
|
681
|
+
.leftJoin(conversationAssistantAttentionState, attentionJoin)
|
|
682
|
+
.where(
|
|
683
|
+
and(
|
|
684
|
+
conversationTypeClause("standard"),
|
|
685
|
+
isNull(conversations.archivedAt),
|
|
686
|
+
groupIdClause(UNGROUPED_GROUP_ID),
|
|
687
|
+
),
|
|
688
|
+
)
|
|
689
|
+
.groupBy(effectiveChannel)
|
|
690
|
+
.all();
|
|
691
|
+
|
|
692
|
+
return { groups, channels };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Check whether the last user message in a conversation is a tool_result-only
|
|
697
|
+
* message (i.e., not a real user-typed message). This is used by undo() to
|
|
698
|
+
* determine if additional exchanges need to be deleted from the DB.
|
|
699
|
+
*/
|
|
700
|
+
export function isLastUserMessageToolResult(conversationId: string): boolean {
|
|
701
|
+
const lastUserContent = latestUserMessageRawContent(conversationId);
|
|
639
702
|
|
|
640
|
-
if (
|
|
703
|
+
if (lastUserContent === null) {
|
|
641
704
|
return false;
|
|
642
705
|
}
|
|
643
706
|
|
|
644
707
|
try {
|
|
645
|
-
const parsed = JSON.parse(
|
|
708
|
+
const parsed = JSON.parse(lastUserContent);
|
|
646
709
|
if (
|
|
647
710
|
Array.isArray(parsed) &&
|
|
648
711
|
parsed.length > 0 &&
|
|
@@ -816,6 +879,10 @@ export async function searchConversations(
|
|
|
816
879
|
if (!opts?.includeArchived) {
|
|
817
880
|
whereClauses.push(`c.archived_at IS NULL`);
|
|
818
881
|
}
|
|
882
|
+
// No completeness predicate: candidate ids come from the lexical
|
|
883
|
+
// index, which filters finalized = 1 at index time, so an unfinalized
|
|
884
|
+
// row cannot be a candidate. Do not copy this shape for id sets with
|
|
885
|
+
// different provenance.
|
|
819
886
|
const visibleRows = rawAll<CandidateRow>(
|
|
820
887
|
"conversation:searchConversations:visibleCandidates",
|
|
821
888
|
`
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
import { and, desc, eq, isNotNull, ne, or, sql } from "drizzle-orm";
|
|
9
9
|
import { v4 as uuid } from "uuid";
|
|
10
10
|
|
|
11
|
+
import type { ChannelId } from "../channels/types.js";
|
|
11
12
|
import { readSlackMetadataFromMessageMetadata } from "../messaging/providers/slack/message-metadata.js";
|
|
12
|
-
import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
|
|
13
13
|
import type { SlackInboundMessageMetadata } from "../runtime/http-types.js";
|
|
14
14
|
import { parseJsonSafe } from "../util/json.js";
|
|
15
15
|
import { isPlainObject } from "../util/object.js";
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
getOrCreateConversation,
|
|
20
20
|
setConversationKeyIfAbsent,
|
|
21
21
|
} from "./conversation-key-store.js";
|
|
22
|
+
import type { NonScheduledConversationType } from "./conversation-types.js";
|
|
22
23
|
import { getDb } from "./db-connection.js";
|
|
23
24
|
import { channelInboundEvents, conversations } from "./schema.js";
|
|
24
25
|
|
|
@@ -31,7 +32,6 @@ export interface InboundResult {
|
|
|
31
32
|
|
|
32
33
|
export interface RecordInboundOptions {
|
|
33
34
|
sourceMessageId?: string;
|
|
34
|
-
assistantId?: string;
|
|
35
35
|
sourceThreadId?: string;
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -45,30 +45,25 @@ const SLACK_LEGACY_THREAD_EVIDENCE_MAX_SCAN = 500;
|
|
|
45
45
|
*/
|
|
46
46
|
const THREAD_SCOPED_CHANNELS = new Set(["slack", "telegram"]);
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Scope prefix on every conversation key.
|
|
50
|
+
*
|
|
51
|
+
* Part of the stored key format, so it is fixed: rows are written under
|
|
52
|
+
* `asst:self:` and stop resolving if it changes. The daemon is single-tenant
|
|
53
|
+
* and scopes all its storage to `self` (`DAEMON_INTERNAL_ASSISTANT_ID`).
|
|
54
|
+
*/
|
|
55
|
+
const CONVERSATION_KEY_SCOPE = "asst:self";
|
|
56
|
+
|
|
57
|
+
export function buildScopedConversationKey(
|
|
50
58
|
sourceChannel: string,
|
|
51
59
|
externalChatId: string,
|
|
52
60
|
sourceThreadId?: string | null,
|
|
53
61
|
): string {
|
|
54
62
|
const threadId = sourceThreadId?.trim();
|
|
55
63
|
if (THREAD_SCOPED_CHANNELS.has(sourceChannel) && threadId) {
|
|
56
|
-
return
|
|
64
|
+
return `${CONVERSATION_KEY_SCOPE}:${sourceChannel}:${externalChatId}:thread:${threadId}`;
|
|
57
65
|
}
|
|
58
|
-
return
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function buildScopedConversationKey(
|
|
62
|
-
sourceChannel: string,
|
|
63
|
-
externalChatId: string,
|
|
64
|
-
sourceThreadId?: string | null,
|
|
65
|
-
): string {
|
|
66
|
-
return buildScopedConversationKeyForAssistant(
|
|
67
|
-
DAEMON_INTERNAL_ASSISTANT_ID,
|
|
68
|
-
sourceChannel,
|
|
69
|
-
externalChatId,
|
|
70
|
-
sourceThreadId,
|
|
71
|
-
);
|
|
66
|
+
return `${CONVERSATION_KEY_SCOPE}:${sourceChannel}:${externalChatId}`;
|
|
72
67
|
}
|
|
73
68
|
|
|
74
69
|
function legacySlackConversationHasThreadEvidence(
|
|
@@ -135,14 +130,24 @@ function legacySlackConversationHasThreadEvidence(
|
|
|
135
130
|
return false;
|
|
136
131
|
}
|
|
137
132
|
|
|
138
|
-
|
|
139
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Applied only where this call is what creates the conversation. An address
|
|
135
|
+
* that already resolves is an existing conversation, and neither its type nor
|
|
136
|
+
* where it came from is the current message's to restate.
|
|
137
|
+
*/
|
|
138
|
+
export interface ResolveInboundConversationOptions {
|
|
139
|
+
conversationType?: NonScheduledConversationType;
|
|
140
|
+
/** Channel this conversation originates on, for first-message attribution. */
|
|
141
|
+
origin?: ChannelId;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function resolveInboundConversation(
|
|
140
145
|
sourceChannel: string,
|
|
141
146
|
externalChatId: string,
|
|
142
147
|
sourceThreadId?: string | null,
|
|
148
|
+
opts?: ResolveInboundConversationOptions,
|
|
143
149
|
): { conversationId: string } {
|
|
144
|
-
const threadedKey =
|
|
145
|
-
assistantId,
|
|
150
|
+
const threadedKey = buildScopedConversationKey(
|
|
146
151
|
sourceChannel,
|
|
147
152
|
externalChatId,
|
|
148
153
|
sourceThreadId,
|
|
@@ -155,7 +160,7 @@ function resolveInboundConversation(
|
|
|
155
160
|
// other thread-scoped channel (Telegram topics) has no flat-key aliasing —
|
|
156
161
|
// a thread id always resolves the threaded key directly.
|
|
157
162
|
if (sourceChannel !== "slack" || !threadId) {
|
|
158
|
-
return getOrCreateConversation(threadedKey);
|
|
163
|
+
return getOrCreateConversation(threadedKey, opts);
|
|
159
164
|
}
|
|
160
165
|
|
|
161
166
|
const threadedMapping = getConversationByKey(threadedKey);
|
|
@@ -163,8 +168,7 @@ function resolveInboundConversation(
|
|
|
163
168
|
return { conversationId: threadedMapping.conversationId };
|
|
164
169
|
}
|
|
165
170
|
|
|
166
|
-
const legacyKey =
|
|
167
|
-
assistantId,
|
|
171
|
+
const legacyKey = buildScopedConversationKey(
|
|
168
172
|
sourceChannel,
|
|
169
173
|
externalChatId,
|
|
170
174
|
null,
|
|
@@ -185,7 +189,7 @@ function resolveInboundConversation(
|
|
|
185
189
|
}
|
|
186
190
|
}
|
|
187
191
|
|
|
188
|
-
return getOrCreateConversation(threadedKey);
|
|
192
|
+
return getOrCreateConversation(threadedKey, opts);
|
|
189
193
|
}
|
|
190
194
|
|
|
191
195
|
/**
|
|
@@ -236,6 +240,13 @@ export function findInboundConversationId(
|
|
|
236
240
|
/**
|
|
237
241
|
* Record an inbound channel event. Returns `duplicate: true` if this
|
|
238
242
|
* exact (channel, chat, message) combination was already seen.
|
|
243
|
+
*
|
|
244
|
+
* The dedup half of this is also implemented gateway-side, on the same
|
|
245
|
+
* triple, in `gateway/src/db/inbound-dedup-store.ts`. Deliberately, not by
|
|
246
|
+
* accident: the gateway claims a delivery before handing it over, so a
|
|
247
|
+
* vendor's retry costs no crossing, and this stays as the record that binds
|
|
248
|
+
* the conversation and tracks the reply. Both must key on the same three
|
|
249
|
+
* fields for either to mean anything.
|
|
239
250
|
*/
|
|
240
251
|
export function recordInbound(
|
|
241
252
|
sourceChannel: string,
|
|
@@ -269,9 +280,7 @@ export function recordInbound(
|
|
|
269
280
|
};
|
|
270
281
|
}
|
|
271
282
|
|
|
272
|
-
const assistantId = options?.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
|
|
273
283
|
const mapping = resolveInboundConversation(
|
|
274
|
-
assistantId,
|
|
275
284
|
sourceChannel,
|
|
276
285
|
externalChatId,
|
|
277
286
|
options?.sourceThreadId,
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* list APIs.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
|
10
|
+
import { and, eq, inArray, isNull, type SQL, sql } from "drizzle-orm";
|
|
11
|
+
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
|
11
12
|
|
|
12
13
|
import { getDb } from "./db-connection.js";
|
|
13
14
|
import { externalConversationBindings } from "./schema/index.js";
|
|
@@ -45,6 +46,19 @@ function normalizeExternalThreadId(
|
|
|
45
46
|
return trimmed ? trimmed : null;
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* An upsert value that leaves the stored column alone when the caller said
|
|
51
|
+
* nothing about it, distinguishing "I do not know" from "there is none".
|
|
52
|
+
*/
|
|
53
|
+
function keepStoredIfAbsent<T extends string | null | undefined>(
|
|
54
|
+
value: T,
|
|
55
|
+
column: AnySQLiteColumn,
|
|
56
|
+
): Exclude<T, undefined> | SQL {
|
|
57
|
+
return value === undefined
|
|
58
|
+
? sql`${column}`
|
|
59
|
+
: (value as Exclude<T, undefined>);
|
|
60
|
+
}
|
|
61
|
+
|
|
48
62
|
function normalizeExternalChatName(
|
|
49
63
|
externalChatName?: string | null,
|
|
50
64
|
): string | null {
|
|
@@ -103,9 +117,23 @@ export function upsertBinding(input: UpsertBindingInput): void {
|
|
|
103
117
|
externalChatName ??
|
|
104
118
|
sql`${externalConversationBindings.externalChatName}`,
|
|
105
119
|
externalThreadId,
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
120
|
+
// An omitted sender field keeps what is stored; an explicit value,
|
|
121
|
+
// including null, replaces it. A caller that knows only the chat
|
|
122
|
+
// coordinates on a message is silent about the sender rather than
|
|
123
|
+
// asserting there is none, and erasing a known name on that silence
|
|
124
|
+
// drops it from the conversation and session APIs.
|
|
125
|
+
externalUserId: keepStoredIfAbsent(
|
|
126
|
+
input.externalUserId,
|
|
127
|
+
externalConversationBindings.externalUserId,
|
|
128
|
+
),
|
|
129
|
+
displayName: keepStoredIfAbsent(
|
|
130
|
+
input.displayName,
|
|
131
|
+
externalConversationBindings.displayName,
|
|
132
|
+
),
|
|
133
|
+
username: keepStoredIfAbsent(
|
|
134
|
+
input.username,
|
|
135
|
+
externalConversationBindings.username,
|
|
136
|
+
),
|
|
109
137
|
updatedAt: now,
|
|
110
138
|
lastInboundAt: now,
|
|
111
139
|
},
|
|
@@ -28,13 +28,14 @@ import {
|
|
|
28
28
|
getTurnTimeBounds,
|
|
29
29
|
messageMetadataSchema,
|
|
30
30
|
} from "./conversation-crud.js";
|
|
31
|
-
import { type DrizzleDb,
|
|
31
|
+
import { type DrizzleDb, getLogsDb } from "./db-connection.js";
|
|
32
32
|
import { getClickHouseLlmRequestLogSink } from "./llm-request-log-sink-clickhouse.js";
|
|
33
33
|
import type {
|
|
34
34
|
LlmRequestLogWriter,
|
|
35
35
|
LlmRequestLogWriteRow,
|
|
36
36
|
} from "./llm-request-log-writer-types.js";
|
|
37
|
-
import {
|
|
37
|
+
import { existingMessageIds } from "./message-reads.js";
|
|
38
|
+
import { llmRequestLogs } from "./schema/index.js";
|
|
38
39
|
import { timeSyncSection } from "./slow-sync-log.js";
|
|
39
40
|
|
|
40
41
|
/**
|
|
@@ -593,14 +594,7 @@ function selectOrphanedLogsInRange(
|
|
|
593
594
|
const candidateMessageIds = [
|
|
594
595
|
...new Set(candidates.map((c) => c.messageId as string)),
|
|
595
596
|
];
|
|
596
|
-
const existing =
|
|
597
|
-
getDb()
|
|
598
|
-
.select({ id: messages.id })
|
|
599
|
-
.from(messages)
|
|
600
|
-
.where(inArray(messages.id, candidateMessageIds))
|
|
601
|
-
.all()
|
|
602
|
-
.map((r) => r.id),
|
|
603
|
-
);
|
|
597
|
+
const existing = existingMessageIds(candidateMessageIds);
|
|
604
598
|
|
|
605
599
|
// Orphaned = the referenced message no longer exists.
|
|
606
600
|
return candidates.filter((c) => !existing.has(c.messageId as string));
|