@vellumai/assistant 0.11.1 → 0.11.2-staging.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +1 -3
- package/README.md +1 -1
- package/eslint.config.mjs +28 -8
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/slack-text/src/index.test.ts +48 -0
- package/node_modules/@vellumai/slack-text/src/index.ts +25 -6
- package/openapi.yaml +292 -5
- package/package.json +1 -1
- package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
- package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
- package/src/__tests__/cli-logger-boundary-guard.test.ts +87 -0
- package/src/__tests__/compaction-events.test.ts +139 -2
- package/src/__tests__/config-loader-backfill.test.ts +35 -3
- package/src/__tests__/config-schema.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
- package/src/__tests__/conversation-agent-loop.test.ts +180 -7
- package/src/__tests__/conversation-error.test.ts +211 -4
- package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
- package/src/__tests__/conversation-queue.test.ts +126 -0
- package/src/__tests__/conversation-retry-route.test.ts +46 -7
- package/src/__tests__/conversation-slash.test.ts +4 -2
- package/src/__tests__/conversation-summarize-route.test.ts +7 -1
- package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
- package/src/__tests__/document-create-dedupe.test.ts +2 -1
- package/src/__tests__/document-find-replace.test.ts +2 -1
- package/src/__tests__/document-tool-security.test.ts +2 -1
- package/src/__tests__/document-update-default-surface.test.ts +2 -1
- package/src/__tests__/document-workspace-file.test.ts +467 -0
- package/src/__tests__/emit-signal-routing-intent.test.ts +290 -49
- package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
- package/src/__tests__/http-user-message-parity.test.ts +66 -0
- package/src/__tests__/list-messages-attachments.test.ts +87 -0
- package/src/__tests__/list-messages-provider-error.test.ts +143 -0
- package/src/__tests__/list-messages-system-card.test.ts +100 -0
- package/src/__tests__/llm-resolver.test.ts +19 -9
- package/src/__tests__/managed-profile-guard.test.ts +23 -0
- package/src/__tests__/notification-platform-adapter.test.ts +130 -2
- package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
- package/src/__tests__/notification-vellum-adapter.test.ts +45 -0
- package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
- package/src/__tests__/provider-error-scenarios.test.ts +140 -0
- package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
- package/src/__tests__/run-conversation-turn-persistence.test.ts +66 -3
- package/src/__tests__/scripted-turn-metadata-persistence.test.ts +209 -0
- package/src/__tests__/skills.test.ts +27 -0
- package/src/__tests__/slack-channels-routes.test.ts +0 -2
- package/src/__tests__/slack-share-routes.test.ts +0 -3
- package/src/__tests__/slack-users-routes.test.ts +0 -2
- package/src/__tests__/subagent-tools.test.ts +11 -0
- package/src/__tests__/tool-preview-lifecycle.test.ts +58 -0
- package/src/__tests__/tool-result-spool.test.ts +5 -4
- package/src/__tests__/turn-boundary-resolution.test.ts +53 -0
- package/src/__tests__/turn-events-store.test.ts +26 -0
- package/src/__tests__/ui-visual-surface.test.ts +695 -0
- package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
- package/src/__tests__/visible-app-context.test.ts +189 -0
- package/src/__tests__/workspace-git-service.test.ts +173 -33
- package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +157 -0
- package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
- package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
- package/src/agent/loop.ts +36 -7
- package/src/api/events/context-window-usage.ts +31 -0
- package/src/api/events/notification-intent.ts +8 -0
- package/src/api/events/ui-surface-pending.ts +35 -0
- package/src/api/index.ts +14 -0
- package/src/api/responses/conversation-message.ts +25 -4
- package/src/api/surfaces.ts +90 -1
- package/src/approvals/guardian-card-withdrawal.ts +66 -31
- package/src/approvals/guardian-decision-primitive.ts +4 -0
- package/src/calls/__tests__/call-setup-router.test.ts +156 -26
- package/src/calls/__tests__/voice-session-bridge.test.ts +201 -12
- package/src/calls/call-setup-router.ts +80 -36
- package/src/calls/voice-session-bridge.ts +173 -26
- package/src/cli/commands/inference.help.ts +3 -3
- package/src/cli/commands/notifications.help.ts +3 -2
- package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +42 -128
- package/src/cli/commands/platform/__tests__/credits.test.ts +9 -78
- package/src/cli/commands/platform/__tests__/helpers.ts +90 -0
- package/src/cli/commands/platform/__tests__/invoices.test.ts +238 -0
- package/src/cli/commands/platform/__tests__/plans.test.ts +9 -88
- package/src/cli/commands/platform/__tests__/status.test.ts +12 -87
- package/src/cli/commands/platform/__tests__/subscription.test.ts +9 -86
- package/src/cli/commands/platform/index.help.ts +92 -0
- package/src/cli/commands/platform/index.ts +7 -0
- package/src/cli/commands/platform/invoices.ts +132 -0
- package/src/cli/commands/usage.help.ts +1 -1
- package/src/cli/lib/list-installed-plugins.ts +2 -1
- package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
- package/src/config/__tests__/deployment-context-defaults.test.ts +27 -5
- package/src/config/assistant-feature-flags.ts +7 -2
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -2
- package/src/config/bundled-skills/subagent/SKILL.md +3 -1
- package/src/config/bundled-skills/subagent/TOOLS.json +3 -3
- package/src/config/bundled-skills/visualize/SKILL.md +163 -0
- package/src/config/call-site-defaults.ts +3 -2
- package/src/config/default-profile-catalog.ts +75 -70
- package/src/config/default-profile-names.ts +13 -28
- package/src/config/env-registry.ts +1 -0
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/llm-resolver.ts +21 -5
- package/src/config/loader.ts +18 -11
- package/src/config/schemas/llm.ts +1 -9
- package/src/config/schemas/memory-retrospective.ts +9 -0
- package/src/config/schemas/monitoring.ts +28 -2
- package/src/config/schemas/workspace-git.ts +15 -0
- package/src/config/seed-inference-profiles.ts +19 -5
- package/src/context/post-turn-tool-result-truncation.ts +2 -2
- package/src/conversations/__tests__/message-consolidation.test.ts +54 -0
- package/src/conversations/message-consolidation.ts +17 -15
- package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +182 -0
- package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +181 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +90 -7
- package/src/daemon/conversation-agent-loop.ts +107 -22
- package/src/daemon/conversation-error.ts +169 -48
- package/src/daemon/conversation-messaging.ts +87 -4
- package/src/daemon/conversation-process.ts +57 -45
- package/src/daemon/conversation-runtime-assembly.ts +57 -0
- package/src/daemon/conversation-store.ts +36 -1
- package/src/daemon/conversation-surfaces.ts +68 -6
- package/src/daemon/conversation-turn-finalize.ts +76 -18
- package/src/daemon/conversation.ts +122 -27
- package/src/daemon/lifecycle.ts +9 -0
- package/src/daemon/message-types/conversations.ts +8 -0
- package/src/daemon/message-types/surfaces.ts +3 -0
- package/src/documents/document-store.ts +247 -10
- package/src/home/__tests__/feed-types.test.ts +43 -8
- package/src/home/__tests__/feed-writer.test.ts +59 -0
- package/src/home/feed-types.ts +34 -7
- package/src/home/feed-writer.ts +15 -6
- package/src/live-voice/__tests__/activity-label.test.ts +95 -0
- package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +310 -25
- package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
- package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
- package/src/live-voice/activity-label.ts +169 -0
- package/src/live-voice/live-activity-reporter.ts +35 -6
- package/src/live-voice/live-voice-session.ts +282 -41
- package/src/live-voice/protocol.ts +35 -0
- package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +84 -2
- package/src/messaging/providers/slack/adapter.ts +86 -19
- package/src/messaging/providers/slack/api.test.ts +85 -1
- package/src/messaging/providers/slack/api.ts +121 -288
- package/src/messaging/providers/slack/client.ts +20 -251
- package/src/messaging/providers/slack/send.test.ts +4 -9
- package/src/messaging/providers/slack/send.ts +1 -1
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/messaging/providers/slack/web-api-transport.test.ts +181 -0
- package/src/messaging/providers/slack/web-api-transport.ts +367 -0
- package/src/messaging/providers/slack/withdraw.ts +6 -14
- package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
- package/src/messaging/providers/telegram-bot/send.ts +23 -2
- package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
- package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
- package/src/monitoring/__tests__/db-integrity-sample.test.ts +18 -4
- package/src/monitoring/__tests__/file-descriptors.test.ts +144 -0
- package/src/monitoring/file-descriptors.ts +262 -0
- package/src/monitoring/process-memory.ts +4 -11
- package/src/monitoring/resource-sampler.ts +5 -0
- package/src/monitoring/worker.ts +12 -0
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +740 -0
- package/src/notifications/__tests__/broadcaster.test.ts +347 -7
- package/src/notifications/__tests__/copy-composer.test.ts +53 -3
- package/src/notifications/__tests__/decision-engine.test.ts +349 -29
- package/src/notifications/__tests__/deterministic-checks.test.ts +21 -0
- package/src/notifications/__tests__/edit-notification.test.ts +330 -0
- package/src/notifications/__tests__/guardian-delivery-recorder.test.ts +71 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
- package/src/notifications/adapters/macos.ts +3 -0
- package/src/notifications/adapters/platform.ts +90 -14
- package/src/notifications/adapters/telegram.ts +6 -4
- package/src/notifications/assistant-reply-producer.ts +219 -0
- package/src/notifications/broadcaster.ts +523 -317
- package/src/notifications/copy-composer.ts +27 -6
- package/src/notifications/decision-engine.ts +154 -77
- package/src/notifications/deterministic-checks.ts +6 -7
- package/src/notifications/edit-notification.ts +8 -4
- package/src/notifications/emit-signal.ts +49 -16
- package/src/notifications/guardian-delivery-recorder.ts +8 -2
- package/src/notifications/home-feed-side-effect.ts +43 -6
- package/src/notifications/notification-utils.ts +39 -4
- package/src/notifications/signal.ts +10 -0
- package/src/notifications/types.ts +17 -0
- package/src/persistence/bookmark-crud.ts +18 -9
- package/src/persistence/conversation-attention-store.ts +26 -0
- package/src/persistence/conversation-crud.ts +79 -32
- package/src/persistence/conversation-queries.ts +30 -15
- package/src/persistence/conversation-title-service.ts +5 -170
- package/src/persistence/conversation-types.ts +192 -1
- package/src/persistence/db-init.ts +14 -1
- package/src/persistence/db-maintenance.ts +5 -4
- package/src/persistence/embeddings/__tests__/plugin-index-qdrant-init.test.ts +219 -0
- package/src/persistence/embeddings/__tests__/plugin-index.test.ts +22 -0
- package/src/persistence/embeddings/__tests__/worker-script-version.test.ts +76 -0
- package/src/persistence/embeddings/embedding-local.ts +2 -1
- package/src/persistence/embeddings/embedding-runtime-manager.ts +29 -5
- package/src/persistence/embeddings/plugin-index.ts +84 -12
- package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
- package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
- package/src/persistence/planner-statistics.ts +14 -0
- package/src/persistence/schema/documents.ts +26 -11
- package/src/persistence/steps.ts +2 -0
- package/src/platform/client.test.ts +172 -2
- package/src/platform/client.ts +178 -55
- package/src/plugin-api/conversation-turn.ts +13 -0
- package/src/plugin-api/model-profiles.test.ts +6 -2
- package/src/plugins/defaults/memory/__tests__/bookmark-crud.test.ts +34 -0
- package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +22 -0
- package/src/plugins/defaults/memory/__tests__/jobs-store-enqueue-gate.test.ts +11 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +199 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +98 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +105 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +24 -2
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +524 -0
- package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +1 -1
- package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +9 -4
- package/src/plugins/defaults/memory/host-utils.ts +5 -0
- package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +105 -1
- package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +64 -5
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +36 -1
- package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +12 -3
- package/src/plugins/defaults/memory/src/__tests__/memory-v2-simulate-route.test.ts +1 -0
- package/src/plugins/defaults/memory/substrate/__tests__/skill-store.test.ts +195 -1
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +1 -1
- package/src/plugins/defaults/memory/substrate/skill-store.ts +167 -29
- package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +131 -0
- package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +1 -0
- package/src/plugins/defaults/memory/v2/activation-log-store.ts +4 -0
- package/src/plugins/defaults/memory/v2/injection.ts +48 -3
- package/src/plugins/defaults/memory/v2/rerank-local.ts +6 -2
- package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -1
- package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
- package/src/plugins/defaults/turn-context/injectors.ts +1 -0
- package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
- package/src/plugins/types.ts +17 -0
- package/src/prompts/templates/system-sections.ts +2 -2
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
- package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
- package/src/providers/call-site-routing.ts +77 -14
- package/src/providers/connection-resolution.ts +42 -2
- package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
- package/src/providers/inference/adapter-factory.ts +70 -12
- package/src/providers/model-catalog.ts +0 -11
- package/src/providers/model-intents.ts +11 -0
- package/src/providers/registry.ts +8 -0
- package/src/providers/retry.ts +85 -17
- package/src/providers/types.ts +8 -1
- package/src/runtime/__tests__/agent-wake.test.ts +181 -0
- package/src/runtime/agent-wake.ts +132 -9
- package/src/runtime/channel-approval-types.ts +25 -0
- package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
- package/src/runtime/routes/__tests__/platform-invoice-routes.test.ts +392 -0
- package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +7 -0
- package/src/runtime/routes/__tests__/workspace-commit-routes.test.ts +63 -0
- package/src/runtime/routes/consolidation-routes.ts +1 -1
- package/src/runtime/routes/conversation-management-routes.ts +18 -6
- package/src/runtime/routes/conversation-query-routes.ts +14 -23
- package/src/runtime/routes/conversation-routes.ts +113 -8
- package/src/runtime/routes/credential-routes.ts +1 -5
- package/src/runtime/routes/documents-routes.ts +207 -12
- package/src/runtime/routes/identity-routes.ts +3 -93
- package/src/runtime/routes/inbound-stages/admission-policy.test.ts +31 -1
- package/src/runtime/routes/inbound-stages/admission-policy.ts +18 -0
- package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
- package/src/runtime/routes/notification-routes.ts +4 -1
- package/src/runtime/routes/platform-routes.ts +238 -3
- package/src/runtime/routes/playground/guard.ts +1 -2
- package/src/runtime/routes/slack-channel-routes.ts +2 -4
- package/src/runtime/routes/workspace-commit-routes.ts +49 -2
- package/src/runtime/routes/workspace-routes.ts +11 -12
- package/src/runtime/routes/workspace-utils.ts +49 -2
- package/src/runtime/services/conversation-serializer.ts +2 -4
- package/src/subagent/__tests__/consult-context-gating.test.ts +98 -0
- package/src/subagent/__tests__/consult-context-skills.test.ts +82 -0
- package/src/subagent/__tests__/consult-context.test.ts +84 -0
- package/src/subagent/__tests__/consult-prompt.test.ts +36 -0
- package/src/subagent/consult-context.ts +410 -0
- package/src/subagent/consult-prompt.ts +38 -5
- package/src/subagent/manager.ts +9 -4
- package/src/subagent/types.ts +8 -0
- package/src/telemetry/telemetry-event-sources.ts +7 -0
- package/src/telemetry/telemetry-wire-source.json +1 -1
- package/src/telemetry/telemetry-wire.generated.ts +1 -0
- package/src/telemetry/turn-events-store.ts +30 -1
- package/src/telemetry/types.ts +24 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
- package/src/tools/browser/pinned-tabs.ts +3 -1
- package/src/tools/subagent/spawn.ts +23 -1
- package/src/tools/terminal/safe-env.ts +1 -0
- package/src/tools/ui-surface/definitions.ts +39 -1
- package/src/tools/ui-surface/surface-shape-docs.ts +38 -4
- package/src/tools/ui-surface/visual-validation.ts +787 -0
- package/src/tools/workflows/run-workflow.test.ts +1 -0
- package/src/util/__tests__/short-title.test.ts +229 -0
- package/src/util/__tests__/worker-compute.test.ts +65 -0
- package/src/util/cgroup-cpu.ts +93 -0
- package/src/util/errors.ts +27 -0
- package/src/util/process-tree.ts +19 -0
- package/src/util/short-title.ts +189 -0
- package/src/util/worker-compute.ts +83 -0
- package/src/workspace/byok-default-profile-ensure.ts +25 -15
- package/src/workspace/git-service.ts +79 -30
- package/src/workspace/migrations/137-repair-retired-fireworks-minimax-model-id.ts +131 -0
- package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
- package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
- package/src/workspace/migrations/registry.ts +6 -0
- package/src/plugins/defaults/memory/substrate/constants.ts +0 -8
|
@@ -18,6 +18,11 @@ import type { Provider, ToolDefinition } from "../providers/types.js";
|
|
|
18
18
|
import { publishConversationTitleChanged } from "../runtime/sync/resource-sync-events.js";
|
|
19
19
|
import { getLogger } from "../util/logger.js";
|
|
20
20
|
import { Mutex } from "../util/mutex.js";
|
|
21
|
+
import {
|
|
22
|
+
normalizeTitle,
|
|
23
|
+
stripThinkingTags,
|
|
24
|
+
truncateTitle,
|
|
25
|
+
} from "../util/short-title.js";
|
|
21
26
|
import {
|
|
22
27
|
getConversation,
|
|
23
28
|
getMessages,
|
|
@@ -577,176 +582,6 @@ function logRetryableFallback(
|
|
|
577
582
|
);
|
|
578
583
|
}
|
|
579
584
|
|
|
580
|
-
const META_FAILURE_TITLES = new Set([
|
|
581
|
-
"missing context",
|
|
582
|
-
"no context",
|
|
583
|
-
"insufficient context",
|
|
584
|
-
"unclear context",
|
|
585
|
-
"empty context",
|
|
586
|
-
"no topic",
|
|
587
|
-
"unclear topic",
|
|
588
|
-
"unclear request",
|
|
589
|
-
"unclear message",
|
|
590
|
-
"empty conversation",
|
|
591
|
-
"empty message",
|
|
592
|
-
"no content",
|
|
593
|
-
]);
|
|
594
|
-
|
|
595
|
-
const MAX_TITLE_LENGTH = 40;
|
|
596
|
-
const MAX_TITLE_WORDS = 7;
|
|
597
|
-
|
|
598
|
-
function truncateTitle(title: string): string {
|
|
599
|
-
if (title.length <= MAX_TITLE_LENGTH) {
|
|
600
|
-
return title;
|
|
601
|
-
}
|
|
602
|
-
const words = title.split(/\s+/);
|
|
603
|
-
if (words.length <= MAX_TITLE_WORDS) {
|
|
604
|
-
// Long words but few of them — truncate to char limit at word boundary
|
|
605
|
-
let result = "";
|
|
606
|
-
for (const word of words) {
|
|
607
|
-
const candidate = result ? result + " " + word : word;
|
|
608
|
-
if (candidate.length > MAX_TITLE_LENGTH) {
|
|
609
|
-
break;
|
|
610
|
-
}
|
|
611
|
-
result = candidate;
|
|
612
|
-
}
|
|
613
|
-
return result || title.slice(0, MAX_TITLE_LENGTH);
|
|
614
|
-
}
|
|
615
|
-
// Too many words — trim to 5 words
|
|
616
|
-
return words.slice(0, 5).join(" ");
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
function normalizeTitle(raw: string): string {
|
|
620
|
-
let title = raw.trim().replace(/^["']|["']$/g, "");
|
|
621
|
-
title = stripMarkdown(title);
|
|
622
|
-
title = stripThinkingTags(title).trim();
|
|
623
|
-
if (!title) {
|
|
624
|
-
return "";
|
|
625
|
-
}
|
|
626
|
-
// Reject outputs that are the model reasoning aloud or continuing the
|
|
627
|
-
// conversation instead of naming it (e.g. "I need to generate a…", "I'll
|
|
628
|
-
// work through these files…"). Callers fall back to a deterministic title.
|
|
629
|
-
if (looksLikeLeakedProse(title)) {
|
|
630
|
-
return "";
|
|
631
|
-
}
|
|
632
|
-
if (META_FAILURE_TITLES.has(title.toLowerCase())) {
|
|
633
|
-
return "";
|
|
634
|
-
}
|
|
635
|
-
return truncateTitle(title);
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
/** Reasoning/sentence openers that never start a legitimate topic title. */
|
|
639
|
-
const LEAKED_PROSE_PREFIXES = [
|
|
640
|
-
"i need to",
|
|
641
|
-
"i needed to",
|
|
642
|
-
"i should",
|
|
643
|
-
"i will",
|
|
644
|
-
"i'll",
|
|
645
|
-
"i can ",
|
|
646
|
-
"i can't",
|
|
647
|
-
"i cannot",
|
|
648
|
-
"i'm ",
|
|
649
|
-
"i am ",
|
|
650
|
-
"i've ",
|
|
651
|
-
"i have ",
|
|
652
|
-
"i'd ",
|
|
653
|
-
"i would",
|
|
654
|
-
"let me",
|
|
655
|
-
"looking at",
|
|
656
|
-
"based on",
|
|
657
|
-
"given the",
|
|
658
|
-
"to generate",
|
|
659
|
-
"to summarize",
|
|
660
|
-
"to title",
|
|
661
|
-
// Subject-led reasoning openers. A bare noun phrase ("The User Interface
|
|
662
|
-
// Redesign", "The Conversation API") is a valid title, so each subject only
|
|
663
|
-
// counts as leaked prose when a verb or possessive follows it — marking the
|
|
664
|
-
// output as a sentence rather than a topic.
|
|
665
|
-
"the user wants",
|
|
666
|
-
"the user asked",
|
|
667
|
-
"the user is",
|
|
668
|
-
"the user wanted",
|
|
669
|
-
"the user needs",
|
|
670
|
-
"the user said",
|
|
671
|
-
"the user has",
|
|
672
|
-
"the user would",
|
|
673
|
-
"the user's request",
|
|
674
|
-
"the conversation is",
|
|
675
|
-
"this conversation is",
|
|
676
|
-
"the conversation appears",
|
|
677
|
-
"the conversation seems",
|
|
678
|
-
"the conversation covers",
|
|
679
|
-
"the conversation discusses",
|
|
680
|
-
"the assistant should",
|
|
681
|
-
"the assistant is",
|
|
682
|
-
"the assistant wants",
|
|
683
|
-
"the assistant needs",
|
|
684
|
-
"the title should",
|
|
685
|
-
"the title is",
|
|
686
|
-
"the title would",
|
|
687
|
-
"the title for",
|
|
688
|
-
"here's ",
|
|
689
|
-
"here is ",
|
|
690
|
-
"here are ",
|
|
691
|
-
"sure,",
|
|
692
|
-
"okay,",
|
|
693
|
-
"ok,",
|
|
694
|
-
];
|
|
695
|
-
|
|
696
|
-
/**
|
|
697
|
-
* Heuristic guard for title outputs that are clearly prose — the model
|
|
698
|
-
* reasoning aloud or replying to the conversation rather than naming it. A real
|
|
699
|
-
* title is a single-line short noun phrase, so we reject multi-line output,
|
|
700
|
-
* embedded transcript markers, leading reasoning openers, and sentence-shaped
|
|
701
|
-
* clauses. Deliberately tight: a false reject only costs a deterministic
|
|
702
|
-
* fallback title, while a false accept persists a broken one.
|
|
703
|
-
*/
|
|
704
|
-
function looksLikeLeakedProse(title: string): boolean {
|
|
705
|
-
if (/\n/.test(title)) {
|
|
706
|
-
return true;
|
|
707
|
-
}
|
|
708
|
-
if (/\b(?:user|assistant)\s*:/i.test(title)) {
|
|
709
|
-
return true;
|
|
710
|
-
}
|
|
711
|
-
const lower = title.toLowerCase();
|
|
712
|
-
if (LEAKED_PROSE_PREFIXES.some((prefix) => lower.startsWith(prefix))) {
|
|
713
|
-
return true;
|
|
714
|
-
}
|
|
715
|
-
// Sentence-shaped: terminal punctuation on a multi-word clause.
|
|
716
|
-
if (/[.?!]$/.test(title) && title.split(/\s+/).length > 5) {
|
|
717
|
-
return true;
|
|
718
|
-
}
|
|
719
|
-
return false;
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
/** Strip thinking tags so they don't bleed into generated titles. */
|
|
723
|
-
function stripThinkingTags(text: string): string {
|
|
724
|
-
return text
|
|
725
|
-
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
|
|
726
|
-
.replace(/<thought>[\s\S]*?<\/thought>/gi, "")
|
|
727
|
-
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
|
728
|
-
.replace(/<thought>/gi, "")
|
|
729
|
-
.replace(/<\/thought>/gi, "")
|
|
730
|
-
.replace(/<thinking>/gi, "")
|
|
731
|
-
.replace(/<\/thinking>/gi, "")
|
|
732
|
-
.replace(/<think>/gi, "")
|
|
733
|
-
.replace(/<\/think>/gi, "")
|
|
734
|
-
.replace(/<:[^>]*>/gi, "");
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
/** Strip common markdown formatting so titles render as plain text. */
|
|
738
|
-
function stripMarkdown(text: string): string {
|
|
739
|
-
return text
|
|
740
|
-
.replace(/\*\*(.+?)\*\*/g, "$1") // **bold**
|
|
741
|
-
.replace(/__(.+?)__/g, "$1") // __bold__
|
|
742
|
-
.replace(/\*(.+?)\*/g, "$1") // *italic*
|
|
743
|
-
.replace(/(?<!\w)_(.+?)_(?!\w)/g, "$1") // _italic_ (word-boundary-aware to preserve snake_case)
|
|
744
|
-
.replace(/~~(.+?)~~/g, "$1") // ~~strikethrough~~
|
|
745
|
-
.replace(/`(.+?)`/g, "$1") // `code`
|
|
746
|
-
.replace(/\[(.+?)\]\(.+?\)/g, "$1") // [link](url)
|
|
747
|
-
.replace(/^#{1,6}\s+/gm, ""); // # headings
|
|
748
|
-
}
|
|
749
|
-
|
|
750
585
|
function deriveFallbackTitle(context?: TitleContext): string | null {
|
|
751
586
|
if (!context) {
|
|
752
587
|
return null;
|
|
@@ -3,7 +3,20 @@
|
|
|
3
3
|
// `conversation-crud.ts`, the notification pipeline, and read-side filters
|
|
4
4
|
// share one declaration without importing the heavy CRUD module. Also provides
|
|
5
5
|
// the shared "is this a non-interactive / background conversation?" predicate
|
|
6
|
-
// used by notification-feed and memory filters
|
|
6
|
+
// used by notification-feed and memory filters, the source-aware
|
|
7
|
+
// `resolveConversationKind` classifier, and the pure predicates over a
|
|
8
|
+
// persisted message's `metadata` record.
|
|
9
|
+
|
|
10
|
+
import { parseChannelId } from "../channels/types.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Canonical `conversations.source` string for background memory v2
|
|
14
|
+
* consolidation runs. A persisted column value, so it lives with the rest of
|
|
15
|
+
* the conversation vocabulary rather than inside the memory plugin that
|
|
16
|
+
* writes it (persistence must not depend on a plugin).
|
|
17
|
+
*/
|
|
18
|
+
// FROZEN: persisted `conversations.source` value. Never rename it.
|
|
19
|
+
export const MEMORY_V2_CONSOLIDATION_SOURCE = "memory_v2_consolidation";
|
|
7
20
|
|
|
8
21
|
/** How a conversation was created / its execution mode. */
|
|
9
22
|
export type ConversationCreateType = "standard" | "background" | "scheduled";
|
|
@@ -29,3 +42,181 @@ export function isBackgroundConversationType(
|
|
|
29
42
|
): boolean {
|
|
30
43
|
return (BACKGROUND_CONVERSATION_TYPES as readonly string[]).includes(t ?? "");
|
|
31
44
|
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Who or what drove a conversation, folding the `source` and `conversationType`
|
|
48
|
+
* columns into one label.
|
|
49
|
+
*/
|
|
50
|
+
export type ConversationKind =
|
|
51
|
+
| "user"
|
|
52
|
+
| "background"
|
|
53
|
+
| "background_memory_consolidation"
|
|
54
|
+
| "scheduled";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Single classifier shared by the LLM-context routes and the notification
|
|
58
|
+
* producers, so a new background source or conversation type lands in every
|
|
59
|
+
* consumer at once.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveConversationKind(
|
|
62
|
+
source: string,
|
|
63
|
+
conversationType: string,
|
|
64
|
+
): ConversationKind {
|
|
65
|
+
if (source === MEMORY_V2_CONSOLIDATION_SOURCE) {
|
|
66
|
+
return "background_memory_consolidation";
|
|
67
|
+
}
|
|
68
|
+
if (conversationType === "background") {
|
|
69
|
+
return "background";
|
|
70
|
+
}
|
|
71
|
+
if (conversationType === "scheduled") {
|
|
72
|
+
return "scheduled";
|
|
73
|
+
}
|
|
74
|
+
return "user";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Shared predicate for the transcript-suppression flag on user-message
|
|
79
|
+
* metadata (the `hidden` field of the persisted `metadata` column). One
|
|
80
|
+
* definition so the sites that must agree (echo suppression, list-messages
|
|
81
|
+
* filtering, queued-snapshot filtering, indexing exclusion, and downstream
|
|
82
|
+
* consumers of message text) cannot drift.
|
|
83
|
+
*/
|
|
84
|
+
export function isHiddenMessageMetadata(
|
|
85
|
+
metadata: Record<string, unknown> | null | undefined,
|
|
86
|
+
): boolean {
|
|
87
|
+
return metadata?.hidden === true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* True when the row is a persisted `<background_event source="...">` trigger.
|
|
92
|
+
* Every wake, scheduled run, and backgrounded-tool completion stamps one (see
|
|
93
|
+
* `persistWakeTriggerMessage`). The permission mode such a turn ran under
|
|
94
|
+
* varies (most run interactive; clientless/headless wakes do not) and is
|
|
95
|
+
* recorded separately in `backgroundEventInteractive`; this predicate only
|
|
96
|
+
* identifies the row as a background event.
|
|
97
|
+
*/
|
|
98
|
+
export function isBackgroundEventMetadata(
|
|
99
|
+
metadata: Record<string, unknown> | undefined,
|
|
100
|
+
): boolean {
|
|
101
|
+
return typeof metadata?.backgroundEventSource === "string";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* True when a role-`"user"` row is internal scaffolding rather than a person's
|
|
106
|
+
* prompt: a daemon-injected run lifecycle notification (subagent
|
|
107
|
+
* `subagentNotification`, ACP run `acpNotification`, or any wake trigger, the
|
|
108
|
+
* persisted `<background_event source="...">` row every wake reads), or a row
|
|
109
|
+
* explicitly flagged `hidden` (e.g. a hidden `POST /messages` send that queued
|
|
110
|
+
* behind an in-flight turn, like the channel-setup wizard-close marker).
|
|
111
|
+
*
|
|
112
|
+
* These rows are persisted so the orchestrator wakes and reads the trigger,
|
|
113
|
+
* but the user sees the wake through its inline card ("Conversation Woke", or
|
|
114
|
+
* a terminal card for a backgrounded bash run), not a chat turn. One
|
|
115
|
+
* definition so the sites that must agree cannot drift: the
|
|
116
|
+
* `user_message_echo` broadcast (never render a live user bubble), the retry
|
|
117
|
+
* route's hidden-prompt re-run, and {@link isReplyPushIneligibleUserMessage}
|
|
118
|
+
* (a turn opened by one of these rows is not a user prompt awaiting a reply).
|
|
119
|
+
*/
|
|
120
|
+
export function isEchoSuppressedUserMessage(
|
|
121
|
+
metadata: Record<string, unknown> | undefined,
|
|
122
|
+
): boolean {
|
|
123
|
+
return (
|
|
124
|
+
isHiddenMessageMetadata(metadata) ||
|
|
125
|
+
metadata?.subagentNotification != null ||
|
|
126
|
+
metadata?.acpNotification != null ||
|
|
127
|
+
isBackgroundEventMetadata(metadata)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* True when a role-`"user"` row was persisted by the voice bridge for a live
|
|
133
|
+
* phone call or in-app voice session (every row `startVoiceTurn` persists, see
|
|
134
|
+
* `calls/voice-session-bridge.ts`).
|
|
135
|
+
*
|
|
136
|
+
* The reply to such a turn is spoken back over the still-open session, so any
|
|
137
|
+
* consumer that treats a finished reply as something the user has yet to see
|
|
138
|
+
* must skip it rather than push a notification for audio the user is hearing
|
|
139
|
+
* right now.
|
|
140
|
+
*
|
|
141
|
+
* This marker is the only durable signal for that: the channel/interface fields
|
|
142
|
+
* cannot stand in, because an in-app live-voice turn persists as
|
|
143
|
+
* `vellum`/`macos`, indistinguishable from a typed desktop send.
|
|
144
|
+
*/
|
|
145
|
+
export function isVoiceSessionUserMessage(
|
|
146
|
+
metadata: Record<string, unknown> | undefined,
|
|
147
|
+
): boolean {
|
|
148
|
+
return metadata?.voiceSessionTurn === true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* True when the row that opened the turn arrived over an external messaging
|
|
153
|
+
* surface (Slack, Telegram, WhatsApp, email, a phone call) rather than the
|
|
154
|
+
* native app.
|
|
155
|
+
*
|
|
156
|
+
* An absent (or unrecognized) channel counts as in-app: every external ingress
|
|
157
|
+
* path stamps its channel via buildChannelMetadata, so the rows that omit the
|
|
158
|
+
* field are daemon-internal persists on native-app conversations (for example
|
|
159
|
+
* the deliberate omission in calls/call-pointer-messages.ts).
|
|
160
|
+
*/
|
|
161
|
+
function isChannelOriginatedUserMessage(
|
|
162
|
+
metadata: Record<string, unknown> | undefined,
|
|
163
|
+
): boolean {
|
|
164
|
+
const channel = parseChannelId(metadata?.userMessageChannel);
|
|
165
|
+
return channel != null && channel !== "vellum";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* True when the reply to this row reaches the user over the surface the row
|
|
170
|
+
* arrived on rather than the app: a messaging channel the finished reply is
|
|
171
|
+
* delivered back to, or a still-open voice session that speaks it aloud.
|
|
172
|
+
*
|
|
173
|
+
* Both reasons describe where a reply lands, not what the row is, so they hold
|
|
174
|
+
* only for a turn that keeps the row's original delivery orchestration.
|
|
175
|
+
*/
|
|
176
|
+
function isReplyDeliveredOffApp(
|
|
177
|
+
metadata: Record<string, unknown> | undefined,
|
|
178
|
+
): boolean {
|
|
179
|
+
return (
|
|
180
|
+
isVoiceSessionUserMessage(metadata) ||
|
|
181
|
+
isChannelOriginatedUserMessage(metadata)
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* True when a finished reply to this row must not raise a
|
|
187
|
+
* `chat.assistant_reply` push. Five independent reasons, one predicate so the
|
|
188
|
+
* producer's own gating and the batch-drain selection of the row it reads
|
|
189
|
+
* cannot disagree: picking a row the producer then suppresses would swallow the
|
|
190
|
+
* push for a genuine prompt coalesced into the same batch.
|
|
191
|
+
*
|
|
192
|
+
* - `automated`: a scheduled or background prompt injected into an ordinary
|
|
193
|
+
* user conversation, which has its own producer (e.g. `schedule.notify`).
|
|
194
|
+
* - Echo-suppressed: internal scaffolding, nobody's prompt awaiting a reply.
|
|
195
|
+
* - Voice-session: the reply is spoken back over the still-open session.
|
|
196
|
+
* - Channel-originated: the finished reply is delivered back to the
|
|
197
|
+
* originating messaging surface (`finalizeEventDelivery`), so the sender
|
|
198
|
+
* already has it and a push would duplicate it on their phone.
|
|
199
|
+
* - `pointerInstruction`: `runPointerMessageTurn` fact-checks the rows the turn
|
|
200
|
+
* generated only after the turn ends, and deletes them when validation fails
|
|
201
|
+
* so deterministic fallback copy takes their place. A push at turn end would
|
|
202
|
+
* already have carried the rejected text.
|
|
203
|
+
*
|
|
204
|
+
* `replyDeliveredInAppOnly` marks a turn whose reply streams to the app and
|
|
205
|
+
* nowhere else: `POST /conversations/:id/retry` re-runs the anchor row through
|
|
206
|
+
* the agent loop with no channel-delivery orchestration and no voice session
|
|
207
|
+
* attached, so the two delivery-surface reasons ({@link isReplyDeliveredOffApp})
|
|
208
|
+
* no longer describe it and suppressing the push would leave the user without a
|
|
209
|
+
* copy anywhere. The other three reasons are properties of the row itself and
|
|
210
|
+
* stand either way.
|
|
211
|
+
*/
|
|
212
|
+
export function isReplyPushIneligibleUserMessage(
|
|
213
|
+
metadata: Record<string, unknown> | undefined,
|
|
214
|
+
options?: { replyDeliveredInAppOnly?: boolean },
|
|
215
|
+
): boolean {
|
|
216
|
+
return (
|
|
217
|
+
metadata?.automated === true ||
|
|
218
|
+
metadata?.pointerInstruction === true ||
|
|
219
|
+
isEchoSuppressedUserMessage(metadata) ||
|
|
220
|
+
(!options?.replyDeliveredInAppOnly && isReplyDeliveredOffApp(metadata))
|
|
221
|
+
);
|
|
222
|
+
}
|
|
@@ -3,9 +3,10 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { getLogger } from "../util/logger.js";
|
|
4
4
|
import { getDbPath } from "../util/platform.js";
|
|
5
5
|
import { runAsyncSqlite } from "./db-async-query.js";
|
|
6
|
-
import { getDb } from "./db-connection.js";
|
|
6
|
+
import { getDb, getSqliteFrom } from "./db-connection.js";
|
|
7
7
|
import { runMigrationSteps } from "./migrations/run-migrations.js";
|
|
8
8
|
import { validateMigrationState } from "./migrations/validate-migration-state.js";
|
|
9
|
+
import { PLANNER_OPTIMIZE_PRAGMA } from "./planner-statistics.js";
|
|
9
10
|
import { migrationSteps } from "./steps.js";
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -101,6 +102,18 @@ export async function initializeDb(): Promise<{ migrationsOk: boolean }> {
|
|
|
101
102
|
"DB migration steps complete",
|
|
102
103
|
);
|
|
103
104
|
|
|
105
|
+
// An index a migration creates has no sqlite_stat1 entry until something
|
|
106
|
+
// analyzes it, which SQLite calls out as a case to handle after a schema
|
|
107
|
+
// change. Only boots that applied a step pay for it, and optimize analyzes
|
|
108
|
+
// just the tables that need it.
|
|
109
|
+
if (applied.length > 0) {
|
|
110
|
+
try {
|
|
111
|
+
getSqliteFrom(database).exec(PLANNER_OPTIMIZE_PRAGMA);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
log.warn({ err }, "Post-migration PRAGMA optimize failed (non-fatal)");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
104
117
|
if (failed.length > 0) {
|
|
105
118
|
log.error(
|
|
106
119
|
{ failedMigrations: failed, count: failed.length },
|
|
@@ -8,6 +8,7 @@ import { getMemoryCheckpoint, setMemoryCheckpoint } from "./checkpoints.js";
|
|
|
8
8
|
import { getLastInteractiveUserMessageTimestamp } from "./conversation-crud.js";
|
|
9
9
|
import { runAsyncSqlite } from "./db-async-query.js";
|
|
10
10
|
import { getSqlite } from "./db-connection.js";
|
|
11
|
+
import { PLANNER_OPTIMIZE_PRAGMA } from "./planner-statistics.js";
|
|
11
12
|
|
|
12
13
|
const log = getLogger("db-maintenance");
|
|
13
14
|
|
|
@@ -80,11 +81,11 @@ async function runDbMaintenance(): Promise<void> {
|
|
|
80
81
|
log.warn({ err }, "Workflow run pruning failed (non-fatal)");
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
// Refresh the query planner's statistics
|
|
84
|
-
//
|
|
85
|
-
// thread when the sqlite3 CLI backend is available.
|
|
84
|
+
// Refresh the query planner's statistics (see PLANNER_OPTIMIZE_PRAGMA for
|
|
85
|
+
// what the mask buys here). Routed through the async path to keep it off the
|
|
86
|
+
// main thread when the sqlite3 CLI backend is available.
|
|
86
87
|
const optimizeResult = await runAsyncSqlite(
|
|
87
|
-
|
|
88
|
+
PLANNER_OPTIMIZE_PRAGMA,
|
|
88
89
|
"db-maintenance:optimize",
|
|
89
90
|
);
|
|
90
91
|
if (!optimizeResult.ok) {
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { SparseEmbedding } from "../embedding-types.js";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Regression: the plugin index must not depend on the memory tier — or on the
|
|
7
|
+
// daemon — having initialized the shared Qdrant client.
|
|
8
|
+
//
|
|
9
|
+
// `runMemoryStartup` calls `initQdrantClient` only in the daemon process, and
|
|
10
|
+
// only while memory v1 is the live tier; on a default workspace
|
|
11
|
+
// (`memory.v2.enabled` defaults true, or `memory.v3.live`) it is skipped, so
|
|
12
|
+
// `getQdrantClient()` throws and every plugin Index op used to fail with
|
|
13
|
+
// `BackendUnavailableError: Qdrant client not initialized`. The ops must
|
|
14
|
+
// initialize the client themselves from the live workspace config, and rebuild
|
|
15
|
+
// it when that config changes rather than serving a stale one.
|
|
16
|
+
//
|
|
17
|
+
// The fake singleton below behaves like the real one — absent until
|
|
18
|
+
// `initQdrantClient` sets it — where `plugin-index.test.ts` mocks a client that
|
|
19
|
+
// is already up. Each test file runs in its own Bun process (see
|
|
20
|
+
// scripts/test.ts), so the two mocks cannot collide.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
interface InitCall {
|
|
24
|
+
url: string;
|
|
25
|
+
collection: string;
|
|
26
|
+
vectorSize: number;
|
|
27
|
+
onDisk: boolean;
|
|
28
|
+
quantization: string;
|
|
29
|
+
embeddingModel?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const calls = {
|
|
33
|
+
init: [] as InitCall[],
|
|
34
|
+
upserts: [] as Array<{ targetType: string; targetId: string }>,
|
|
35
|
+
get: [] as Array<{ targetId: string }>,
|
|
36
|
+
deleteScoped: [] as Array<{ targetId: string }>,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const fakeQdrant = {
|
|
40
|
+
async upsert(targetType: string, targetId: string) {
|
|
41
|
+
calls.upserts.push({ targetType, targetId });
|
|
42
|
+
},
|
|
43
|
+
async getByTarget(_targetType: string, targetId: string) {
|
|
44
|
+
calls.get.push({ targetId });
|
|
45
|
+
return null;
|
|
46
|
+
},
|
|
47
|
+
async deleteByTargetAndPlugin(_targetType: string, targetId: string) {
|
|
48
|
+
calls.deleteScoped.push({ targetId });
|
|
49
|
+
},
|
|
50
|
+
async hybridSearch() {
|
|
51
|
+
return [];
|
|
52
|
+
},
|
|
53
|
+
async search() {
|
|
54
|
+
return [];
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Stands in for the module singleton in `qdrant-client.ts`: null until set. */
|
|
59
|
+
let instance: typeof fakeQdrant | null = null;
|
|
60
|
+
|
|
61
|
+
mock.module("../qdrant-client.js", () => ({
|
|
62
|
+
getQdrantClient: () => {
|
|
63
|
+
if (!instance) {
|
|
64
|
+
throw new Error("Qdrant client not initialized. Call initQdrantClient()");
|
|
65
|
+
}
|
|
66
|
+
return instance;
|
|
67
|
+
},
|
|
68
|
+
initQdrantClient: (cfg: InitCall) => {
|
|
69
|
+
calls.init.push(cfg);
|
|
70
|
+
instance = fakeQdrant;
|
|
71
|
+
return instance;
|
|
72
|
+
},
|
|
73
|
+
resolveQdrantUrl: () => "http://127.0.0.1:7777",
|
|
74
|
+
}));
|
|
75
|
+
mock.module("../qdrant-circuit-breaker.js", () => ({
|
|
76
|
+
withQdrantBreaker: <T>(fn: () => Promise<T>) => fn(),
|
|
77
|
+
}));
|
|
78
|
+
mock.module("../embed.js", () => ({
|
|
79
|
+
embedWithRetry: async () => ({
|
|
80
|
+
provider: "gemini",
|
|
81
|
+
model: "test-embed-model",
|
|
82
|
+
vectors: [[0.1, 0.2, 0.3]],
|
|
83
|
+
}),
|
|
84
|
+
}));
|
|
85
|
+
mock.module("../embedding-backend.js", () => ({
|
|
86
|
+
selectEmbeddingBackend: async () => ({
|
|
87
|
+
backend: { provider: "gemini", model: liveConfig.memory.embeddings.model },
|
|
88
|
+
reason: null,
|
|
89
|
+
}),
|
|
90
|
+
getMemoryBackendStatus: async () => ({
|
|
91
|
+
enabled: true,
|
|
92
|
+
degraded: false,
|
|
93
|
+
provider: "gemini",
|
|
94
|
+
model: liveConfig.memory.embeddings.model,
|
|
95
|
+
reason: null,
|
|
96
|
+
}),
|
|
97
|
+
generateSparseEmbedding: (): SparseEmbedding => ({
|
|
98
|
+
indices: [1, 2],
|
|
99
|
+
values: [0.5, 0.5],
|
|
100
|
+
}),
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
/** The workspace config `getConfig()` returns; mutated to simulate a reload. */
|
|
104
|
+
let liveConfig = {
|
|
105
|
+
memory: {
|
|
106
|
+
qdrant: {
|
|
107
|
+
collection: "vellum_memory",
|
|
108
|
+
vectorSize: 768,
|
|
109
|
+
onDisk: true,
|
|
110
|
+
quantization: "scalar",
|
|
111
|
+
},
|
|
112
|
+
embeddings: { provider: "gemini", model: "test-embed-model" },
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
mock.module("../../../config/loader.js", () => ({
|
|
117
|
+
getConfig: () => liveConfig,
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
const { indexDocument, queryIndex, getDocument, removeDocument } =
|
|
121
|
+
await import("../plugin-index.js");
|
|
122
|
+
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
calls.init.length = 0;
|
|
125
|
+
calls.upserts.length = 0;
|
|
126
|
+
calls.get.length = 0;
|
|
127
|
+
calls.deleteScoped.length = 0;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
afterAll(() => {
|
|
131
|
+
mock.restore();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("plugin index without a pre-initialized Qdrant client", () => {
|
|
135
|
+
test("indexDocument initializes the client from config instead of throwing", async () => {
|
|
136
|
+
const res = await indexDocument(
|
|
137
|
+
liveConfig as never,
|
|
138
|
+
"ledger",
|
|
139
|
+
"Morning coffee",
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
expect(res.documentId).toBeTruthy();
|
|
143
|
+
expect(calls.upserts).toEqual([
|
|
144
|
+
{ targetType: "plugin_index", targetId: `ledger:${res.documentId}` },
|
|
145
|
+
]);
|
|
146
|
+
// Same shared collection, same dimension, and the dense model identity so
|
|
147
|
+
// the collection keeps its create/migrate semantics.
|
|
148
|
+
expect(calls.init).toEqual([
|
|
149
|
+
{
|
|
150
|
+
url: "http://127.0.0.1:7777",
|
|
151
|
+
collection: "vellum_memory",
|
|
152
|
+
vectorSize: 768,
|
|
153
|
+
onDisk: true,
|
|
154
|
+
quantization: "scalar",
|
|
155
|
+
embeddingModel: "gemini:test-embed-model",
|
|
156
|
+
},
|
|
157
|
+
]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("query/get/remove also work without a pre-initialized client", async () => {
|
|
161
|
+
await expect(
|
|
162
|
+
queryIndex(liveConfig as never, "ledger", "coffee"),
|
|
163
|
+
).resolves.toEqual([]);
|
|
164
|
+
await expect(getDocument("ledger", "doc-1")).resolves.toBeNull();
|
|
165
|
+
await expect(removeDocument("ledger", "doc-1")).resolves.toBeUndefined();
|
|
166
|
+
|
|
167
|
+
expect(calls.get).toEqual([{ targetId: "ledger:doc-1" }]);
|
|
168
|
+
expect(calls.deleteScoped).toEqual([{ targetId: "ledger:doc-1" }]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("an unchanged config reuses the client instead of rebuilding it", async () => {
|
|
172
|
+
await indexDocument(liveConfig as never, "ledger", "one");
|
|
173
|
+
await indexDocument(liveConfig as never, "ledger", "two");
|
|
174
|
+
await getDocument("ledger", "doc-1");
|
|
175
|
+
|
|
176
|
+
expect(calls.upserts).toHaveLength(2);
|
|
177
|
+
expect(calls.init).toHaveLength(0); // already built by an earlier test
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("a live embedding-config change rebuilds the client", async () => {
|
|
181
|
+
await indexDocument(liveConfig as never, "ledger", "before");
|
|
182
|
+
expect(calls.init).toHaveLength(0);
|
|
183
|
+
|
|
184
|
+
// A model swap at the same dimension: the client's captured model identity
|
|
185
|
+
// is now wrong, so reusing it would mix two models' vectors in one
|
|
186
|
+
// collection without tripping the sentinel migration.
|
|
187
|
+
liveConfig = {
|
|
188
|
+
memory: {
|
|
189
|
+
...liveConfig.memory,
|
|
190
|
+
embeddings: { provider: "gemini", model: "swapped-embed-model" },
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
await indexDocument(liveConfig as never, "ledger", "after");
|
|
194
|
+
|
|
195
|
+
expect(calls.init).toEqual([
|
|
196
|
+
{
|
|
197
|
+
url: "http://127.0.0.1:7777",
|
|
198
|
+
collection: "vellum_memory",
|
|
199
|
+
vectorSize: 768,
|
|
200
|
+
onDisk: true,
|
|
201
|
+
quantization: "scalar",
|
|
202
|
+
embeddingModel: "gemini:swapped-embed-model",
|
|
203
|
+
},
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("a dimension change rebuilds the client", async () => {
|
|
208
|
+
liveConfig = {
|
|
209
|
+
memory: {
|
|
210
|
+
...liveConfig.memory,
|
|
211
|
+
qdrant: { ...liveConfig.memory.qdrant, vectorSize: 1536 },
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
await indexDocument(liveConfig as never, "ledger", "resized");
|
|
215
|
+
|
|
216
|
+
expect(calls.init).toHaveLength(1);
|
|
217
|
+
expect(calls.init[0].vectorSize).toBe(1536);
|
|
218
|
+
});
|
|
219
|
+
});
|