@vellumai/assistant 0.5.16 → 0.6.0
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 +1 -1
- package/Dockerfile +0 -3
- package/knip.json +2 -1
- package/openapi.yaml +660 -80
- package/package.json +1 -1
- package/src/__tests__/actor-token-service.test.ts +68 -0
- package/src/__tests__/agent-loop.test.ts +0 -32
- package/src/__tests__/always-loaded-tools-guard.test.ts +2 -2
- package/src/__tests__/anthropic-provider.test.ts +57 -3
- package/src/__tests__/app-compiler.test.ts +120 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +2 -2
- package/src/__tests__/call-conversation-messages.test.ts +2 -6
- package/src/__tests__/call-domain.test.ts +2 -6
- package/src/__tests__/call-pointer-messages.test.ts +2 -14
- package/src/__tests__/call-recovery.test.ts +2 -6
- package/src/__tests__/call-routes-http.test.ts +2 -6
- package/src/__tests__/call-store.test.ts +2 -6
- package/src/__tests__/cancel-resolves-conversation-key.test.ts +2 -6
- package/src/__tests__/canonical-guardian-store.test.ts +2 -6
- package/src/__tests__/channel-delivery-store.test.ts +2 -6
- package/src/__tests__/channel-retry-sweep.test.ts +2 -6
- package/src/__tests__/checker.test.ts +25 -3
- package/src/__tests__/clawhub.test.ts +54 -24
- package/src/__tests__/cli-command-risk-guard.test.ts +14 -0
- package/src/__tests__/cli-memory.test.ts +74 -69
- package/src/__tests__/config-schema.test.ts +1 -1
- package/src/__tests__/config-set-platform-guard.test.ts +302 -0
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +2 -6
- package/src/__tests__/contacts-tools.test.ts +31 -0
- package/src/__tests__/context-overflow-reducer.test.ts +86 -0
- package/src/__tests__/context-token-estimator.test.ts +175 -10
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +9 -0
- package/src/__tests__/conversation-agent-loop.test.ts +9 -0
- package/src/__tests__/conversation-attachments.test.ts +2 -6
- package/src/__tests__/conversation-attention-store.test.ts +2 -6
- package/src/__tests__/conversation-clear-safety.test.ts +2 -6
- package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +4 -10
- package/src/__tests__/conversation-disk-view-integration.test.ts +2 -6
- package/src/__tests__/conversation-disk-view.test.ts +2 -6
- package/src/__tests__/conversation-error.test.ts +33 -2
- package/src/__tests__/conversation-fork-crud.test.ts +2 -6
- package/src/__tests__/conversation-history-web-search.test.ts +5 -0
- package/src/__tests__/conversation-load-history-repair.test.ts +5 -1
- package/src/__tests__/conversation-media-retry.test.ts +91 -0
- package/src/__tests__/conversation-starter-routes.test.ts +20 -11
- package/src/__tests__/conversation-store.test.ts +2 -6
- package/src/__tests__/conversation-usage.test.ts +2 -6
- package/src/__tests__/conversation-wipe.test.ts +11 -408
- package/src/__tests__/credential-execution-feature-gates.test.ts +3 -3
- package/src/__tests__/credential-execution-shell-lockdown.test.ts +2 -2
- package/src/__tests__/credential-security-e2e.test.ts +2 -0
- package/src/__tests__/followup-tools.test.ts +2 -6
- package/src/__tests__/graph-extraction-event-date.test.ts +186 -0
- package/src/__tests__/guardian-action-conversation-turn.test.ts +2 -6
- package/src/__tests__/guardian-action-followup-executor.test.ts +2 -6
- package/src/__tests__/guardian-action-followup-store.test.ts +2 -6
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +2 -6
- package/src/__tests__/guardian-action-late-reply.test.ts +2 -6
- package/src/__tests__/guardian-action-store.test.ts +2 -6
- package/src/__tests__/guardian-binding-drift-heal.test.ts +2 -6
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +8 -8
- package/src/__tests__/guardian-dispatch.test.ts +2 -6
- package/src/__tests__/guardian-grant-minting.test.ts +2 -14
- package/src/__tests__/guardian-principal-id-roundtrip.test.ts +2 -6
- package/src/__tests__/guardian-routing-invariants.test.ts +192 -6
- package/src/__tests__/guardian-routing-state.test.ts +2 -6
- package/src/__tests__/guardian-verification-voice-binding.test.ts +2 -6
- package/src/__tests__/inbound-invite-redemption.test.ts +2 -6
- package/src/__tests__/injection-block.test.ts +154 -0
- package/src/__tests__/install-meta.test.ts +506 -0
- package/src/__tests__/install-skill-routing.test.ts +292 -0
- package/src/__tests__/invite-redemption-service.test.ts +2 -6
- package/src/__tests__/invite-routes-http.test.ts +2 -6
- package/src/__tests__/jobs-store-qdrant-breaker.test.ts +2 -14
- package/src/__tests__/list-messages-attachments.test.ts +2 -6
- package/src/__tests__/llm-context-route-provider.test.ts +2 -6
- package/src/__tests__/llm-request-log-turn-query.test.ts +2 -6
- package/src/__tests__/llm-usage-store.test.ts +2 -6
- package/src/__tests__/log-export-workspace.test.ts +2 -6
- package/src/__tests__/managed-store.test.ts +38 -11
- package/src/__tests__/memory-jobs-worker-backoff.test.ts +2 -8
- package/src/__tests__/memory-recall-log-store.test.ts +2 -6
- package/src/__tests__/memory-upsert-concurrency.test.ts +4 -112
- package/src/__tests__/non-member-access-request.test.ts +2 -6
- package/src/__tests__/notification-guardian-path.test.ts +2 -6
- package/src/__tests__/oauth-cli.test.ts +364 -2
- package/src/__tests__/oauth2-gateway-transport.test.ts +18 -3
- package/src/__tests__/outlook-attachments.test.ts +301 -0
- package/src/__tests__/outlook-automation-tools.test.ts +425 -0
- package/src/__tests__/outlook-categories.test.ts +212 -0
- package/src/__tests__/outlook-client-automation.test.ts +246 -0
- package/src/__tests__/outlook-compose-tools.test.ts +325 -0
- package/src/__tests__/outlook-declutter-tools.test.ts +585 -0
- package/src/__tests__/outlook-email-watcher.test.ts +322 -0
- package/src/__tests__/outlook-follow-up.test.ts +196 -0
- package/src/__tests__/outlook-messaging-provider.test.ts +498 -3
- package/src/__tests__/outlook-trash.test.ts +77 -0
- package/src/__tests__/outlook-unsubscribe.test.ts +250 -0
- package/src/__tests__/platform-callback-registration.test.ts +4 -4
- package/src/__tests__/playbook-execution.test.ts +76 -80
- package/src/__tests__/playbook-tools.test.ts +5 -7
- package/src/__tests__/provider-error-scenarios.test.ts +21 -0
- package/src/__tests__/rebuild-index-graph-nodes.test.ts +273 -0
- package/src/__tests__/registry.test.ts +2 -2
- package/src/__tests__/require-fresh-approval.test.ts +64 -2
- package/src/__tests__/runtime-events-sse-parity.test.ts +2 -6
- package/src/__tests__/runtime-events-sse.test.ts +2 -6
- package/src/__tests__/schedule-store.test.ts +2 -6
- package/src/__tests__/schedule-tools.test.ts +2 -6
- package/src/__tests__/scheduler-recurrence.test.ts +1 -5
- package/src/__tests__/scoped-approval-grants.test.ts +2 -6
- package/src/__tests__/scoped-grant-security-matrix.test.ts +2 -6
- package/src/__tests__/search-skills-unified.test.ts +421 -0
- package/src/__tests__/secret-onetime-send.test.ts +2 -0
- package/src/__tests__/send-endpoint-busy.test.ts +2 -6
- package/src/__tests__/sequence-store.test.ts +2 -6
- package/src/__tests__/server-history-render.test.ts +2 -6
- package/src/__tests__/skill-feature-flags-integration.test.ts +38 -31
- package/src/__tests__/skill-feature-flags.test.ts +6 -6
- package/src/__tests__/skill-load-feature-flag.test.ts +11 -11
- package/src/__tests__/skill-memory.test.ts +140 -98
- package/src/__tests__/skills-uninstall.test.ts +2 -2
- package/src/__tests__/skills.test.ts +1 -1
- package/src/__tests__/slack-inbound-verification.test.ts +2 -6
- package/src/__tests__/task-compiler.test.ts +2 -6
- package/src/__tests__/task-management-tools.test.ts +2 -6
- package/src/__tests__/task-memory-cleanup.test.ts +173 -229
- package/src/__tests__/task-runner.test.ts +2 -6
- package/src/__tests__/task-scheduler.test.ts +2 -6
- package/src/__tests__/test-preload.ts +3 -0
- package/src/__tests__/tool-approval-handler.test.ts +2 -6
- package/src/__tests__/tool-grant-request-escalation.test.ts +2 -6
- package/src/__tests__/tool-side-effects-slack-dm.test.ts +276 -0
- package/src/__tests__/trust-store.test.ts +1 -1
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -6
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +2 -6
- package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
- package/src/__tests__/trusted-contact-verification.test.ts +2 -6
- package/src/__tests__/turn-boundary-resolution.test.ts +2 -6
- package/src/__tests__/usage-cache-backfill-migration.test.ts +1 -6
- package/src/__tests__/usage-routes.test.ts +2 -6
- package/src/__tests__/verification-control-plane-policy.test.ts +0 -2
- package/src/__tests__/voice-invite-redemption.test.ts +2 -6
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +2 -6
- package/src/__tests__/voice-session-bridge.test.ts +2 -6
- package/src/__tests__/volume-security-guard.test.ts +2 -0
- package/src/__tests__/workspace-lifecycle.test.ts +29 -1
- package/src/__tests__/workspace-migration-009-backfill-conversation-disk-view.test.ts +2 -6
- package/src/__tests__/workspace-migration-013-repair-conversation-disk-view.test.ts +2 -6
- package/src/__tests__/workspace-migration-026-backfill-install-meta.test.ts +558 -0
- package/src/__tests__/workspace-policy.test.ts +1 -1
- package/src/agent/attachments.ts +7 -2
- package/src/agent/image-optimize.ts +165 -0
- package/src/agent/loop.ts +1 -15
- package/src/bundler/app-compiler.ts +179 -2
- package/src/bundler/package-resolver.ts +3 -5
- package/src/cli/__tests__/notifications.test.ts +1 -2
- package/src/cli/cli-memory.ts +67 -64
- package/src/cli/commands/avatar.ts +3 -3
- package/src/cli/commands/config.ts +26 -13
- package/src/cli/commands/doctor.ts +2 -2
- package/src/cli/commands/memory.ts +41 -55
- package/src/cli/commands/oauth/__tests__/connect.test.ts +2 -2
- package/src/cli/commands/oauth/__tests__/disconnect.test.ts +2 -2
- package/src/cli/commands/oauth/__tests__/mode.test.ts +8 -1
- package/src/cli/commands/oauth/__tests__/status.test.ts +2 -2
- package/src/cli/commands/oauth/connect.ts +11 -6
- package/src/cli/commands/oauth/mode.ts +7 -0
- package/src/cli/commands/oauth/shared.ts +39 -3
- package/src/cli/commands/platform/__tests__/connect.test.ts +1 -1
- package/src/cli/commands/platform/__tests__/disconnect.test.ts +1 -1
- package/src/cli/commands/platform/__tests__/status.test.ts +5 -5
- package/src/cli/commands/platform/index.ts +16 -16
- package/src/cli/commands/skills.ts +88 -16
- package/src/cli/commands/trust.ts +2 -2
- package/src/cli/lib/daemon-credential-client.ts +2 -3
- package/src/config/bundled-skills/acp/TOOLS.json +1 -1
- package/src/config/bundled-skills/contacts/SKILL.md +0 -1
- package/src/config/bundled-skills/contacts/TOOLS.json +0 -8
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +0 -4
- package/src/config/bundled-skills/gmail/SKILL.md +2 -10
- package/src/config/bundled-skills/google-calendar/SKILL.md +1 -9
- package/src/config/bundled-skills/messaging/SKILL.md +10 -18
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +40 -33
- package/src/config/bundled-skills/outlook/SKILL.md +189 -0
- package/src/config/bundled-skills/outlook/TOOLS.json +530 -0
- package/src/config/bundled-skills/outlook/tools/outlook-attachments.ts +85 -0
- package/src/config/bundled-skills/outlook/tools/outlook-categories.ts +77 -0
- package/src/config/bundled-skills/outlook/tools/outlook-draft.ts +84 -0
- package/src/config/bundled-skills/outlook/tools/outlook-follow-up.ts +94 -0
- package/src/config/bundled-skills/outlook/tools/outlook-forward.ts +49 -0
- package/src/config/bundled-skills/outlook/tools/outlook-outreach-scan.ts +237 -0
- package/src/config/bundled-skills/outlook/tools/outlook-rules.ts +161 -0
- package/src/config/bundled-skills/outlook/tools/outlook-send-draft.ts +32 -0
- package/src/config/bundled-skills/outlook/tools/outlook-sender-digest.ts +272 -0
- package/src/config/bundled-skills/outlook/tools/outlook-trash.ts +29 -0
- package/src/config/bundled-skills/outlook/tools/outlook-unsubscribe.ts +129 -0
- package/src/config/bundled-skills/outlook/tools/outlook-vacation.ts +87 -0
- package/src/config/bundled-skills/outlook/tools/shared.ts +20 -0
- package/src/config/bundled-skills/outlook-calendar/SKILL.md +51 -0
- package/src/config/bundled-skills/outlook-calendar/TOOLS.json +221 -0
- package/src/config/bundled-skills/outlook-calendar/calendar-client.ts +252 -0
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-check-availability.ts +53 -0
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-create-event.ts +74 -0
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-get-event.ts +18 -0
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-list-events.ts +46 -0
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-rsvp.ts +36 -0
- package/src/config/bundled-skills/outlook-calendar/tools/shared.ts +17 -0
- package/src/config/bundled-skills/outlook-calendar/types.ts +120 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +47 -40
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +16 -29
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +16 -18
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +39 -47
- package/src/config/bundled-skills/slack/SKILL.md +1 -7
- package/src/config/bundled-tool-registry.ts +56 -4
- package/src/config/env-registry.ts +15 -8
- package/src/config/feature-flag-registry.json +21 -124
- package/src/config/schemas/platform.ts +8 -0
- package/src/config/schemas/timeouts.ts +1 -1
- package/src/config/skills.ts +18 -7
- package/src/context/token-estimator.ts +25 -18
- package/src/context/window-manager.ts +6 -2
- package/src/credential-execution/process-manager.ts +3 -1
- package/src/daemon/context-overflow-reducer.ts +46 -2
- package/src/daemon/conversation-agent-loop-handlers.ts +123 -82
- package/src/daemon/conversation-agent-loop.ts +96 -61
- package/src/daemon/conversation-error.ts +31 -8
- package/src/daemon/conversation-lifecycle.ts +33 -0
- package/src/daemon/conversation-media-retry.ts +85 -7
- package/src/daemon/conversation-notifiers.ts +4 -1
- package/src/daemon/conversation-runtime-assembly.ts +5 -0
- package/src/daemon/conversation.ts +41 -2
- package/src/daemon/daemon-control.ts +8 -2
- package/src/daemon/handlers/shared.ts +22 -12
- package/src/daemon/handlers/skills.ts +416 -202
- package/src/daemon/lifecycle.ts +40 -1
- package/src/daemon/main.ts +5 -1
- package/src/daemon/message-types/conversations.ts +4 -1
- package/src/daemon/message-types/messages.ts +3 -1
- package/src/daemon/message-types/skills.ts +97 -36
- package/src/daemon/providers-setup.ts +5 -0
- package/src/daemon/server.ts +11 -2
- package/src/daemon/tool-side-effects.ts +27 -5
- package/src/heartbeat/heartbeat-service.ts +1 -0
- package/src/hooks/cli.ts +2 -2
- package/src/hooks/runner.ts +15 -38
- package/src/inbound/platform-callback-registration.ts +14 -14
- package/src/memory/admin.ts +11 -45
- package/src/memory/conversation-bootstrap.ts +2 -0
- package/src/memory/conversation-crud.ts +242 -348
- package/src/memory/conversation-group-migration.ts +157 -0
- package/src/memory/conversation-queries.ts +4 -2
- package/src/memory/db-init.ts +30 -3
- package/src/memory/embed.ts +73 -0
- package/src/memory/embedding-backend.ts +8 -14
- package/src/memory/embedding-runtime-manager.ts +12 -114
- package/src/memory/fingerprint.ts +2 -2
- package/src/memory/graph/bootstrap.ts +512 -0
- package/src/memory/graph/capability-seed.ts +297 -0
- package/src/memory/graph/consolidation.ts +691 -0
- package/src/memory/graph/conversation-graph-memory.ts +630 -0
- package/src/memory/graph/decay.test.ts +208 -0
- package/src/memory/graph/decay.ts +195 -0
- package/src/memory/graph/extraction-job.ts +69 -0
- package/src/memory/graph/extraction.test.ts +936 -0
- package/src/memory/graph/extraction.ts +1254 -0
- package/src/memory/graph/graph-search.ts +266 -0
- package/src/memory/graph/image-ref-utils.ts +29 -0
- package/src/memory/graph/injection.test.ts +513 -0
- package/src/memory/graph/injection.ts +439 -0
- package/src/memory/graph/inspect.ts +534 -0
- package/src/memory/graph/narrative.ts +267 -0
- package/src/memory/graph/pattern-scan.ts +269 -0
- package/src/memory/graph/retriever.ts +1008 -0
- package/src/memory/graph/scoring.test.ts +548 -0
- package/src/memory/graph/scoring.ts +232 -0
- package/src/memory/graph/serendipity.ts +65 -0
- package/src/memory/graph/store.test.ts +1050 -0
- package/src/memory/graph/store.ts +699 -0
- package/src/memory/graph/tool-handlers.ts +426 -0
- package/src/memory/graph/tools.ts +141 -0
- package/src/memory/graph/triggers.test.ts +487 -0
- package/src/memory/graph/triggers.ts +223 -0
- package/src/memory/graph/types.ts +271 -0
- package/src/memory/group-crud.ts +191 -0
- package/src/memory/indexer.ts +37 -19
- package/src/memory/job-handlers/cleanup.ts +0 -53
- package/src/memory/job-handlers/conversation-starters.ts +91 -53
- package/src/memory/job-handlers/embedding.ts +5 -31
- package/src/memory/job-handlers/index-maintenance.ts +23 -11
- package/src/memory/job-handlers/summarization.ts +32 -17
- package/src/memory/job-utils.ts +1 -1
- package/src/memory/jobs-store.ts +50 -70
- package/src/memory/jobs-worker.ts +147 -112
- package/src/memory/message-content.ts +1 -0
- package/src/memory/migrations/202-memory-graph-tables.ts +130 -0
- package/src/memory/migrations/203-drop-memory-items-tables.ts +23 -0
- package/src/memory/migrations/204-rename-memory-graph-type-values.ts +46 -0
- package/src/memory/migrations/205-memory-graph-image-refs.ts +11 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/qdrant-client.ts +44 -17
- package/src/memory/schema/index.ts +1 -0
- package/src/memory/schema/memory-graph.ts +139 -0
- package/src/memory/search/semantic.ts +47 -91
- package/src/memory/task-memory-cleanup.ts +28 -50
- package/src/messaging/providers/outlook/adapter.ts +8 -1
- package/src/messaging/providers/outlook/client.ts +299 -0
- package/src/messaging/providers/outlook/types.ts +118 -0
- package/src/notifications/adapters/macos.ts +1 -0
- package/src/notifications/copy-composer.ts +9 -0
- package/src/notifications/signal.ts +16 -0
- package/src/oauth/seed-providers.ts +2 -1
- package/src/permissions/checker.ts +24 -3
- package/src/permissions/defaults.ts +4 -4
- package/src/permissions/workspace-policy.ts +1 -1
- package/src/playbooks/playbook-compiler.ts +19 -18
- package/src/playbooks/types.ts +4 -3
- package/src/prompts/system-prompt.ts +3 -29
- package/src/providers/anthropic/client.ts +47 -19
- package/src/providers/gemini/client.ts +1 -1
- package/src/providers/openai/client.ts +1 -1
- package/src/providers/registry.ts +1 -1
- package/src/providers/retry.ts +19 -3
- package/src/runtime/actor-trust-resolver.ts +5 -1
- package/src/runtime/auth/route-policy.ts +7 -0
- package/src/runtime/guardian-reply-router.ts +5 -1
- package/src/runtime/http-server.ts +23 -3
- package/src/runtime/middleware/auth.ts +20 -0
- package/src/runtime/routes/attachment-routes.test.ts +106 -0
- package/src/runtime/routes/attachment-routes.ts +106 -16
- package/src/runtime/routes/brain-graph-routes.ts +21 -22
- package/src/runtime/routes/btw-routes.ts +8 -0
- package/src/runtime/routes/conversation-management-routes.ts +2 -0
- package/src/runtime/routes/conversation-starter-routes.ts +2 -2
- package/src/runtime/routes/debug-routes.ts +1 -1
- package/src/runtime/routes/global-search-routes.ts +21 -19
- package/src/runtime/routes/group-routes.ts +207 -0
- package/src/runtime/routes/guardian-action-routes.ts +21 -10
- package/src/runtime/routes/guardian-bootstrap-routes.ts +23 -19
- package/src/runtime/routes/inbound-message-handler.ts +19 -0
- package/src/runtime/routes/inbound-stages/guardian-activation-intercept.test.ts +292 -0
- package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +207 -0
- package/src/runtime/routes/memory-item-routes.test.ts +2 -14
- package/src/runtime/routes/memory-item-routes.ts +341 -388
- package/src/runtime/routes/schedule-routes.ts +2 -0
- package/src/runtime/routes/skills-routes.ts +103 -37
- package/src/runtime/routes/work-items-routes.test.ts +2 -6
- package/src/schedule/scheduler.ts +8 -1
- package/src/security/oauth2.ts +1 -1
- package/src/security/secure-keys.ts +4 -8
- package/src/shared/provider-env-vars.ts +19 -0
- package/src/skills/catalog-cache.ts +5 -0
- package/src/skills/catalog-install.ts +15 -14
- package/src/skills/clawhub.ts +134 -154
- package/src/skills/install-meta.ts +208 -0
- package/src/skills/managed-store.ts +27 -16
- package/src/skills/skill-memory.ts +152 -77
- package/src/skills/skillssh-registry.ts +19 -17
- package/src/tasks/task-runner.ts +3 -1
- package/src/telemetry/usage-telemetry-reporter.test.ts +3 -5
- package/src/tools/browser/runtime-check.ts +3 -1
- package/src/tools/memory/register.ts +63 -46
- package/src/tools/permission-checker.ts +7 -1
- package/src/tools/shared/filesystem/image-read.ts +22 -85
- package/src/tools/terminal/safe-env.ts +1 -0
- package/src/tools/tool-manifest.ts +3 -3
- package/src/util/browser.ts +25 -10
- package/src/util/bun-runtime.ts +172 -0
- package/src/watcher/providers/outlook-calendar.ts +343 -0
- package/src/watcher/providers/outlook.ts +198 -0
- package/src/workspace/migrations/025-remove-oauth-app-setup-skills.ts +76 -0
- package/src/workspace/migrations/026-backfill-install-meta.ts +325 -0
- package/src/workspace/migrations/027-remove-orphaned-optimized-images-cache.ts +42 -0
- package/src/workspace/migrations/registry.ts +6 -0
- package/src/__tests__/context-memory-e2e.test.ts +0 -415
- package/src/__tests__/journal-context.test.ts +0 -268
- package/src/__tests__/memory-context-benchmark.benchmark.test.ts +0 -297
- package/src/__tests__/memory-lifecycle-e2e.test.ts +0 -459
- package/src/__tests__/memory-query-builder.test.ts +0 -59
- package/src/__tests__/memory-recall-quality.test.ts +0 -1046
- package/src/__tests__/memory-regressions.experimental.test.ts +0 -629
- package/src/__tests__/memory-regressions.test.ts +0 -3696
- package/src/__tests__/memory-retrieval.benchmark.test.ts +0 -295
- package/src/daemon/conversation-memory.ts +0 -207
- package/src/memory/conversation-starters-cadence.ts +0 -74
- package/src/memory/items-extractor.ts +0 -860
- package/src/memory/job-handlers/batch-extraction.ts +0 -753
- package/src/memory/job-handlers/extraction.ts +0 -40
- package/src/memory/job-handlers/journal-carry-forward.test.ts +0 -355
- package/src/memory/job-handlers/journal-carry-forward.ts +0 -255
- package/src/memory/journal-memory.ts +0 -224
- package/src/memory/query-builder.ts +0 -47
- package/src/memory/query-expansion.ts +0 -83
- package/src/memory/retriever.test.ts +0 -1592
- package/src/memory/retriever.ts +0 -1331
- package/src/memory/search/formatting.test.ts +0 -140
- package/src/memory/search/formatting.ts +0 -262
- package/src/memory/search/mmr.ts +0 -139
- package/src/memory/search/ranking.ts +0 -15
- package/src/memory/search/staleness.ts +0 -40
- package/src/memory/search/tier-classifier.ts +0 -18
- package/src/memory/search/types.ts +0 -121
- package/src/prompts/journal-context.ts +0 -154
- package/src/tools/memory/definitions.ts +0 -69
- package/src/tools/memory/handlers.test.ts +0 -562
- package/src/tools/memory/handlers.ts +0 -434
|
@@ -1,1592 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the memory retrieval pipeline.
|
|
3
|
-
*
|
|
4
|
-
* Covers: hybrid search → tier classification → staleness → injection,
|
|
5
|
-
* empty results → no injection, superseded items filtered out,
|
|
6
|
-
* staleness demotion, budget allocation, and degradation scenarios.
|
|
7
|
-
*/
|
|
8
|
-
import {
|
|
9
|
-
afterAll,
|
|
10
|
-
beforeAll,
|
|
11
|
-
beforeEach,
|
|
12
|
-
describe,
|
|
13
|
-
expect,
|
|
14
|
-
mock,
|
|
15
|
-
test,
|
|
16
|
-
} from "bun:test";
|
|
17
|
-
|
|
18
|
-
mock.module("../util/logger.js", () => ({
|
|
19
|
-
getLogger: () =>
|
|
20
|
-
new Proxy({} as Record<string, unknown>, {
|
|
21
|
-
get: () => () => {},
|
|
22
|
-
}),
|
|
23
|
-
}));
|
|
24
|
-
|
|
25
|
-
// Stub local embedding backend to avoid loading ONNX runtime.
|
|
26
|
-
mock.module("../memory/embedding-local.js", () => ({
|
|
27
|
-
LocalEmbeddingBackend: class {
|
|
28
|
-
readonly provider = "local" as const;
|
|
29
|
-
readonly model: string;
|
|
30
|
-
constructor(model: string) {
|
|
31
|
-
this.model = model;
|
|
32
|
-
}
|
|
33
|
-
async embed(texts: string[]): Promise<number[][]> {
|
|
34
|
-
return texts.map(() => new Array(384).fill(0));
|
|
35
|
-
}
|
|
36
|
-
},
|
|
37
|
-
}));
|
|
38
|
-
|
|
39
|
-
// Mock Qdrant client so semantic search returns empty results by default.
|
|
40
|
-
// Tests can push entries into `mockQdrantResults` to simulate Qdrant returning
|
|
41
|
-
// specific hits (e.g. item candidates).
|
|
42
|
-
const mockQdrantResults: Array<{
|
|
43
|
-
id: string;
|
|
44
|
-
score: number;
|
|
45
|
-
payload: Record<string, unknown>;
|
|
46
|
-
}> = [];
|
|
47
|
-
|
|
48
|
-
mock.module("../memory/qdrant-client.js", () => ({
|
|
49
|
-
getQdrantClient: () => ({
|
|
50
|
-
searchWithFilter: async () => [...mockQdrantResults],
|
|
51
|
-
hybridSearch: async () => [...mockQdrantResults],
|
|
52
|
-
upsertPoints: async () => {},
|
|
53
|
-
deletePoints: async () => {},
|
|
54
|
-
}),
|
|
55
|
-
initQdrantClient: () => {},
|
|
56
|
-
}));
|
|
57
|
-
|
|
58
|
-
import { DEFAULT_CONFIG } from "../config/defaults.js";
|
|
59
|
-
import type { AssistantConfig } from "../config/types.js";
|
|
60
|
-
|
|
61
|
-
const TEST_CONFIG: AssistantConfig = {
|
|
62
|
-
...DEFAULT_CONFIG,
|
|
63
|
-
memory: {
|
|
64
|
-
...DEFAULT_CONFIG.memory,
|
|
65
|
-
extraction: {
|
|
66
|
-
...DEFAULT_CONFIG.memory.extraction,
|
|
67
|
-
useLLM: false,
|
|
68
|
-
},
|
|
69
|
-
embeddings: {
|
|
70
|
-
...DEFAULT_CONFIG.memory.embeddings,
|
|
71
|
-
required: false,
|
|
72
|
-
},
|
|
73
|
-
},
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
mock.module("../config/loader.js", () => ({
|
|
77
|
-
loadConfig: () => TEST_CONFIG,
|
|
78
|
-
getConfig: () => TEST_CONFIG,
|
|
79
|
-
invalidateConfigCache: () => {},
|
|
80
|
-
}));
|
|
81
|
-
|
|
82
|
-
import { getDb, initializeDb, resetDb } from "../memory/db.js";
|
|
83
|
-
import { clearEmbeddingBackendCache } from "../memory/embedding-backend.js";
|
|
84
|
-
import {
|
|
85
|
-
_resetQdrantBreaker,
|
|
86
|
-
isQdrantBreakerOpen,
|
|
87
|
-
} from "../memory/qdrant-circuit-breaker.js";
|
|
88
|
-
import {
|
|
89
|
-
buildMemoryRecall,
|
|
90
|
-
injectMemoryRecallAsUserBlock,
|
|
91
|
-
} from "../memory/retriever.js";
|
|
92
|
-
import {
|
|
93
|
-
conversations,
|
|
94
|
-
memoryItems,
|
|
95
|
-
memoryItemSources,
|
|
96
|
-
messages,
|
|
97
|
-
} from "../memory/schema.js";
|
|
98
|
-
import type { ContentBlock, Message } from "../providers/types.js";
|
|
99
|
-
|
|
100
|
-
// ---------------------------------------------------------------------------
|
|
101
|
-
// Helpers
|
|
102
|
-
// ---------------------------------------------------------------------------
|
|
103
|
-
|
|
104
|
-
/** Extract text from a content block, asserting it is a text block. */
|
|
105
|
-
function textOf(block: ContentBlock): string {
|
|
106
|
-
if (block.type !== "text")
|
|
107
|
-
throw new Error(`Expected text block, got ${block.type}`);
|
|
108
|
-
return block.text;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function insertConversation(
|
|
112
|
-
db: ReturnType<typeof getDb>,
|
|
113
|
-
id: string,
|
|
114
|
-
createdAt: number,
|
|
115
|
-
opts?: { contextCompactedMessageCount?: number },
|
|
116
|
-
) {
|
|
117
|
-
db.insert(conversations)
|
|
118
|
-
.values({
|
|
119
|
-
id,
|
|
120
|
-
title: null,
|
|
121
|
-
createdAt,
|
|
122
|
-
updatedAt: createdAt,
|
|
123
|
-
totalInputTokens: 0,
|
|
124
|
-
totalOutputTokens: 0,
|
|
125
|
-
totalEstimatedCost: 0,
|
|
126
|
-
contextSummary: null,
|
|
127
|
-
contextCompactedMessageCount: opts?.contextCompactedMessageCount ?? 0,
|
|
128
|
-
contextCompactedAt: null,
|
|
129
|
-
})
|
|
130
|
-
.run();
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function insertMessage(
|
|
134
|
-
db: ReturnType<typeof getDb>,
|
|
135
|
-
id: string,
|
|
136
|
-
conversationId: string,
|
|
137
|
-
role: string,
|
|
138
|
-
text: string,
|
|
139
|
-
createdAt: number,
|
|
140
|
-
opts?: { metadata?: string | null },
|
|
141
|
-
) {
|
|
142
|
-
db.insert(messages)
|
|
143
|
-
.values({
|
|
144
|
-
id,
|
|
145
|
-
conversationId,
|
|
146
|
-
role,
|
|
147
|
-
content: JSON.stringify([{ type: "text", text }]),
|
|
148
|
-
createdAt,
|
|
149
|
-
metadata: opts?.metadata ?? null,
|
|
150
|
-
})
|
|
151
|
-
.run();
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function insertSegment(
|
|
155
|
-
db: ReturnType<typeof getDb>,
|
|
156
|
-
id: string,
|
|
157
|
-
messageId: string,
|
|
158
|
-
conversationId: string,
|
|
159
|
-
role: string,
|
|
160
|
-
text: string,
|
|
161
|
-
createdAt: number,
|
|
162
|
-
) {
|
|
163
|
-
db.run(`
|
|
164
|
-
INSERT INTO memory_segments (
|
|
165
|
-
id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
|
|
166
|
-
) VALUES (
|
|
167
|
-
'${id}', '${messageId}', '${conversationId}', '${role}', 0, '${text.replace(
|
|
168
|
-
/'/g,
|
|
169
|
-
"''",
|
|
170
|
-
)}', ${Math.ceil(text.split(/\s+/).length * 1.3)}, ${createdAt}, ${createdAt}
|
|
171
|
-
)
|
|
172
|
-
`);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function insertItem(
|
|
176
|
-
db: ReturnType<typeof getDb>,
|
|
177
|
-
opts: {
|
|
178
|
-
id: string;
|
|
179
|
-
kind: string;
|
|
180
|
-
subject: string;
|
|
181
|
-
statement: string;
|
|
182
|
-
status?: string;
|
|
183
|
-
confidence?: number;
|
|
184
|
-
importance?: number;
|
|
185
|
-
firstSeenAt: number;
|
|
186
|
-
lastSeenAt?: number;
|
|
187
|
-
},
|
|
188
|
-
) {
|
|
189
|
-
db.insert(memoryItems)
|
|
190
|
-
.values({
|
|
191
|
-
id: opts.id,
|
|
192
|
-
kind: opts.kind,
|
|
193
|
-
subject: opts.subject,
|
|
194
|
-
statement: opts.statement,
|
|
195
|
-
status: opts.status ?? "active",
|
|
196
|
-
confidence: opts.confidence ?? 0.8,
|
|
197
|
-
importance: opts.importance ?? 0.6,
|
|
198
|
-
accessCount: 0,
|
|
199
|
-
fingerprint: `fp-${opts.id}`,
|
|
200
|
-
firstSeenAt: opts.firstSeenAt,
|
|
201
|
-
lastSeenAt: opts.lastSeenAt ?? opts.firstSeenAt,
|
|
202
|
-
lastUsedAt: null,
|
|
203
|
-
})
|
|
204
|
-
.run();
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function insertItemSource(
|
|
208
|
-
db: ReturnType<typeof getDb>,
|
|
209
|
-
itemId: string,
|
|
210
|
-
messageId: string,
|
|
211
|
-
createdAt: number,
|
|
212
|
-
) {
|
|
213
|
-
db.insert(memoryItemSources)
|
|
214
|
-
.values({
|
|
215
|
-
memoryItemId: itemId,
|
|
216
|
-
messageId,
|
|
217
|
-
evidence: `evidence for ${itemId}`,
|
|
218
|
-
createdAt,
|
|
219
|
-
})
|
|
220
|
-
.run();
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** Seed the database with some searchable memory content. */
|
|
224
|
-
function seedMemory() {
|
|
225
|
-
const db = getDb();
|
|
226
|
-
const now = Date.now();
|
|
227
|
-
const convId = "conv-test";
|
|
228
|
-
|
|
229
|
-
insertConversation(db, convId, now - 60_000);
|
|
230
|
-
insertMessage(
|
|
231
|
-
db,
|
|
232
|
-
"msg-1",
|
|
233
|
-
convId,
|
|
234
|
-
"user",
|
|
235
|
-
"discuss API design",
|
|
236
|
-
now - 50_000,
|
|
237
|
-
);
|
|
238
|
-
insertMessage(
|
|
239
|
-
db,
|
|
240
|
-
"msg-2",
|
|
241
|
-
convId,
|
|
242
|
-
"assistant",
|
|
243
|
-
"The API design uses REST endpoints",
|
|
244
|
-
now - 40_000,
|
|
245
|
-
);
|
|
246
|
-
|
|
247
|
-
insertSegment(
|
|
248
|
-
db,
|
|
249
|
-
"seg-1",
|
|
250
|
-
"msg-1",
|
|
251
|
-
convId,
|
|
252
|
-
"user",
|
|
253
|
-
"discuss API design patterns",
|
|
254
|
-
now - 50_000,
|
|
255
|
-
);
|
|
256
|
-
insertSegment(
|
|
257
|
-
db,
|
|
258
|
-
"seg-2",
|
|
259
|
-
"msg-2",
|
|
260
|
-
convId,
|
|
261
|
-
"assistant",
|
|
262
|
-
"The API design uses REST endpoints with JSON responses",
|
|
263
|
-
now - 40_000,
|
|
264
|
-
);
|
|
265
|
-
|
|
266
|
-
insertItem(db, {
|
|
267
|
-
id: "item-1",
|
|
268
|
-
kind: "preference",
|
|
269
|
-
subject: "API design",
|
|
270
|
-
statement: "User prefers REST over GraphQL for API design",
|
|
271
|
-
firstSeenAt: now - 30_000,
|
|
272
|
-
});
|
|
273
|
-
insertItemSource(db, "item-1", "msg-1", now - 30_000);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// ---------------------------------------------------------------------------
|
|
277
|
-
// Suite
|
|
278
|
-
// ---------------------------------------------------------------------------
|
|
279
|
-
|
|
280
|
-
describe("Memory Retriever Pipeline", () => {
|
|
281
|
-
beforeAll(() => {
|
|
282
|
-
initializeDb();
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
beforeEach(() => {
|
|
286
|
-
const db = getDb();
|
|
287
|
-
db.run("DELETE FROM memory_item_sources");
|
|
288
|
-
db.run("DELETE FROM memory_items");
|
|
289
|
-
db.run("DELETE FROM memory_segments");
|
|
290
|
-
db.run("DELETE FROM messages");
|
|
291
|
-
db.run("DELETE FROM conversations");
|
|
292
|
-
_resetQdrantBreaker();
|
|
293
|
-
clearEmbeddingBackendCache();
|
|
294
|
-
mockQdrantResults.length = 0;
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
afterAll(() => {
|
|
298
|
-
resetDb();
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
// -----------------------------------------------------------------------
|
|
302
|
-
// Hybrid search → tier classification → injection
|
|
303
|
-
// -----------------------------------------------------------------------
|
|
304
|
-
|
|
305
|
-
test("baseline: pipeline completes non-degraded with mock Qdrant returning empty", async () => {
|
|
306
|
-
seedMemory();
|
|
307
|
-
|
|
308
|
-
const result = await buildMemoryRecall(
|
|
309
|
-
"API design",
|
|
310
|
-
"conv-test",
|
|
311
|
-
TEST_CONFIG,
|
|
312
|
-
);
|
|
313
|
-
|
|
314
|
-
expect(result.enabled).toBe(true);
|
|
315
|
-
expect(result.degraded).toBe(false);
|
|
316
|
-
expect(result.degradation).toBeUndefined();
|
|
317
|
-
// With Qdrant mocked empty, no candidates are found.
|
|
318
|
-
// The pipeline still completes successfully with tier metadata.
|
|
319
|
-
expect(result.tier1Count).toBeDefined();
|
|
320
|
-
expect(result.tier2Count).toBeDefined();
|
|
321
|
-
expect(result.hybridSearchMs).toBeDefined();
|
|
322
|
-
// Without semantic search, no candidates are found.
|
|
323
|
-
expect(result.mergedCount).toBe(0);
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
// -----------------------------------------------------------------------
|
|
327
|
-
// Current-conversation segment filtering
|
|
328
|
-
// -----------------------------------------------------------------------
|
|
329
|
-
|
|
330
|
-
test("current-conversation segments are filtered from search results", async () => {
|
|
331
|
-
const db = getDb();
|
|
332
|
-
const now = Date.now();
|
|
333
|
-
const activeConv = "conv-active";
|
|
334
|
-
const otherConv = "conv-other";
|
|
335
|
-
|
|
336
|
-
insertConversation(db, activeConv, now - 60_000);
|
|
337
|
-
insertConversation(db, otherConv, now - 120_000);
|
|
338
|
-
|
|
339
|
-
// Messages and segments in the active conversation (should be filtered)
|
|
340
|
-
insertMessage(
|
|
341
|
-
db,
|
|
342
|
-
"msg-a1",
|
|
343
|
-
activeConv,
|
|
344
|
-
"user",
|
|
345
|
-
"hello world",
|
|
346
|
-
now - 50_000,
|
|
347
|
-
);
|
|
348
|
-
insertSegment(
|
|
349
|
-
db,
|
|
350
|
-
"seg-a1",
|
|
351
|
-
"msg-a1",
|
|
352
|
-
activeConv,
|
|
353
|
-
"user",
|
|
354
|
-
"hello world",
|
|
355
|
-
now - 50_000,
|
|
356
|
-
);
|
|
357
|
-
|
|
358
|
-
// Messages and segments in a different conversation (should be kept)
|
|
359
|
-
insertMessage(
|
|
360
|
-
db,
|
|
361
|
-
"msg-o1",
|
|
362
|
-
otherConv,
|
|
363
|
-
"user",
|
|
364
|
-
"hello world from other",
|
|
365
|
-
now - 100_000,
|
|
366
|
-
);
|
|
367
|
-
insertSegment(
|
|
368
|
-
db,
|
|
369
|
-
"seg-o1",
|
|
370
|
-
"msg-o1",
|
|
371
|
-
otherConv,
|
|
372
|
-
"user",
|
|
373
|
-
"hello world from other",
|
|
374
|
-
now - 100_000,
|
|
375
|
-
);
|
|
376
|
-
|
|
377
|
-
// Query from the active conversation
|
|
378
|
-
const result = await buildMemoryRecall(
|
|
379
|
-
"hello world",
|
|
380
|
-
activeConv,
|
|
381
|
-
TEST_CONFIG,
|
|
382
|
-
);
|
|
383
|
-
|
|
384
|
-
expect(result.enabled).toBe(true);
|
|
385
|
-
// Without semantic search, no candidates are found.
|
|
386
|
-
expect(result.mergedCount).toBe(0);
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
test("compacted segments from current conversation are preserved in memory", async () => {
|
|
390
|
-
const db = getDb();
|
|
391
|
-
const now = Date.now();
|
|
392
|
-
const convId = "conv-compacted";
|
|
393
|
-
|
|
394
|
-
// Create a conversation where 2 messages have been compacted away
|
|
395
|
-
insertConversation(db, convId, now - 120_000, {
|
|
396
|
-
contextCompactedMessageCount: 2,
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
// Older messages (compacted out of context window) — their segments
|
|
400
|
-
// should NOT be filtered because the model can no longer see them
|
|
401
|
-
insertMessage(
|
|
402
|
-
db,
|
|
403
|
-
"msg-old-1",
|
|
404
|
-
convId,
|
|
405
|
-
"user",
|
|
406
|
-
"old discussion topic",
|
|
407
|
-
now - 100_000,
|
|
408
|
-
);
|
|
409
|
-
insertMessage(
|
|
410
|
-
db,
|
|
411
|
-
"msg-old-2",
|
|
412
|
-
convId,
|
|
413
|
-
"assistant",
|
|
414
|
-
"old response",
|
|
415
|
-
now - 90_000,
|
|
416
|
-
);
|
|
417
|
-
|
|
418
|
-
// Newer messages (still in context window) — their segments should
|
|
419
|
-
// be filtered since the model can still see them
|
|
420
|
-
insertMessage(
|
|
421
|
-
db,
|
|
422
|
-
"msg-new-1",
|
|
423
|
-
convId,
|
|
424
|
-
"user",
|
|
425
|
-
"recent discussion",
|
|
426
|
-
now - 50_000,
|
|
427
|
-
);
|
|
428
|
-
insertMessage(
|
|
429
|
-
db,
|
|
430
|
-
"msg-new-2",
|
|
431
|
-
convId,
|
|
432
|
-
"assistant",
|
|
433
|
-
"recent response",
|
|
434
|
-
now - 40_000,
|
|
435
|
-
);
|
|
436
|
-
|
|
437
|
-
// Segments from compacted messages (should survive filtering)
|
|
438
|
-
insertSegment(
|
|
439
|
-
db,
|
|
440
|
-
"seg-old-1",
|
|
441
|
-
"msg-old-1",
|
|
442
|
-
convId,
|
|
443
|
-
"user",
|
|
444
|
-
"old discussion topic details",
|
|
445
|
-
now - 100_000,
|
|
446
|
-
);
|
|
447
|
-
insertSegment(
|
|
448
|
-
db,
|
|
449
|
-
"seg-old-2",
|
|
450
|
-
"msg-old-2",
|
|
451
|
-
convId,
|
|
452
|
-
"assistant",
|
|
453
|
-
"old response details",
|
|
454
|
-
now - 90_000,
|
|
455
|
-
);
|
|
456
|
-
|
|
457
|
-
// Segments from in-context messages (should be filtered)
|
|
458
|
-
insertSegment(
|
|
459
|
-
db,
|
|
460
|
-
"seg-new-1",
|
|
461
|
-
"msg-new-1",
|
|
462
|
-
convId,
|
|
463
|
-
"user",
|
|
464
|
-
"recent discussion details",
|
|
465
|
-
now - 50_000,
|
|
466
|
-
);
|
|
467
|
-
insertSegment(
|
|
468
|
-
db,
|
|
469
|
-
"seg-new-2",
|
|
470
|
-
"msg-new-2",
|
|
471
|
-
convId,
|
|
472
|
-
"assistant",
|
|
473
|
-
"recent response details",
|
|
474
|
-
now - 40_000,
|
|
475
|
-
);
|
|
476
|
-
|
|
477
|
-
const result = await buildMemoryRecall(
|
|
478
|
-
"discussion topic",
|
|
479
|
-
convId,
|
|
480
|
-
TEST_CONFIG,
|
|
481
|
-
);
|
|
482
|
-
|
|
483
|
-
expect(result.enabled).toBe(true);
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
// -----------------------------------------------------------------------
|
|
487
|
-
// Empty results → no injection
|
|
488
|
-
// -----------------------------------------------------------------------
|
|
489
|
-
|
|
490
|
-
test("empty results: no injection when no memory content exists", async () => {
|
|
491
|
-
// Don't seed any memory
|
|
492
|
-
const result = await buildMemoryRecall(
|
|
493
|
-
"nonexistent topic",
|
|
494
|
-
"conv-empty",
|
|
495
|
-
TEST_CONFIG,
|
|
496
|
-
);
|
|
497
|
-
|
|
498
|
-
expect(result.enabled).toBe(true);
|
|
499
|
-
expect(result.selectedCount).toBe(0);
|
|
500
|
-
expect(result.injectedText).toBe("");
|
|
501
|
-
expect(result.mergedCount).toBe(0);
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
// -----------------------------------------------------------------------
|
|
505
|
-
// Memory disabled
|
|
506
|
-
// -----------------------------------------------------------------------
|
|
507
|
-
|
|
508
|
-
test("disabled: returns enabled=false when memory is disabled", async () => {
|
|
509
|
-
const disabledConfig: AssistantConfig = {
|
|
510
|
-
...TEST_CONFIG,
|
|
511
|
-
memory: {
|
|
512
|
-
...TEST_CONFIG.memory,
|
|
513
|
-
enabled: false,
|
|
514
|
-
},
|
|
515
|
-
};
|
|
516
|
-
|
|
517
|
-
const result = await buildMemoryRecall(
|
|
518
|
-
"test query",
|
|
519
|
-
"conv-test",
|
|
520
|
-
disabledConfig,
|
|
521
|
-
);
|
|
522
|
-
|
|
523
|
-
expect(result.enabled).toBe(false);
|
|
524
|
-
expect(result.reason).toBe("memory.disabled");
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
// -----------------------------------------------------------------------
|
|
528
|
-
// Superseded items filtered out
|
|
529
|
-
// -----------------------------------------------------------------------
|
|
530
|
-
|
|
531
|
-
test("superseded items are not included in results", async () => {
|
|
532
|
-
const db = getDb();
|
|
533
|
-
const now = Date.now();
|
|
534
|
-
const convId = "conv-superseded";
|
|
535
|
-
|
|
536
|
-
insertConversation(db, convId, now - 60_000);
|
|
537
|
-
insertMessage(
|
|
538
|
-
db,
|
|
539
|
-
"msg-s1",
|
|
540
|
-
convId,
|
|
541
|
-
"user",
|
|
542
|
-
"test superseded",
|
|
543
|
-
now - 50_000,
|
|
544
|
-
);
|
|
545
|
-
|
|
546
|
-
insertSegment(
|
|
547
|
-
db,
|
|
548
|
-
"seg-s1",
|
|
549
|
-
"msg-s1",
|
|
550
|
-
convId,
|
|
551
|
-
"user",
|
|
552
|
-
"test superseded content",
|
|
553
|
-
now - 50_000,
|
|
554
|
-
);
|
|
555
|
-
|
|
556
|
-
// Insert an active item and a superseded item
|
|
557
|
-
insertItem(db, {
|
|
558
|
-
id: "item-active",
|
|
559
|
-
kind: "fact",
|
|
560
|
-
subject: "test",
|
|
561
|
-
statement: "Active fact about testing",
|
|
562
|
-
status: "active",
|
|
563
|
-
firstSeenAt: now - 30_000,
|
|
564
|
-
});
|
|
565
|
-
insertItem(db, {
|
|
566
|
-
id: "item-superseded",
|
|
567
|
-
kind: "fact",
|
|
568
|
-
subject: "test",
|
|
569
|
-
statement: "Old fact that was superseded",
|
|
570
|
-
status: "superseded",
|
|
571
|
-
firstSeenAt: now - 30_000,
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
const result = await buildMemoryRecall(
|
|
575
|
-
"test superseded",
|
|
576
|
-
convId,
|
|
577
|
-
TEST_CONFIG,
|
|
578
|
-
);
|
|
579
|
-
|
|
580
|
-
// The injected text should not contain the superseded item statement
|
|
581
|
-
if (result.injectedText.length > 0) {
|
|
582
|
-
expect(result.injectedText).not.toContain("Old fact that was superseded");
|
|
583
|
-
}
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
// -----------------------------------------------------------------------
|
|
587
|
-
// Staleness demotion (very_stale tier 1 → tier 2)
|
|
588
|
-
// -----------------------------------------------------------------------
|
|
589
|
-
|
|
590
|
-
test("staleness: very old items get demoted from tier 1 to tier 2", async () => {
|
|
591
|
-
const db = getDb();
|
|
592
|
-
const now = Date.now();
|
|
593
|
-
const convId = "conv-stale";
|
|
594
|
-
const MS_PER_DAY = 86_400_000;
|
|
595
|
-
|
|
596
|
-
insertConversation(db, convId, now - MS_PER_DAY * 200);
|
|
597
|
-
|
|
598
|
-
// Create a message from 200 days ago (staleness test anchor)
|
|
599
|
-
insertMessage(
|
|
600
|
-
db,
|
|
601
|
-
"msg-old",
|
|
602
|
-
convId,
|
|
603
|
-
"user",
|
|
604
|
-
"ancient discussion about TypeScript",
|
|
605
|
-
now - MS_PER_DAY * 200,
|
|
606
|
-
);
|
|
607
|
-
insertSegment(
|
|
608
|
-
db,
|
|
609
|
-
"seg-old",
|
|
610
|
-
"msg-old",
|
|
611
|
-
convId,
|
|
612
|
-
"user",
|
|
613
|
-
"ancient discussion about TypeScript patterns",
|
|
614
|
-
now - MS_PER_DAY * 200,
|
|
615
|
-
);
|
|
616
|
-
|
|
617
|
-
// Insert a very old item (200 days) — should be marked as very_stale
|
|
618
|
-
insertItem(db, {
|
|
619
|
-
id: "item-old",
|
|
620
|
-
kind: "fact",
|
|
621
|
-
subject: "TypeScript",
|
|
622
|
-
statement: "User uses TypeScript for all projects",
|
|
623
|
-
firstSeenAt: now - MS_PER_DAY * 200,
|
|
624
|
-
});
|
|
625
|
-
insertItemSource(db, "item-old", "msg-old", now - MS_PER_DAY * 200);
|
|
626
|
-
|
|
627
|
-
const result = await buildMemoryRecall(
|
|
628
|
-
"TypeScript patterns",
|
|
629
|
-
convId,
|
|
630
|
-
TEST_CONFIG,
|
|
631
|
-
);
|
|
632
|
-
|
|
633
|
-
// The pipeline should still return results (just potentially in tier 2)
|
|
634
|
-
expect(result.enabled).toBe(true);
|
|
635
|
-
// Very old items should still appear but may be in tier 2 after demotion
|
|
636
|
-
expect(result.tier1Count).toBeDefined();
|
|
637
|
-
expect(result.tier2Count).toBeDefined();
|
|
638
|
-
});
|
|
639
|
-
|
|
640
|
-
// -----------------------------------------------------------------------
|
|
641
|
-
// Budget allocation (tier 1 priority)
|
|
642
|
-
// -----------------------------------------------------------------------
|
|
643
|
-
|
|
644
|
-
test("budget: respects maxInjectTokens override", async () => {
|
|
645
|
-
seedMemory();
|
|
646
|
-
|
|
647
|
-
// Use a very small token budget
|
|
648
|
-
const result = await buildMemoryRecall(
|
|
649
|
-
"API design",
|
|
650
|
-
"conv-test",
|
|
651
|
-
TEST_CONFIG,
|
|
652
|
-
{ maxInjectTokensOverride: 10 },
|
|
653
|
-
);
|
|
654
|
-
|
|
655
|
-
expect(result.enabled).toBe(true);
|
|
656
|
-
// With a 10-token budget, most content should be truncated
|
|
657
|
-
expect(result.injectedTokens).toBeLessThanOrEqual(10);
|
|
658
|
-
});
|
|
659
|
-
|
|
660
|
-
// -----------------------------------------------------------------------
|
|
661
|
-
// Degradation: Qdrant circuit breaker open
|
|
662
|
-
// -----------------------------------------------------------------------
|
|
663
|
-
|
|
664
|
-
test("Qdrant unavailable: pipeline completes with empty results", async () => {
|
|
665
|
-
seedMemory();
|
|
666
|
-
|
|
667
|
-
// Force the Qdrant circuit breaker open
|
|
668
|
-
const { withQdrantBreaker } =
|
|
669
|
-
await import("../memory/qdrant-circuit-breaker.js");
|
|
670
|
-
for (let i = 0; i < 5; i++) {
|
|
671
|
-
try {
|
|
672
|
-
await withQdrantBreaker(async () => {
|
|
673
|
-
throw new Error("simulated qdrant failure");
|
|
674
|
-
});
|
|
675
|
-
} catch {
|
|
676
|
-
// expected
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
expect(isQdrantBreakerOpen()).toBe(true);
|
|
680
|
-
|
|
681
|
-
const result = await buildMemoryRecall(
|
|
682
|
-
"API design",
|
|
683
|
-
"conv-test",
|
|
684
|
-
TEST_CONFIG,
|
|
685
|
-
);
|
|
686
|
-
|
|
687
|
-
expect(result.enabled).toBe(true);
|
|
688
|
-
// Semantic/hybrid search should be skipped
|
|
689
|
-
expect(result.semanticHits).toBe(0);
|
|
690
|
-
// Without semantic search, no candidates are found.
|
|
691
|
-
expect(result.mergedCount).toBe(0);
|
|
692
|
-
});
|
|
693
|
-
|
|
694
|
-
// -----------------------------------------------------------------------
|
|
695
|
-
// Degradation: embedding provider down
|
|
696
|
-
// -----------------------------------------------------------------------
|
|
697
|
-
|
|
698
|
-
test("embedding provider down: returns degraded when embeddings required", async () => {
|
|
699
|
-
seedMemory();
|
|
700
|
-
|
|
701
|
-
const requiredEmbedConfig: AssistantConfig = {
|
|
702
|
-
...TEST_CONFIG,
|
|
703
|
-
memory: {
|
|
704
|
-
...TEST_CONFIG.memory,
|
|
705
|
-
embeddings: {
|
|
706
|
-
...TEST_CONFIG.memory.embeddings,
|
|
707
|
-
provider: "openai",
|
|
708
|
-
required: true,
|
|
709
|
-
},
|
|
710
|
-
},
|
|
711
|
-
};
|
|
712
|
-
|
|
713
|
-
const result = await buildMemoryRecall(
|
|
714
|
-
"API design",
|
|
715
|
-
"conv-test",
|
|
716
|
-
requiredEmbedConfig,
|
|
717
|
-
);
|
|
718
|
-
|
|
719
|
-
expect(result.enabled).toBe(true);
|
|
720
|
-
expect(result.degraded).toBe(true);
|
|
721
|
-
expect(result.degradation).toBeDefined();
|
|
722
|
-
expect(result.degradation!.semanticUnavailable).toBe(true);
|
|
723
|
-
expect(result.degradation!.reason).toBe("embedding_provider_down");
|
|
724
|
-
expect(result.degradation!.fallbackSources).toEqual([]);
|
|
725
|
-
});
|
|
726
|
-
|
|
727
|
-
// -----------------------------------------------------------------------
|
|
728
|
-
// Signal abort
|
|
729
|
-
// -----------------------------------------------------------------------
|
|
730
|
-
|
|
731
|
-
test("abort: returns early when signal is aborted", async () => {
|
|
732
|
-
seedMemory();
|
|
733
|
-
const controller = new AbortController();
|
|
734
|
-
controller.abort();
|
|
735
|
-
|
|
736
|
-
const result = await buildMemoryRecall(
|
|
737
|
-
"API design",
|
|
738
|
-
"conv-test",
|
|
739
|
-
TEST_CONFIG,
|
|
740
|
-
{ signal: controller.signal },
|
|
741
|
-
);
|
|
742
|
-
|
|
743
|
-
expect(result.enabled).toBe(true);
|
|
744
|
-
expect(result.reason).toBe("memory.aborted");
|
|
745
|
-
expect(result.injectedText).toBe("");
|
|
746
|
-
});
|
|
747
|
-
|
|
748
|
-
// -----------------------------------------------------------------------
|
|
749
|
-
// injectMemoryRecallAsUserBlock
|
|
750
|
-
// -----------------------------------------------------------------------
|
|
751
|
-
|
|
752
|
-
test("injectMemoryRecallAsUserBlock: prepends memory context to last user message", () => {
|
|
753
|
-
const msgs: Message[] = [
|
|
754
|
-
{
|
|
755
|
-
role: "user",
|
|
756
|
-
content: [{ type: "text", text: "Hello" }],
|
|
757
|
-
},
|
|
758
|
-
];
|
|
759
|
-
|
|
760
|
-
const recallText =
|
|
761
|
-
"<memory_context __injected>\n\n<relevant_context>\ntest\n</relevant_context>\n\n</memory_context>";
|
|
762
|
-
const result = injectMemoryRecallAsUserBlock(msgs, recallText);
|
|
763
|
-
|
|
764
|
-
// Same number of messages — no synthetic pair added
|
|
765
|
-
expect(result).toHaveLength(1);
|
|
766
|
-
expect(result[0].role).toBe("user");
|
|
767
|
-
// Memory context prepended as first content block
|
|
768
|
-
expect(result[0].content).toHaveLength(2);
|
|
769
|
-
expect(textOf(result[0].content[0])).toBe(recallText);
|
|
770
|
-
// Original user text preserved as second block
|
|
771
|
-
expect(textOf(result[0].content[1])).toBe("Hello");
|
|
772
|
-
});
|
|
773
|
-
|
|
774
|
-
test("injectMemoryRecallAsUserBlock: no-op for empty text", () => {
|
|
775
|
-
const msgs: Message[] = [
|
|
776
|
-
{
|
|
777
|
-
role: "user",
|
|
778
|
-
content: [{ type: "text", text: "Hello" }],
|
|
779
|
-
},
|
|
780
|
-
];
|
|
781
|
-
|
|
782
|
-
const result = injectMemoryRecallAsUserBlock(msgs, "");
|
|
783
|
-
expect(result).toHaveLength(1);
|
|
784
|
-
expect(textOf(result[0].content[0])).toBe("Hello");
|
|
785
|
-
});
|
|
786
|
-
|
|
787
|
-
test("injectMemoryRecallAsUserBlock: preserves history before last user message", () => {
|
|
788
|
-
const msgs: Message[] = [
|
|
789
|
-
{ role: "user", content: [{ type: "text", text: "First" }] },
|
|
790
|
-
{ role: "assistant", content: [{ type: "text", text: "Response" }] },
|
|
791
|
-
{ role: "user", content: [{ type: "text", text: "Second" }] },
|
|
792
|
-
];
|
|
793
|
-
|
|
794
|
-
const recallText =
|
|
795
|
-
"<memory_context __injected>\n\n<relevant_context>\nfact\n</relevant_context>\n\n</memory_context>";
|
|
796
|
-
const result = injectMemoryRecallAsUserBlock(msgs, recallText);
|
|
797
|
-
|
|
798
|
-
expect(result).toHaveLength(3);
|
|
799
|
-
// Earlier messages unchanged
|
|
800
|
-
expect(result[0]).toBe(msgs[0]);
|
|
801
|
-
expect(result[1]).toBe(msgs[1]);
|
|
802
|
-
// Last user message has memory prepended
|
|
803
|
-
expect(textOf(result[2].content[0])).toBe(recallText);
|
|
804
|
-
expect(textOf(result[2].content[1])).toBe("Second");
|
|
805
|
-
});
|
|
806
|
-
|
|
807
|
-
// -----------------------------------------------------------------------
|
|
808
|
-
// Local embedding stub end-to-end
|
|
809
|
-
// -----------------------------------------------------------------------
|
|
810
|
-
|
|
811
|
-
test("local embedding: pipeline completes non-degraded", async () => {
|
|
812
|
-
seedMemory();
|
|
813
|
-
|
|
814
|
-
const localEmbedConfig: AssistantConfig = {
|
|
815
|
-
...TEST_CONFIG,
|
|
816
|
-
memory: {
|
|
817
|
-
...TEST_CONFIG.memory,
|
|
818
|
-
embeddings: {
|
|
819
|
-
...TEST_CONFIG.memory.embeddings,
|
|
820
|
-
provider: "local",
|
|
821
|
-
required: false,
|
|
822
|
-
},
|
|
823
|
-
},
|
|
824
|
-
};
|
|
825
|
-
|
|
826
|
-
const result = await buildMemoryRecall(
|
|
827
|
-
"API design",
|
|
828
|
-
"conv-test",
|
|
829
|
-
localEmbedConfig,
|
|
830
|
-
);
|
|
831
|
-
|
|
832
|
-
// The local stub returns zero vectors — embedding "succeeds" so the
|
|
833
|
-
// pipeline proceeds non-degraded end-to-end.
|
|
834
|
-
expect(result.enabled).toBe(true);
|
|
835
|
-
expect(result.degraded).toBe(false);
|
|
836
|
-
// Without semantic search, no candidates are found.
|
|
837
|
-
expect(result.mergedCount).toBe(0);
|
|
838
|
-
});
|
|
839
|
-
|
|
840
|
-
// -----------------------------------------------------------------------
|
|
841
|
-
// Step 5b: in-context item filtering
|
|
842
|
-
// -----------------------------------------------------------------------
|
|
843
|
-
|
|
844
|
-
describe("step 5b: in-context item filtering", () => {
|
|
845
|
-
test("filters items whose all sources are in-context messages", async () => {
|
|
846
|
-
const db = getDb();
|
|
847
|
-
const now = Date.now();
|
|
848
|
-
const convId = "conv-item-filter";
|
|
849
|
-
|
|
850
|
-
insertConversation(db, convId, now - 60_000);
|
|
851
|
-
insertMessage(db, "msg-if-1", convId, "user", "hello", now - 50_000);
|
|
852
|
-
insertMessage(db, "msg-if-2", convId, "assistant", "world", now - 40_000);
|
|
853
|
-
insertMessage(
|
|
854
|
-
db,
|
|
855
|
-
"msg-if-3",
|
|
856
|
-
convId,
|
|
857
|
-
"user",
|
|
858
|
-
"memory items test",
|
|
859
|
-
now - 30_000,
|
|
860
|
-
);
|
|
861
|
-
|
|
862
|
-
// Insert a memory item sourced from msg-if-2 (in-context)
|
|
863
|
-
insertItem(db, {
|
|
864
|
-
id: "item-in-ctx",
|
|
865
|
-
kind: "fact",
|
|
866
|
-
subject: "test",
|
|
867
|
-
statement: "A fact from in-context message",
|
|
868
|
-
firstSeenAt: now - 35_000,
|
|
869
|
-
});
|
|
870
|
-
insertItemSource(db, "item-in-ctx", "msg-if-2", now - 35_000);
|
|
871
|
-
|
|
872
|
-
// Simulate Qdrant returning this item as a semantic hit
|
|
873
|
-
mockQdrantResults.push({
|
|
874
|
-
id: "qdrant-pt-1",
|
|
875
|
-
score: 0.9,
|
|
876
|
-
payload: {
|
|
877
|
-
target_type: "item",
|
|
878
|
-
target_id: "item-in-ctx",
|
|
879
|
-
text: "test: A fact from in-context message",
|
|
880
|
-
created_at: now - 35_000,
|
|
881
|
-
},
|
|
882
|
-
});
|
|
883
|
-
|
|
884
|
-
const result = await buildMemoryRecall(
|
|
885
|
-
"memory items test",
|
|
886
|
-
convId,
|
|
887
|
-
TEST_CONFIG,
|
|
888
|
-
);
|
|
889
|
-
|
|
890
|
-
expect(result.enabled).toBe(true);
|
|
891
|
-
// The item should be filtered because its only source is in-context
|
|
892
|
-
expect(result.mergedCount).toBe(0);
|
|
893
|
-
});
|
|
894
|
-
|
|
895
|
-
test("keeps items from compacted messages", async () => {
|
|
896
|
-
const db = getDb();
|
|
897
|
-
const now = Date.now();
|
|
898
|
-
const convId = "conv-item-compacted";
|
|
899
|
-
|
|
900
|
-
// 2 messages compacted away
|
|
901
|
-
insertConversation(db, convId, now - 120_000, {
|
|
902
|
-
contextCompactedMessageCount: 2,
|
|
903
|
-
});
|
|
904
|
-
|
|
905
|
-
// Compacted messages (first 2 by createdAt order)
|
|
906
|
-
insertMessage(
|
|
907
|
-
db,
|
|
908
|
-
"msg-ic-1",
|
|
909
|
-
convId,
|
|
910
|
-
"user",
|
|
911
|
-
"compacted old topic",
|
|
912
|
-
now - 100_000,
|
|
913
|
-
);
|
|
914
|
-
insertMessage(
|
|
915
|
-
db,
|
|
916
|
-
"msg-ic-2",
|
|
917
|
-
convId,
|
|
918
|
-
"assistant",
|
|
919
|
-
"compacted old reply",
|
|
920
|
-
now - 90_000,
|
|
921
|
-
);
|
|
922
|
-
|
|
923
|
-
// Still in context
|
|
924
|
-
insertMessage(
|
|
925
|
-
db,
|
|
926
|
-
"msg-ic-3",
|
|
927
|
-
convId,
|
|
928
|
-
"user",
|
|
929
|
-
"item compaction test",
|
|
930
|
-
now - 50_000,
|
|
931
|
-
);
|
|
932
|
-
|
|
933
|
-
// Item sourced from a compacted message — should be kept
|
|
934
|
-
insertItem(db, {
|
|
935
|
-
id: "item-compacted",
|
|
936
|
-
kind: "fact",
|
|
937
|
-
subject: "compaction",
|
|
938
|
-
statement: "A fact from a compacted message",
|
|
939
|
-
firstSeenAt: now - 95_000,
|
|
940
|
-
});
|
|
941
|
-
insertItemSource(db, "item-compacted", "msg-ic-1", now - 95_000);
|
|
942
|
-
|
|
943
|
-
// Simulate Qdrant returning this item as a semantic hit
|
|
944
|
-
mockQdrantResults.push({
|
|
945
|
-
id: "qdrant-pt-2",
|
|
946
|
-
score: 0.9,
|
|
947
|
-
payload: {
|
|
948
|
-
target_type: "item",
|
|
949
|
-
target_id: "item-compacted",
|
|
950
|
-
text: "compaction: A fact from a compacted message",
|
|
951
|
-
created_at: now - 95_000,
|
|
952
|
-
},
|
|
953
|
-
});
|
|
954
|
-
|
|
955
|
-
const result = await buildMemoryRecall(
|
|
956
|
-
"item compaction test",
|
|
957
|
-
convId,
|
|
958
|
-
TEST_CONFIG,
|
|
959
|
-
);
|
|
960
|
-
|
|
961
|
-
expect(result.enabled).toBe(true);
|
|
962
|
-
// The item sourced from a compacted message should survive filtering
|
|
963
|
-
// because its source is no longer in the context window
|
|
964
|
-
expect(result.mergedCount).toBeGreaterThan(0);
|
|
965
|
-
});
|
|
966
|
-
|
|
967
|
-
test("keeps items with cross-conversation sources", async () => {
|
|
968
|
-
const db = getDb();
|
|
969
|
-
const now = Date.now();
|
|
970
|
-
const convId = "conv-item-cross";
|
|
971
|
-
const otherConvId = "conv-item-other";
|
|
972
|
-
|
|
973
|
-
insertConversation(db, convId, now - 60_000);
|
|
974
|
-
insertConversation(db, otherConvId, now - 120_000);
|
|
975
|
-
|
|
976
|
-
// Messages in current conversation
|
|
977
|
-
insertMessage(
|
|
978
|
-
db,
|
|
979
|
-
"msg-cr-1",
|
|
980
|
-
convId,
|
|
981
|
-
"user",
|
|
982
|
-
"cross conv test",
|
|
983
|
-
now - 50_000,
|
|
984
|
-
);
|
|
985
|
-
insertMessage(
|
|
986
|
-
db,
|
|
987
|
-
"msg-cr-2",
|
|
988
|
-
convId,
|
|
989
|
-
"assistant",
|
|
990
|
-
"cross conv reply",
|
|
991
|
-
now - 40_000,
|
|
992
|
-
);
|
|
993
|
-
|
|
994
|
-
// Message in the other conversation
|
|
995
|
-
insertMessage(
|
|
996
|
-
db,
|
|
997
|
-
"msg-cr-other",
|
|
998
|
-
otherConvId,
|
|
999
|
-
"user",
|
|
1000
|
-
"other conv msg",
|
|
1001
|
-
now - 100_000,
|
|
1002
|
-
);
|
|
1003
|
-
|
|
1004
|
-
// Item sourced from BOTH the current conversation AND a different one
|
|
1005
|
-
insertItem(db, {
|
|
1006
|
-
id: "item-cross",
|
|
1007
|
-
kind: "fact",
|
|
1008
|
-
subject: "cross",
|
|
1009
|
-
statement: "A cross-conversation fact",
|
|
1010
|
-
firstSeenAt: now - 95_000,
|
|
1011
|
-
});
|
|
1012
|
-
insertItemSource(db, "item-cross", "msg-cr-1", now - 45_000);
|
|
1013
|
-
insertItemSource(db, "item-cross", "msg-cr-other", now - 95_000);
|
|
1014
|
-
|
|
1015
|
-
// Simulate Qdrant returning this item as a semantic hit
|
|
1016
|
-
mockQdrantResults.push({
|
|
1017
|
-
id: "qdrant-pt-3",
|
|
1018
|
-
score: 0.9,
|
|
1019
|
-
payload: {
|
|
1020
|
-
target_type: "item",
|
|
1021
|
-
target_id: "item-cross",
|
|
1022
|
-
text: "cross: A cross-conversation fact",
|
|
1023
|
-
created_at: now - 95_000,
|
|
1024
|
-
},
|
|
1025
|
-
});
|
|
1026
|
-
|
|
1027
|
-
const result = await buildMemoryRecall(
|
|
1028
|
-
"cross conv test",
|
|
1029
|
-
convId,
|
|
1030
|
-
TEST_CONFIG,
|
|
1031
|
-
);
|
|
1032
|
-
|
|
1033
|
-
expect(result.enabled).toBe(true);
|
|
1034
|
-
// The item has a source outside the in-context set (from other conv),
|
|
1035
|
-
// so it should NOT be filtered — it carries cross-conversation info
|
|
1036
|
-
expect(result.mergedCount).toBeGreaterThan(0);
|
|
1037
|
-
});
|
|
1038
|
-
});
|
|
1039
|
-
|
|
1040
|
-
// -----------------------------------------------------------------------
|
|
1041
|
-
// Step 5b: fork-aware filtering
|
|
1042
|
-
// -----------------------------------------------------------------------
|
|
1043
|
-
|
|
1044
|
-
describe("step 5b: fork-aware filtering", () => {
|
|
1045
|
-
test("filters segments sourced from fork-parent messages", async () => {
|
|
1046
|
-
const db = getDb();
|
|
1047
|
-
const now = Date.now();
|
|
1048
|
-
|
|
1049
|
-
// Parent conversation with messages
|
|
1050
|
-
const parentConv = "conv-parent";
|
|
1051
|
-
insertConversation(db, parentConv, now - 120_000);
|
|
1052
|
-
insertMessage(
|
|
1053
|
-
db,
|
|
1054
|
-
"parent-msg-1",
|
|
1055
|
-
parentConv,
|
|
1056
|
-
"user",
|
|
1057
|
-
"discuss fork patterns",
|
|
1058
|
-
now - 110_000,
|
|
1059
|
-
);
|
|
1060
|
-
insertMessage(
|
|
1061
|
-
db,
|
|
1062
|
-
"parent-msg-2",
|
|
1063
|
-
parentConv,
|
|
1064
|
-
"assistant",
|
|
1065
|
-
"fork patterns are useful",
|
|
1066
|
-
now - 100_000,
|
|
1067
|
-
);
|
|
1068
|
-
|
|
1069
|
-
// Fork conversation — messages are copies with forkSourceMessageId metadata
|
|
1070
|
-
const forkConv = "conv-fork";
|
|
1071
|
-
insertConversation(db, forkConv, now - 50_000);
|
|
1072
|
-
insertMessage(
|
|
1073
|
-
db,
|
|
1074
|
-
"fork-msg-1",
|
|
1075
|
-
forkConv,
|
|
1076
|
-
"user",
|
|
1077
|
-
"discuss fork patterns",
|
|
1078
|
-
now - 50_000,
|
|
1079
|
-
{
|
|
1080
|
-
metadata: JSON.stringify({
|
|
1081
|
-
forkSourceMessageId: "parent-msg-1",
|
|
1082
|
-
}),
|
|
1083
|
-
},
|
|
1084
|
-
);
|
|
1085
|
-
insertMessage(
|
|
1086
|
-
db,
|
|
1087
|
-
"fork-msg-2",
|
|
1088
|
-
forkConv,
|
|
1089
|
-
"assistant",
|
|
1090
|
-
"fork patterns are useful",
|
|
1091
|
-
now - 49_000,
|
|
1092
|
-
{
|
|
1093
|
-
metadata: JSON.stringify({
|
|
1094
|
-
forkSourceMessageId: "parent-msg-2",
|
|
1095
|
-
}),
|
|
1096
|
-
},
|
|
1097
|
-
);
|
|
1098
|
-
|
|
1099
|
-
// Segment sourced from a parent message — should be filtered when
|
|
1100
|
-
// recalling for the fork conversation since the fork copy is in context.
|
|
1101
|
-
insertSegment(
|
|
1102
|
-
db,
|
|
1103
|
-
"seg-parent-1",
|
|
1104
|
-
"parent-msg-1",
|
|
1105
|
-
parentConv,
|
|
1106
|
-
"user",
|
|
1107
|
-
"discuss fork patterns detail",
|
|
1108
|
-
now - 110_000,
|
|
1109
|
-
);
|
|
1110
|
-
|
|
1111
|
-
// Simulate Qdrant returning the parent-conversation segment as a
|
|
1112
|
-
// semantic hit so it enters the candidate map.
|
|
1113
|
-
mockQdrantResults.push({
|
|
1114
|
-
id: "qdrant-fork-1",
|
|
1115
|
-
score: 0.9,
|
|
1116
|
-
payload: {
|
|
1117
|
-
target_type: "segment",
|
|
1118
|
-
target_id: "seg-parent-1",
|
|
1119
|
-
text: "discuss fork patterns detail",
|
|
1120
|
-
created_at: now - 110_000,
|
|
1121
|
-
message_id: "parent-msg-1",
|
|
1122
|
-
conversation_id: parentConv,
|
|
1123
|
-
},
|
|
1124
|
-
});
|
|
1125
|
-
|
|
1126
|
-
const result = await buildMemoryRecall(
|
|
1127
|
-
"fork patterns",
|
|
1128
|
-
forkConv,
|
|
1129
|
-
TEST_CONFIG,
|
|
1130
|
-
);
|
|
1131
|
-
|
|
1132
|
-
expect(result.enabled).toBe(true);
|
|
1133
|
-
// The segment entered the candidate map via semantic search…
|
|
1134
|
-
expect(result.semanticHits).toBeGreaterThanOrEqual(1);
|
|
1135
|
-
// …but the fork-source filtering removed it because parent-msg-1 is
|
|
1136
|
-
// in the in-context set (via forkSourceMessageId on fork-msg-1).
|
|
1137
|
-
expect(result.mergedCount).toBe(0);
|
|
1138
|
-
});
|
|
1139
|
-
|
|
1140
|
-
test("keeps segments from compacted fork messages' parents", async () => {
|
|
1141
|
-
const db = getDb();
|
|
1142
|
-
const now = Date.now();
|
|
1143
|
-
|
|
1144
|
-
// Parent conversation
|
|
1145
|
-
const parentConv = "conv-parent-compact";
|
|
1146
|
-
insertConversation(db, parentConv, now - 200_000);
|
|
1147
|
-
insertMessage(
|
|
1148
|
-
db,
|
|
1149
|
-
"parent-compact-msg-1",
|
|
1150
|
-
parentConv,
|
|
1151
|
-
"user",
|
|
1152
|
-
"compacted parent topic",
|
|
1153
|
-
now - 190_000,
|
|
1154
|
-
);
|
|
1155
|
-
insertMessage(
|
|
1156
|
-
db,
|
|
1157
|
-
"parent-compact-msg-2",
|
|
1158
|
-
parentConv,
|
|
1159
|
-
"assistant",
|
|
1160
|
-
"compacted parent response",
|
|
1161
|
-
now - 180_000,
|
|
1162
|
-
);
|
|
1163
|
-
|
|
1164
|
-
// Fork conversation with compaction — first 2 messages are compacted
|
|
1165
|
-
const forkConv = "conv-fork-compact";
|
|
1166
|
-
insertConversation(db, forkConv, now - 100_000, {
|
|
1167
|
-
contextCompactedMessageCount: 2,
|
|
1168
|
-
});
|
|
1169
|
-
|
|
1170
|
-
// These two messages are compacted (offset=2 means first 2 are compacted)
|
|
1171
|
-
insertMessage(
|
|
1172
|
-
db,
|
|
1173
|
-
"fork-compact-msg-1",
|
|
1174
|
-
forkConv,
|
|
1175
|
-
"user",
|
|
1176
|
-
"compacted parent topic",
|
|
1177
|
-
now - 100_000,
|
|
1178
|
-
{
|
|
1179
|
-
metadata: JSON.stringify({
|
|
1180
|
-
forkSourceMessageId: "parent-compact-msg-1",
|
|
1181
|
-
}),
|
|
1182
|
-
},
|
|
1183
|
-
);
|
|
1184
|
-
insertMessage(
|
|
1185
|
-
db,
|
|
1186
|
-
"fork-compact-msg-2",
|
|
1187
|
-
forkConv,
|
|
1188
|
-
"assistant",
|
|
1189
|
-
"compacted parent response",
|
|
1190
|
-
now - 99_000,
|
|
1191
|
-
{
|
|
1192
|
-
metadata: JSON.stringify({
|
|
1193
|
-
forkSourceMessageId: "parent-compact-msg-2",
|
|
1194
|
-
}),
|
|
1195
|
-
},
|
|
1196
|
-
);
|
|
1197
|
-
|
|
1198
|
-
// A newer message still in context
|
|
1199
|
-
insertMessage(
|
|
1200
|
-
db,
|
|
1201
|
-
"fork-compact-msg-3",
|
|
1202
|
-
forkConv,
|
|
1203
|
-
"user",
|
|
1204
|
-
"recent fork topic",
|
|
1205
|
-
now - 50_000,
|
|
1206
|
-
);
|
|
1207
|
-
|
|
1208
|
-
// Segment in the fork conversation sourced from a compacted fork
|
|
1209
|
-
// message. Since the fork message is compacted, its forkSourceMessageId
|
|
1210
|
-
// is NOT added to the in-context set, so the segment should survive.
|
|
1211
|
-
insertSegment(
|
|
1212
|
-
db,
|
|
1213
|
-
"seg-compact-fork",
|
|
1214
|
-
"fork-compact-msg-1",
|
|
1215
|
-
forkConv,
|
|
1216
|
-
"user",
|
|
1217
|
-
"compacted parent topic detail",
|
|
1218
|
-
now - 100_000,
|
|
1219
|
-
);
|
|
1220
|
-
|
|
1221
|
-
// Also insert a segment from an in-context message for contrast —
|
|
1222
|
-
// this one SHOULD be filtered.
|
|
1223
|
-
insertSegment(
|
|
1224
|
-
db,
|
|
1225
|
-
"seg-in-context-fork",
|
|
1226
|
-
"fork-compact-msg-3",
|
|
1227
|
-
forkConv,
|
|
1228
|
-
"user",
|
|
1229
|
-
"recent fork topic detail",
|
|
1230
|
-
now - 50_000,
|
|
1231
|
-
);
|
|
1232
|
-
|
|
1233
|
-
// Simulate Qdrant returning both segments as semantic hits so they
|
|
1234
|
-
// enter the candidate map (recency search was removed).
|
|
1235
|
-
mockQdrantResults.push(
|
|
1236
|
-
{
|
|
1237
|
-
id: "qdrant-compact-fork-1",
|
|
1238
|
-
score: 0.9,
|
|
1239
|
-
payload: {
|
|
1240
|
-
target_type: "segment",
|
|
1241
|
-
target_id: "seg-compact-fork",
|
|
1242
|
-
text: "compacted parent topic detail",
|
|
1243
|
-
created_at: now - 100_000,
|
|
1244
|
-
message_id: "fork-compact-msg-1",
|
|
1245
|
-
conversation_id: forkConv,
|
|
1246
|
-
},
|
|
1247
|
-
},
|
|
1248
|
-
{
|
|
1249
|
-
id: "qdrant-compact-fork-2",
|
|
1250
|
-
score: 0.85,
|
|
1251
|
-
payload: {
|
|
1252
|
-
target_type: "segment",
|
|
1253
|
-
target_id: "seg-in-context-fork",
|
|
1254
|
-
text: "recent fork topic detail",
|
|
1255
|
-
created_at: now - 50_000,
|
|
1256
|
-
message_id: "fork-compact-msg-3",
|
|
1257
|
-
conversation_id: forkConv,
|
|
1258
|
-
},
|
|
1259
|
-
},
|
|
1260
|
-
);
|
|
1261
|
-
|
|
1262
|
-
const result = await buildMemoryRecall(
|
|
1263
|
-
"compacted parent topic",
|
|
1264
|
-
forkConv,
|
|
1265
|
-
TEST_CONFIG,
|
|
1266
|
-
);
|
|
1267
|
-
|
|
1268
|
-
expect(result.enabled).toBe(true);
|
|
1269
|
-
// The segment from the compacted fork message survives filtering
|
|
1270
|
-
// (its source message is no longer in context). The in-context segment
|
|
1271
|
-
// is filtered out. Semantic search returns both, but only the compacted
|
|
1272
|
-
// one survives step 5b.
|
|
1273
|
-
expect(result.mergedCount).toBeGreaterThan(0);
|
|
1274
|
-
});
|
|
1275
|
-
|
|
1276
|
-
test("handles multi-level forks", async () => {
|
|
1277
|
-
const db = getDb();
|
|
1278
|
-
const now = Date.now();
|
|
1279
|
-
|
|
1280
|
-
// Grandparent conversation
|
|
1281
|
-
const grandparentConv = "conv-grandparent";
|
|
1282
|
-
insertConversation(db, grandparentConv, now - 300_000);
|
|
1283
|
-
insertMessage(
|
|
1284
|
-
db,
|
|
1285
|
-
"gp-msg-1",
|
|
1286
|
-
grandparentConv,
|
|
1287
|
-
"user",
|
|
1288
|
-
"grandparent topic",
|
|
1289
|
-
now - 290_000,
|
|
1290
|
-
);
|
|
1291
|
-
|
|
1292
|
-
// Parent conversation (fork of grandparent)
|
|
1293
|
-
// The fork metadata preserves the original grandparent message ID
|
|
1294
|
-
const parentConv = "conv-parent-multi";
|
|
1295
|
-
insertConversation(db, parentConv, now - 200_000);
|
|
1296
|
-
insertMessage(
|
|
1297
|
-
db,
|
|
1298
|
-
"parent-multi-msg-1",
|
|
1299
|
-
parentConv,
|
|
1300
|
-
"user",
|
|
1301
|
-
"grandparent topic",
|
|
1302
|
-
now - 200_000,
|
|
1303
|
-
{
|
|
1304
|
-
metadata: JSON.stringify({
|
|
1305
|
-
forkSourceMessageId: "gp-msg-1",
|
|
1306
|
-
}),
|
|
1307
|
-
},
|
|
1308
|
-
);
|
|
1309
|
-
|
|
1310
|
-
// Child conversation (fork of parent)
|
|
1311
|
-
// forkSourceMessageId still points to the original grandparent message
|
|
1312
|
-
const childConv = "conv-child-multi";
|
|
1313
|
-
insertConversation(db, childConv, now - 100_000);
|
|
1314
|
-
insertMessage(
|
|
1315
|
-
db,
|
|
1316
|
-
"child-multi-msg-1",
|
|
1317
|
-
childConv,
|
|
1318
|
-
"user",
|
|
1319
|
-
"grandparent topic",
|
|
1320
|
-
now - 100_000,
|
|
1321
|
-
{
|
|
1322
|
-
metadata: JSON.stringify({
|
|
1323
|
-
forkSourceMessageId: "gp-msg-1",
|
|
1324
|
-
}),
|
|
1325
|
-
},
|
|
1326
|
-
);
|
|
1327
|
-
|
|
1328
|
-
// Segment sourced from the grandparent message
|
|
1329
|
-
insertSegment(
|
|
1330
|
-
db,
|
|
1331
|
-
"seg-gp",
|
|
1332
|
-
"gp-msg-1",
|
|
1333
|
-
grandparentConv,
|
|
1334
|
-
"user",
|
|
1335
|
-
"grandparent topic detail",
|
|
1336
|
-
now - 290_000,
|
|
1337
|
-
);
|
|
1338
|
-
|
|
1339
|
-
// Simulate Qdrant returning the grandparent segment as a semantic hit
|
|
1340
|
-
// so it enters the candidate map.
|
|
1341
|
-
mockQdrantResults.push({
|
|
1342
|
-
id: "qdrant-gp-1",
|
|
1343
|
-
score: 0.9,
|
|
1344
|
-
payload: {
|
|
1345
|
-
target_type: "segment",
|
|
1346
|
-
target_id: "seg-gp",
|
|
1347
|
-
text: "grandparent topic detail",
|
|
1348
|
-
created_at: now - 290_000,
|
|
1349
|
-
message_id: "gp-msg-1",
|
|
1350
|
-
conversation_id: grandparentConv,
|
|
1351
|
-
},
|
|
1352
|
-
});
|
|
1353
|
-
|
|
1354
|
-
const result = await buildMemoryRecall(
|
|
1355
|
-
"grandparent topic",
|
|
1356
|
-
childConv,
|
|
1357
|
-
TEST_CONFIG,
|
|
1358
|
-
);
|
|
1359
|
-
|
|
1360
|
-
expect(result.enabled).toBe(true);
|
|
1361
|
-
// The segment entered the candidate map via semantic search…
|
|
1362
|
-
expect(result.semanticHits).toBeGreaterThanOrEqual(1);
|
|
1363
|
-
// …but the fork-source filtering removed it because gp-msg-1 is in the
|
|
1364
|
-
// in-context set (via forkSourceMessageId on child-multi-msg-1).
|
|
1365
|
-
expect(result.mergedCount).toBe(0);
|
|
1366
|
-
});
|
|
1367
|
-
|
|
1368
|
-
test("handles missing or invalid metadata gracefully", async () => {
|
|
1369
|
-
const db = getDb();
|
|
1370
|
-
const now = Date.now();
|
|
1371
|
-
|
|
1372
|
-
const forkConv = "conv-fork-bad-meta";
|
|
1373
|
-
insertConversation(db, forkConv, now - 50_000);
|
|
1374
|
-
|
|
1375
|
-
// Message with null metadata (no forkSourceMessageId)
|
|
1376
|
-
insertMessage(
|
|
1377
|
-
db,
|
|
1378
|
-
"fork-null-meta",
|
|
1379
|
-
forkConv,
|
|
1380
|
-
"user",
|
|
1381
|
-
"null metadata topic",
|
|
1382
|
-
now - 50_000,
|
|
1383
|
-
);
|
|
1384
|
-
|
|
1385
|
-
// Message with malformed JSON metadata
|
|
1386
|
-
insertMessage(
|
|
1387
|
-
db,
|
|
1388
|
-
"fork-bad-json",
|
|
1389
|
-
forkConv,
|
|
1390
|
-
"assistant",
|
|
1391
|
-
"bad json topic",
|
|
1392
|
-
now - 49_000,
|
|
1393
|
-
{ metadata: "not valid json {{{" },
|
|
1394
|
-
);
|
|
1395
|
-
|
|
1396
|
-
// Message with metadata that is a JSON array (not an object)
|
|
1397
|
-
insertMessage(
|
|
1398
|
-
db,
|
|
1399
|
-
"fork-array-meta",
|
|
1400
|
-
forkConv,
|
|
1401
|
-
"user",
|
|
1402
|
-
"array metadata topic",
|
|
1403
|
-
now - 48_000,
|
|
1404
|
-
{ metadata: JSON.stringify([1, 2, 3]) },
|
|
1405
|
-
);
|
|
1406
|
-
|
|
1407
|
-
// Message with metadata object but no forkSourceMessageId field
|
|
1408
|
-
insertMessage(
|
|
1409
|
-
db,
|
|
1410
|
-
"fork-no-field",
|
|
1411
|
-
forkConv,
|
|
1412
|
-
"assistant",
|
|
1413
|
-
"no field topic",
|
|
1414
|
-
now - 47_000,
|
|
1415
|
-
{ metadata: JSON.stringify({ someOtherField: "value" }) },
|
|
1416
|
-
);
|
|
1417
|
-
|
|
1418
|
-
// Message with forkSourceMessageId that is not a string
|
|
1419
|
-
insertMessage(
|
|
1420
|
-
db,
|
|
1421
|
-
"fork-non-string",
|
|
1422
|
-
forkConv,
|
|
1423
|
-
"user",
|
|
1424
|
-
"non-string fork id",
|
|
1425
|
-
now - 46_000,
|
|
1426
|
-
{ metadata: JSON.stringify({ forkSourceMessageId: 12345 }) },
|
|
1427
|
-
);
|
|
1428
|
-
|
|
1429
|
-
// Insert a segment from this conversation — should be filtered normally
|
|
1430
|
-
// (it's an in-context segment from the active conversation)
|
|
1431
|
-
insertSegment(
|
|
1432
|
-
db,
|
|
1433
|
-
"seg-bad-meta",
|
|
1434
|
-
"fork-null-meta",
|
|
1435
|
-
forkConv,
|
|
1436
|
-
"user",
|
|
1437
|
-
"null metadata topic detail",
|
|
1438
|
-
now - 50_000,
|
|
1439
|
-
);
|
|
1440
|
-
|
|
1441
|
-
// This should not crash despite various malformed metadata
|
|
1442
|
-
const result = await buildMemoryRecall(
|
|
1443
|
-
"metadata topic",
|
|
1444
|
-
forkConv,
|
|
1445
|
-
TEST_CONFIG,
|
|
1446
|
-
);
|
|
1447
|
-
|
|
1448
|
-
expect(result.enabled).toBe(true);
|
|
1449
|
-
// No crash — the pipeline completes successfully
|
|
1450
|
-
// The in-context segment is still filtered normally
|
|
1451
|
-
expect(result.mergedCount).toBe(0);
|
|
1452
|
-
});
|
|
1453
|
-
});
|
|
1454
|
-
|
|
1455
|
-
// -----------------------------------------------------------------------
|
|
1456
|
-
// Serendipity layer
|
|
1457
|
-
// -----------------------------------------------------------------------
|
|
1458
|
-
|
|
1459
|
-
describe("serendipity sampling", () => {
|
|
1460
|
-
test("samples random active items and renders them in <echoes>", async () => {
|
|
1461
|
-
const db = getDb();
|
|
1462
|
-
const now = Date.now();
|
|
1463
|
-
const convId = "conv-serendipity";
|
|
1464
|
-
|
|
1465
|
-
insertConversation(db, convId, now - 60_000);
|
|
1466
|
-
insertMessage(db, "msg-s-1", convId, "user", "hello", now - 50_000);
|
|
1467
|
-
|
|
1468
|
-
// Items sourced from a different conversation so in-context filtering
|
|
1469
|
-
// doesn't remove them (serendipity is cross-conversation recall).
|
|
1470
|
-
const otherConvId = "conv-serendipity-other";
|
|
1471
|
-
insertConversation(db, otherConvId, now - 120_000);
|
|
1472
|
-
insertMessage(
|
|
1473
|
-
db,
|
|
1474
|
-
"msg-s-other",
|
|
1475
|
-
otherConvId,
|
|
1476
|
-
"user",
|
|
1477
|
-
"other",
|
|
1478
|
-
now - 110_000,
|
|
1479
|
-
);
|
|
1480
|
-
|
|
1481
|
-
// Insert several active items that are NOT returned by Qdrant
|
|
1482
|
-
for (let i = 1; i <= 5; i++) {
|
|
1483
|
-
insertItem(db, {
|
|
1484
|
-
id: `serendipity-item-${i}`,
|
|
1485
|
-
kind: "fact",
|
|
1486
|
-
subject: `topic ${i}`,
|
|
1487
|
-
statement: `Serendipity fact number ${i}`,
|
|
1488
|
-
importance: i * 0.15, // 0.15..0.75
|
|
1489
|
-
firstSeenAt: now - i * 10_000,
|
|
1490
|
-
});
|
|
1491
|
-
insertItemSource(
|
|
1492
|
-
db,
|
|
1493
|
-
`serendipity-item-${i}`,
|
|
1494
|
-
"msg-s-other",
|
|
1495
|
-
now - i * 10_000,
|
|
1496
|
-
);
|
|
1497
|
-
}
|
|
1498
|
-
|
|
1499
|
-
// Qdrant returns nothing — no recalled candidates
|
|
1500
|
-
mockQdrantResults.length = 0;
|
|
1501
|
-
|
|
1502
|
-
const result = await buildMemoryRecall(
|
|
1503
|
-
"unrelated query",
|
|
1504
|
-
convId,
|
|
1505
|
-
TEST_CONFIG,
|
|
1506
|
-
);
|
|
1507
|
-
|
|
1508
|
-
expect(result.enabled).toBe(true);
|
|
1509
|
-
// No semantic hits, so no recalled candidates
|
|
1510
|
-
expect(result.mergedCount).toBe(0);
|
|
1511
|
-
// But serendipity items should appear in the injection
|
|
1512
|
-
expect(result.injectedText).toContain("<echoes>");
|
|
1513
|
-
expect(result.injectedText).toContain("</echoes>");
|
|
1514
|
-
// At most 3 serendipity items
|
|
1515
|
-
const itemMatches = result.injectedText.match(/<item /g);
|
|
1516
|
-
expect(itemMatches).toBeTruthy();
|
|
1517
|
-
expect(itemMatches!.length).toBeLessThanOrEqual(3);
|
|
1518
|
-
expect(itemMatches!.length).toBeGreaterThanOrEqual(1);
|
|
1519
|
-
// selectedCount includes serendipity items
|
|
1520
|
-
expect(result.selectedCount).toBeGreaterThan(0);
|
|
1521
|
-
});
|
|
1522
|
-
|
|
1523
|
-
test("excludes items already in the candidate pool from serendipity", async () => {
|
|
1524
|
-
const db = getDb();
|
|
1525
|
-
const now = Date.now();
|
|
1526
|
-
const convId = "conv-serendipity-excl";
|
|
1527
|
-
|
|
1528
|
-
insertConversation(db, convId, now - 60_000);
|
|
1529
|
-
insertMessage(
|
|
1530
|
-
db,
|
|
1531
|
-
"msg-se-1",
|
|
1532
|
-
convId,
|
|
1533
|
-
"user",
|
|
1534
|
-
"query about X",
|
|
1535
|
-
now - 50_000,
|
|
1536
|
-
);
|
|
1537
|
-
|
|
1538
|
-
// This item will be returned by Qdrant as a recalled candidate
|
|
1539
|
-
insertItem(db, {
|
|
1540
|
-
id: "recalled-item",
|
|
1541
|
-
kind: "fact",
|
|
1542
|
-
subject: "X",
|
|
1543
|
-
statement: "Recalled fact about X",
|
|
1544
|
-
importance: 0.9,
|
|
1545
|
-
firstSeenAt: now - 30_000,
|
|
1546
|
-
});
|
|
1547
|
-
insertItemSource(db, "recalled-item", "msg-se-1", now - 30_000);
|
|
1548
|
-
|
|
1549
|
-
// Qdrant returns the recalled item
|
|
1550
|
-
mockQdrantResults.push({
|
|
1551
|
-
id: "qdrant-recalled",
|
|
1552
|
-
score: 0.9,
|
|
1553
|
-
payload: {
|
|
1554
|
-
target_type: "item",
|
|
1555
|
-
target_id: "recalled-item",
|
|
1556
|
-
text: "X: Recalled fact about X",
|
|
1557
|
-
created_at: now - 30_000,
|
|
1558
|
-
},
|
|
1559
|
-
});
|
|
1560
|
-
|
|
1561
|
-
const result = await buildMemoryRecall(
|
|
1562
|
-
"query about X",
|
|
1563
|
-
convId,
|
|
1564
|
-
TEST_CONFIG,
|
|
1565
|
-
);
|
|
1566
|
-
|
|
1567
|
-
expect(result.enabled).toBe(true);
|
|
1568
|
-
// The recalled item is in <recalled>, not in <echoes>
|
|
1569
|
-
if (result.injectedText.includes("<echoes>")) {
|
|
1570
|
-
// If echoes exists, the recalled item should NOT be duplicated there
|
|
1571
|
-
const echoesMatch = result.injectedText.match(
|
|
1572
|
-
/<echoes>([\s\S]*?)<\/echoes>/,
|
|
1573
|
-
);
|
|
1574
|
-
if (echoesMatch) {
|
|
1575
|
-
expect(echoesMatch[1]).not.toContain("recalled-item");
|
|
1576
|
-
}
|
|
1577
|
-
}
|
|
1578
|
-
});
|
|
1579
|
-
|
|
1580
|
-
test("no <echoes> section when no active items exist", async () => {
|
|
1581
|
-
// No items seeded at all
|
|
1582
|
-
const result = await buildMemoryRecall(
|
|
1583
|
-
"anything",
|
|
1584
|
-
"conv-empty-seren",
|
|
1585
|
-
TEST_CONFIG,
|
|
1586
|
-
);
|
|
1587
|
-
|
|
1588
|
-
expect(result.enabled).toBe(true);
|
|
1589
|
-
expect(result.injectedText).not.toContain("<echoes>");
|
|
1590
|
-
});
|
|
1591
|
-
});
|
|
1592
|
-
});
|