@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,3696 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
afterAll,
|
|
5
|
-
beforeAll,
|
|
6
|
-
beforeEach,
|
|
7
|
-
describe,
|
|
8
|
-
expect,
|
|
9
|
-
mock,
|
|
10
|
-
test,
|
|
11
|
-
} from "bun:test";
|
|
12
|
-
|
|
13
|
-
const testWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR!;
|
|
14
|
-
|
|
15
|
-
mock.module("../util/logger.js", () => ({
|
|
16
|
-
getLogger: () =>
|
|
17
|
-
new Proxy({} as Record<string, unknown>, {
|
|
18
|
-
get: () => () => {},
|
|
19
|
-
}),
|
|
20
|
-
}));
|
|
21
|
-
|
|
22
|
-
// Stub the local embedding backend so the real ONNX model (2.5 GB RSS) never
|
|
23
|
-
// loads — avoids a Bun v1.3.9 panic on process exit.
|
|
24
|
-
mock.module("../memory/embedding-local.js", () => ({
|
|
25
|
-
LocalEmbeddingBackend: class {
|
|
26
|
-
readonly provider = "local" as const;
|
|
27
|
-
readonly model: string;
|
|
28
|
-
constructor(model: string) {
|
|
29
|
-
this.model = model;
|
|
30
|
-
}
|
|
31
|
-
async embed(texts: string[]): Promise<number[][]> {
|
|
32
|
-
return texts.map(() => new Array(384).fill(0));
|
|
33
|
-
}
|
|
34
|
-
},
|
|
35
|
-
}));
|
|
36
|
-
|
|
37
|
-
// Dynamic Qdrant mock: tests can push results to be returned by hybridSearch
|
|
38
|
-
let mockQdrantResults: Array<{
|
|
39
|
-
id: string;
|
|
40
|
-
score: number;
|
|
41
|
-
payload: Record<string, unknown>;
|
|
42
|
-
}> = [];
|
|
43
|
-
|
|
44
|
-
mock.module("../memory/qdrant-client.js", () => ({
|
|
45
|
-
getQdrantClient: () => ({
|
|
46
|
-
searchWithFilter: async () => mockQdrantResults,
|
|
47
|
-
hybridSearch: async () => mockQdrantResults,
|
|
48
|
-
upsertPoints: async () => {},
|
|
49
|
-
deletePoints: async () => {},
|
|
50
|
-
}),
|
|
51
|
-
initQdrantClient: () => {},
|
|
52
|
-
}));
|
|
53
|
-
|
|
54
|
-
import { and, eq } from "drizzle-orm";
|
|
55
|
-
|
|
56
|
-
import { DEFAULT_CONFIG } from "../config/defaults.js";
|
|
57
|
-
import { vectorToBlob } from "../memory/job-utils.js";
|
|
58
|
-
|
|
59
|
-
// Disable LLM extraction and summarization in tests to avoid real API calls.
|
|
60
|
-
const TEST_CONFIG = {
|
|
61
|
-
...DEFAULT_CONFIG,
|
|
62
|
-
memory: {
|
|
63
|
-
...DEFAULT_CONFIG.memory,
|
|
64
|
-
extraction: {
|
|
65
|
-
...DEFAULT_CONFIG.memory.extraction,
|
|
66
|
-
useLLM: false,
|
|
67
|
-
},
|
|
68
|
-
summarization: {
|
|
69
|
-
...DEFAULT_CONFIG.memory.summarization,
|
|
70
|
-
useLLM: false,
|
|
71
|
-
},
|
|
72
|
-
},
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
mock.module("../config/loader.js", () => ({
|
|
76
|
-
loadConfig: () => TEST_CONFIG,
|
|
77
|
-
getConfig: () => TEST_CONFIG,
|
|
78
|
-
invalidateConfigCache: () => {},
|
|
79
|
-
}));
|
|
80
|
-
import { estimateTextTokens } from "../context/token-estimator.js";
|
|
81
|
-
import { stripUserTextBlocksByPrefix } from "../daemon/conversation-runtime-assembly.js";
|
|
82
|
-
import {
|
|
83
|
-
getMemorySystemStatus,
|
|
84
|
-
requestMemoryBackfill,
|
|
85
|
-
requestMemoryCleanup,
|
|
86
|
-
} from "../memory/admin.js";
|
|
87
|
-
import {
|
|
88
|
-
addMessage,
|
|
89
|
-
createConversation,
|
|
90
|
-
getConversationMemoryScopeId,
|
|
91
|
-
messageMetadataSchema,
|
|
92
|
-
provenanceFromTrustContext,
|
|
93
|
-
} from "../memory/conversation-crud.js";
|
|
94
|
-
import { getDb, initializeDb, resetDb } from "../memory/db.js";
|
|
95
|
-
import { selectEmbeddingBackend } from "../memory/embedding-backend.js";
|
|
96
|
-
import {
|
|
97
|
-
getRecentSegmentsForConversation,
|
|
98
|
-
indexMessageNow,
|
|
99
|
-
} from "../memory/indexer.js";
|
|
100
|
-
import { backfillJob } from "../memory/job-handlers/backfill.js";
|
|
101
|
-
import { buildConversationSummaryJob } from "../memory/job-handlers/summarization.js";
|
|
102
|
-
import { claimMemoryJobs, enqueueMemoryJob } from "../memory/jobs-store.js";
|
|
103
|
-
import {
|
|
104
|
-
maybeEnqueueScheduledCleanupJobs,
|
|
105
|
-
resetCleanupScheduleThrottle,
|
|
106
|
-
resetStaleSweepThrottle,
|
|
107
|
-
runMemoryJobsOnce,
|
|
108
|
-
sweepStaleItems,
|
|
109
|
-
} from "../memory/jobs-worker.js";
|
|
110
|
-
import {
|
|
111
|
-
buildMemoryRecall,
|
|
112
|
-
escapeXmlTags,
|
|
113
|
-
formatAbsoluteTime,
|
|
114
|
-
formatRelativeTime,
|
|
115
|
-
injectMemoryRecallAsUserBlock,
|
|
116
|
-
lookupSupersessionChain,
|
|
117
|
-
} from "../memory/retriever.js";
|
|
118
|
-
import {
|
|
119
|
-
conversations,
|
|
120
|
-
memoryEmbeddings,
|
|
121
|
-
memoryItems,
|
|
122
|
-
memoryItemSources,
|
|
123
|
-
memoryJobs,
|
|
124
|
-
memorySegments,
|
|
125
|
-
memorySummaries,
|
|
126
|
-
messages,
|
|
127
|
-
} from "../memory/schema.js";
|
|
128
|
-
import { buildMemoryInjection } from "../memory/search/formatting.js";
|
|
129
|
-
import { buildCoreIdentityContext } from "../prompts/system-prompt.js";
|
|
130
|
-
import type { Message } from "../providers/types.js";
|
|
131
|
-
|
|
132
|
-
describe("Memory regressions", () => {
|
|
133
|
-
beforeAll(() => {
|
|
134
|
-
initializeDb();
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
beforeEach(() => {
|
|
138
|
-
const db = getDb();
|
|
139
|
-
db.run("DELETE FROM memory_item_sources");
|
|
140
|
-
db.run("DELETE FROM memory_embeddings");
|
|
141
|
-
db.run("DELETE FROM memory_summaries");
|
|
142
|
-
db.run("DELETE FROM memory_items");
|
|
143
|
-
|
|
144
|
-
db.run("DELETE FROM memory_segments");
|
|
145
|
-
db.run("DELETE FROM messages");
|
|
146
|
-
db.run("DELETE FROM conversations");
|
|
147
|
-
db.run("DELETE FROM memory_jobs");
|
|
148
|
-
db.run("DELETE FROM memory_checkpoints");
|
|
149
|
-
mockQdrantResults = [];
|
|
150
|
-
resetCleanupScheduleThrottle();
|
|
151
|
-
resetStaleSweepThrottle();
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
afterAll(() => {
|
|
155
|
-
resetDb();
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
function semanticRecallConfig() {
|
|
159
|
-
return {
|
|
160
|
-
...DEFAULT_CONFIG,
|
|
161
|
-
memory: {
|
|
162
|
-
...DEFAULT_CONFIG.memory,
|
|
163
|
-
embeddings: {
|
|
164
|
-
...DEFAULT_CONFIG.memory.embeddings,
|
|
165
|
-
provider: "ollama" as const,
|
|
166
|
-
required: true,
|
|
167
|
-
},
|
|
168
|
-
retrieval: {
|
|
169
|
-
...DEFAULT_CONFIG.memory.retrieval,
|
|
170
|
-
maxInjectTokens: 2000,
|
|
171
|
-
},
|
|
172
|
-
},
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Baseline: indexMessageNow without explicit scopeId defaults to 'default'
|
|
177
|
-
test('baseline: memory segments default to scope "default" when no scopeId given', async () => {
|
|
178
|
-
const db = getDb();
|
|
179
|
-
const now = Date.now();
|
|
180
|
-
db.insert(conversations)
|
|
181
|
-
.values({
|
|
182
|
-
id: "conv-baseline-scope",
|
|
183
|
-
title: null,
|
|
184
|
-
createdAt: now,
|
|
185
|
-
updatedAt: now,
|
|
186
|
-
totalInputTokens: 0,
|
|
187
|
-
totalOutputTokens: 0,
|
|
188
|
-
totalEstimatedCost: 0,
|
|
189
|
-
contextSummary: null,
|
|
190
|
-
contextCompactedMessageCount: 0,
|
|
191
|
-
contextCompactedAt: null,
|
|
192
|
-
})
|
|
193
|
-
.run();
|
|
194
|
-
db.insert(messages)
|
|
195
|
-
.values({
|
|
196
|
-
id: "msg-baseline-scope",
|
|
197
|
-
conversationId: "conv-baseline-scope",
|
|
198
|
-
role: "user",
|
|
199
|
-
content: JSON.stringify([
|
|
200
|
-
{
|
|
201
|
-
type: "text",
|
|
202
|
-
text: "The user strongly prefers dark mode for all editor themes and UIs.",
|
|
203
|
-
},
|
|
204
|
-
]),
|
|
205
|
-
createdAt: now,
|
|
206
|
-
})
|
|
207
|
-
.run();
|
|
208
|
-
|
|
209
|
-
// Index without explicit scopeId — should use 'default'
|
|
210
|
-
await indexMessageNow(
|
|
211
|
-
{
|
|
212
|
-
messageId: "msg-baseline-scope",
|
|
213
|
-
conversationId: "conv-baseline-scope",
|
|
214
|
-
role: "user",
|
|
215
|
-
content: JSON.stringify([
|
|
216
|
-
{
|
|
217
|
-
type: "text",
|
|
218
|
-
text: "The user strongly prefers dark mode for all editor themes and UIs.",
|
|
219
|
-
},
|
|
220
|
-
]),
|
|
221
|
-
createdAt: now,
|
|
222
|
-
},
|
|
223
|
-
DEFAULT_CONFIG.memory,
|
|
224
|
-
);
|
|
225
|
-
|
|
226
|
-
const segs = db
|
|
227
|
-
.select()
|
|
228
|
-
.from(memorySegments)
|
|
229
|
-
.where(eq(memorySegments.messageId, "msg-baseline-scope"))
|
|
230
|
-
.all();
|
|
231
|
-
|
|
232
|
-
expect(segs.length).toBeGreaterThan(0);
|
|
233
|
-
for (const seg of segs) {
|
|
234
|
-
expect(seg.scopeId).toBe("default");
|
|
235
|
-
}
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
test("recall excludes current-turn message ids from injected candidates", async () => {
|
|
239
|
-
const db = getDb();
|
|
240
|
-
const now = 1_700_000_100_000;
|
|
241
|
-
db.insert(conversations)
|
|
242
|
-
.values({
|
|
243
|
-
id: "conv-exclude",
|
|
244
|
-
title: null,
|
|
245
|
-
createdAt: now,
|
|
246
|
-
updatedAt: now,
|
|
247
|
-
totalInputTokens: 0,
|
|
248
|
-
totalOutputTokens: 0,
|
|
249
|
-
totalEstimatedCost: 0,
|
|
250
|
-
contextSummary: null,
|
|
251
|
-
contextCompactedMessageCount: 0,
|
|
252
|
-
contextCompactedAt: null,
|
|
253
|
-
})
|
|
254
|
-
.run();
|
|
255
|
-
db.insert(messages)
|
|
256
|
-
.values({
|
|
257
|
-
id: "msg-old",
|
|
258
|
-
conversationId: "conv-exclude",
|
|
259
|
-
role: "user",
|
|
260
|
-
content: JSON.stringify([
|
|
261
|
-
{ type: "text", text: "Remember my timezone is PST." },
|
|
262
|
-
]),
|
|
263
|
-
createdAt: now - 10_000,
|
|
264
|
-
})
|
|
265
|
-
.run();
|
|
266
|
-
db.insert(messages)
|
|
267
|
-
.values({
|
|
268
|
-
id: "msg-current",
|
|
269
|
-
conversationId: "conv-exclude",
|
|
270
|
-
role: "user",
|
|
271
|
-
content: JSON.stringify([
|
|
272
|
-
{ type: "text", text: "What is my timezone again?" },
|
|
273
|
-
]),
|
|
274
|
-
createdAt: now,
|
|
275
|
-
})
|
|
276
|
-
.run();
|
|
277
|
-
db.run(`
|
|
278
|
-
INSERT INTO memory_segments (
|
|
279
|
-
id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
|
|
280
|
-
) VALUES
|
|
281
|
-
('seg-old', 'msg-old', 'conv-exclude', 'user', 0, 'Remember my timezone is PST.', 7, ${
|
|
282
|
-
now - 10_000
|
|
283
|
-
}, ${now - 10_000}),
|
|
284
|
-
('seg-current', 'msg-current', 'conv-exclude', 'user', 0, 'What is my timezone again?', 7, ${now}, ${now})
|
|
285
|
-
`);
|
|
286
|
-
|
|
287
|
-
const config = {
|
|
288
|
-
...DEFAULT_CONFIG,
|
|
289
|
-
memory: {
|
|
290
|
-
...DEFAULT_CONFIG.memory,
|
|
291
|
-
embeddings: {
|
|
292
|
-
...DEFAULT_CONFIG.memory.embeddings,
|
|
293
|
-
required: false,
|
|
294
|
-
},
|
|
295
|
-
},
|
|
296
|
-
};
|
|
297
|
-
|
|
298
|
-
const recall = await buildMemoryRecall("timezone", "conv-exclude", config, {
|
|
299
|
-
excludeMessageIds: ["msg-current"],
|
|
300
|
-
});
|
|
301
|
-
expect(recall.enabled).toBe(true);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
test("memory recall injection as user block and stripped from runtime history", () => {
|
|
305
|
-
const memoryRecallText =
|
|
306
|
-
"<memory_context __injected>\n\n<relevant_context>\nuser prefers concise answers\n</relevant_context>\n\n</memory_context>";
|
|
307
|
-
const originalMessages: Message[] = [
|
|
308
|
-
{
|
|
309
|
-
role: "user",
|
|
310
|
-
content: [{ type: "text" as const, text: "Actual user request" }],
|
|
311
|
-
},
|
|
312
|
-
];
|
|
313
|
-
const injected = injectMemoryRecallAsUserBlock(
|
|
314
|
-
originalMessages,
|
|
315
|
-
memoryRecallText,
|
|
316
|
-
);
|
|
317
|
-
|
|
318
|
-
// Memory context prepended to last user message as content block
|
|
319
|
-
expect(injected).toHaveLength(1);
|
|
320
|
-
expect(injected[0].role).toBe("user");
|
|
321
|
-
expect(injected[0].content).toHaveLength(2);
|
|
322
|
-
const b0 = injected[0].content[0];
|
|
323
|
-
const b1 = injected[0].content[1];
|
|
324
|
-
expect(b0.type === "text" && b0.text).toBe(memoryRecallText);
|
|
325
|
-
expect(b1.type === "text" && b1.text).toBe("Actual user request");
|
|
326
|
-
|
|
327
|
-
// Stripped by prefix-based stripping
|
|
328
|
-
const cleaned = stripUserTextBlocksByPrefix(injected, [
|
|
329
|
-
"<memory_context __injected>",
|
|
330
|
-
]);
|
|
331
|
-
expect(cleaned).toHaveLength(1);
|
|
332
|
-
expect(cleaned[0].content).toHaveLength(1);
|
|
333
|
-
const cb0 = cleaned[0].content[0];
|
|
334
|
-
expect(cb0.type === "text" && cb0.text).toBe("Actual user request");
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
test("prefix-based stripping removes all <memory_context> blocks from merged content", () => {
|
|
338
|
-
const memoryRecallText =
|
|
339
|
-
"<memory_context __injected>\n\n<relevant_context>\nuser prefers concise answers\n</relevant_context>\n\n</memory_context>";
|
|
340
|
-
// Simulate deep-repair merging where multiple memory context blocks exist.
|
|
341
|
-
// Prefix-based stripping removes all blocks starting with <memory_context __injected>.
|
|
342
|
-
const mergedUserMessage: Message = {
|
|
343
|
-
role: "user",
|
|
344
|
-
content: [
|
|
345
|
-
{ type: "text" as const, text: memoryRecallText },
|
|
346
|
-
{ type: "text" as const, text: "Earlier user request" },
|
|
347
|
-
{ type: "text" as const, text: memoryRecallText },
|
|
348
|
-
{ type: "text" as const, text: "Latest user request" },
|
|
349
|
-
],
|
|
350
|
-
};
|
|
351
|
-
|
|
352
|
-
const cleaned = stripUserTextBlocksByPrefix(
|
|
353
|
-
[mergedUserMessage],
|
|
354
|
-
["<memory_context __injected>"],
|
|
355
|
-
);
|
|
356
|
-
expect(cleaned).toHaveLength(1);
|
|
357
|
-
expect(cleaned[0].content).toEqual([
|
|
358
|
-
{ type: "text", text: "Earlier user request" },
|
|
359
|
-
{ type: "text", text: "Latest user request" },
|
|
360
|
-
]);
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
test("injectMemoryRecallAsUserBlock prepends memory to last user message", () => {
|
|
364
|
-
const history: Message[] = [
|
|
365
|
-
{ role: "user", content: [{ type: "text" as const, text: "Hello" }] },
|
|
366
|
-
{ role: "assistant", content: [{ type: "text" as const, text: "Hi!" }] },
|
|
367
|
-
{
|
|
368
|
-
role: "user",
|
|
369
|
-
content: [{ type: "text" as const, text: "Tell me about X" }],
|
|
370
|
-
},
|
|
371
|
-
];
|
|
372
|
-
const recallText =
|
|
373
|
-
"<memory_context __injected>\n\n<relevant_context>\nSome recalled fact\n</relevant_context>\n\n</memory_context>";
|
|
374
|
-
const result = injectMemoryRecallAsUserBlock(history, recallText);
|
|
375
|
-
// Same number of messages — no synthetic pair
|
|
376
|
-
expect(result).toHaveLength(3);
|
|
377
|
-
expect(result[0]).toBe(history[0]);
|
|
378
|
-
expect(result[1]).toBe(history[1]);
|
|
379
|
-
// Last user message has memory prepended
|
|
380
|
-
const r0 = result[2].content[0];
|
|
381
|
-
const r1 = result[2].content[1];
|
|
382
|
-
expect(r0.type === "text" && r0.text).toBe(recallText);
|
|
383
|
-
expect(r1.type === "text" && r1.text).toBe("Tell me about X");
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
test("injectMemoryRecallAsUserBlock with empty text is a no-op", () => {
|
|
387
|
-
const history: Message[] = [
|
|
388
|
-
{ role: "user", content: [{ type: "text" as const, text: "Hello" }] },
|
|
389
|
-
];
|
|
390
|
-
const result = injectMemoryRecallAsUserBlock(history, " ");
|
|
391
|
-
expect(result).toBe(history);
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
test("stripUserTextBlocksByPrefix removes memory_context block from user message", () => {
|
|
395
|
-
const recallText =
|
|
396
|
-
"<memory_context __injected>\n\n<relevant_context>\nSome recalled fact\n</relevant_context>\n\n</memory_context>";
|
|
397
|
-
const msgs: Message[] = [
|
|
398
|
-
{ role: "user", content: [{ type: "text" as const, text: "Hello" }] },
|
|
399
|
-
{
|
|
400
|
-
role: "assistant",
|
|
401
|
-
content: [{ type: "text" as const, text: "Hi!" }],
|
|
402
|
-
},
|
|
403
|
-
{
|
|
404
|
-
role: "user",
|
|
405
|
-
content: [
|
|
406
|
-
{ type: "text" as const, text: recallText },
|
|
407
|
-
{ type: "text" as const, text: "Tell me about X" },
|
|
408
|
-
],
|
|
409
|
-
},
|
|
410
|
-
];
|
|
411
|
-
const cleaned = stripUserTextBlocksByPrefix(msgs, [
|
|
412
|
-
"<memory_context __injected>",
|
|
413
|
-
]);
|
|
414
|
-
expect(cleaned).toHaveLength(3);
|
|
415
|
-
const c0 = cleaned[0].content[0];
|
|
416
|
-
const c1 = cleaned[1].content[0];
|
|
417
|
-
const c2 = cleaned[2].content[0];
|
|
418
|
-
expect(c0.type === "text" && c0.text).toBe("Hello");
|
|
419
|
-
expect(c1.type === "text" && c1.text).toBe("Hi!");
|
|
420
|
-
expect(cleaned[2].content).toHaveLength(1);
|
|
421
|
-
expect(c2.type === "text" && c2.text).toBe("Tell me about X");
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
test("aborting memory recall embedding returns a non-degraded aborted recall result", async () => {
|
|
425
|
-
const originalFetch = globalThis.fetch;
|
|
426
|
-
const controller = new AbortController();
|
|
427
|
-
let seenSignal: AbortSignal | undefined;
|
|
428
|
-
|
|
429
|
-
globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => {
|
|
430
|
-
seenSignal = init?.signal as AbortSignal | undefined;
|
|
431
|
-
return new Promise<Response>((_resolve, reject) => {
|
|
432
|
-
const signal = init?.signal as AbortSignal | undefined;
|
|
433
|
-
if (!signal) {
|
|
434
|
-
reject(new Error("Expected abort signal"));
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
const abortError = new Error("Aborted");
|
|
438
|
-
abortError.name = "AbortError";
|
|
439
|
-
if (signal.aborted) {
|
|
440
|
-
reject(abortError);
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
signal.addEventListener("abort", () => reject(abortError), {
|
|
444
|
-
once: true,
|
|
445
|
-
});
|
|
446
|
-
});
|
|
447
|
-
}) as typeof globalThis.fetch;
|
|
448
|
-
|
|
449
|
-
try {
|
|
450
|
-
const recallPromise = buildMemoryRecall(
|
|
451
|
-
"timezone",
|
|
452
|
-
"conv-abort",
|
|
453
|
-
semanticRecallConfig(),
|
|
454
|
-
{ signal: controller.signal },
|
|
455
|
-
);
|
|
456
|
-
controller.abort();
|
|
457
|
-
const recall = await recallPromise;
|
|
458
|
-
expect(seenSignal).toBe(controller.signal);
|
|
459
|
-
expect(recall.degraded).toBe(false);
|
|
460
|
-
expect(recall.reason).toBe("memory.aborted");
|
|
461
|
-
expect(recall.injectedText).toBe("");
|
|
462
|
-
expect(recall.injectedTokens).toBe(0);
|
|
463
|
-
} finally {
|
|
464
|
-
globalThis.fetch = originalFetch;
|
|
465
|
-
}
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
test("memory item lastSeenAt does not move backwards on duplicate save", async () => {
|
|
469
|
-
const { handleMemorySave } = await import("../tools/memory/handlers.js");
|
|
470
|
-
|
|
471
|
-
// First save creates the item
|
|
472
|
-
const r1 = await handleMemorySave(
|
|
473
|
-
{
|
|
474
|
-
statement: "We decided to use sqlite for local persistence",
|
|
475
|
-
kind: "decision",
|
|
476
|
-
},
|
|
477
|
-
DEFAULT_CONFIG,
|
|
478
|
-
"conv-lastseen-1",
|
|
479
|
-
"msg-lastseen-1",
|
|
480
|
-
);
|
|
481
|
-
expect(r1.isError).toBe(false);
|
|
482
|
-
|
|
483
|
-
const db = getDb();
|
|
484
|
-
const firstSave = db
|
|
485
|
-
.select()
|
|
486
|
-
.from(memoryItems)
|
|
487
|
-
.where(eq(memoryItems.kind, "decision"))
|
|
488
|
-
.get();
|
|
489
|
-
expect(firstSave).not.toBeNull();
|
|
490
|
-
const firstLastSeenAt = firstSave!.lastSeenAt;
|
|
491
|
-
expect(firstLastSeenAt).toBeGreaterThan(0);
|
|
492
|
-
|
|
493
|
-
// Second save of the same statement should update lastSeenAt monotonically
|
|
494
|
-
const r2 = await handleMemorySave(
|
|
495
|
-
{
|
|
496
|
-
statement: "We decided to use sqlite for local persistence",
|
|
497
|
-
kind: "decision",
|
|
498
|
-
},
|
|
499
|
-
DEFAULT_CONFIG,
|
|
500
|
-
"conv-lastseen-2",
|
|
501
|
-
"msg-lastseen-2",
|
|
502
|
-
);
|
|
503
|
-
expect(r2.isError).toBe(false);
|
|
504
|
-
|
|
505
|
-
const secondSave = db
|
|
506
|
-
.select()
|
|
507
|
-
.from(memoryItems)
|
|
508
|
-
.where(eq(memoryItems.kind, "decision"))
|
|
509
|
-
.get();
|
|
510
|
-
expect(secondSave!.lastSeenAt).toBeGreaterThanOrEqual(firstLastSeenAt);
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
test("memory_save sets verificationState to user_confirmed", async () => {
|
|
514
|
-
const { handleMemorySave } = await import("../tools/memory/handlers.js");
|
|
515
|
-
|
|
516
|
-
const result = await handleMemorySave(
|
|
517
|
-
{
|
|
518
|
-
statement: "User explicitly saved this preference",
|
|
519
|
-
kind: "preference",
|
|
520
|
-
},
|
|
521
|
-
DEFAULT_CONFIG,
|
|
522
|
-
"conv-verify-save",
|
|
523
|
-
"msg-verify-save",
|
|
524
|
-
);
|
|
525
|
-
expect(result.isError).toBe(false);
|
|
526
|
-
|
|
527
|
-
const db = getDb();
|
|
528
|
-
const items = db.select().from(memoryItems).all();
|
|
529
|
-
const saved = items.find(
|
|
530
|
-
(i) => i.statement === "User explicitly saved this preference",
|
|
531
|
-
);
|
|
532
|
-
expect(saved).toBeDefined();
|
|
533
|
-
expect(saved!.verificationState).toBe("user_confirmed");
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
test("memory_save in different scopes creates separate items", async () => {
|
|
537
|
-
const { handleMemorySave } = await import("../tools/memory/handlers.js");
|
|
538
|
-
|
|
539
|
-
const sharedArgs = { statement: "I prefer dark mode", kind: "preference" };
|
|
540
|
-
|
|
541
|
-
// Save in the default scope
|
|
542
|
-
const r1 = await handleMemorySave(
|
|
543
|
-
sharedArgs,
|
|
544
|
-
DEFAULT_CONFIG,
|
|
545
|
-
"conv-scope-1",
|
|
546
|
-
"msg-scope-1",
|
|
547
|
-
"default",
|
|
548
|
-
);
|
|
549
|
-
expect(r1.isError).toBe(false);
|
|
550
|
-
expect(r1.content).toContain("Saved to memory");
|
|
551
|
-
|
|
552
|
-
// Save the identical statement in a private scope
|
|
553
|
-
const r2 = await handleMemorySave(
|
|
554
|
-
sharedArgs,
|
|
555
|
-
DEFAULT_CONFIG,
|
|
556
|
-
"conv-scope-2",
|
|
557
|
-
"msg-scope-2",
|
|
558
|
-
"private-abc",
|
|
559
|
-
);
|
|
560
|
-
expect(r2.isError).toBe(false);
|
|
561
|
-
expect(r2.content).toContain("Saved to memory");
|
|
562
|
-
|
|
563
|
-
// Both items should exist with distinct IDs
|
|
564
|
-
const db = getDb();
|
|
565
|
-
const items = db
|
|
566
|
-
.select()
|
|
567
|
-
.from(memoryItems)
|
|
568
|
-
.where(eq(memoryItems.statement, "I prefer dark mode"))
|
|
569
|
-
.all();
|
|
570
|
-
expect(items.length).toBe(2);
|
|
571
|
-
|
|
572
|
-
const scopes = new Set(items.map((i) => i.scopeId));
|
|
573
|
-
expect(scopes.has("default")).toBe(true);
|
|
574
|
-
expect(scopes.has("private-abc")).toBe(true);
|
|
575
|
-
|
|
576
|
-
// Saving the same statement again in default scope should dedup (not create a third)
|
|
577
|
-
const r3 = await handleMemorySave(
|
|
578
|
-
sharedArgs,
|
|
579
|
-
DEFAULT_CONFIG,
|
|
580
|
-
"conv-scope-3",
|
|
581
|
-
"msg-scope-3",
|
|
582
|
-
"default",
|
|
583
|
-
);
|
|
584
|
-
expect(r3.isError).toBe(false);
|
|
585
|
-
expect(r3.content).toContain("already exists");
|
|
586
|
-
|
|
587
|
-
const afterDedup = db
|
|
588
|
-
.select()
|
|
589
|
-
.from(memoryItems)
|
|
590
|
-
.where(eq(memoryItems.statement, "I prefer dark mode"))
|
|
591
|
-
.all();
|
|
592
|
-
expect(afterDedup.length).toBe(2);
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
test("memory_update promotes verificationState to user_confirmed", async () => {
|
|
596
|
-
const db = getDb();
|
|
597
|
-
const now = Date.now();
|
|
598
|
-
const { handleMemoryUpdate } = await import("../tools/memory/handlers.js");
|
|
599
|
-
|
|
600
|
-
// Pre-seed an assistant-inferred item
|
|
601
|
-
db.insert(memoryItems)
|
|
602
|
-
.values({
|
|
603
|
-
id: "item-update-verify",
|
|
604
|
-
kind: "fact",
|
|
605
|
-
subject: "update test",
|
|
606
|
-
statement: "Original assistant inferred statement",
|
|
607
|
-
status: "active",
|
|
608
|
-
confidence: 0.6,
|
|
609
|
-
importance: 0.4,
|
|
610
|
-
fingerprint: "fp-update-verify-original",
|
|
611
|
-
verificationState: "assistant_inferred",
|
|
612
|
-
firstSeenAt: now,
|
|
613
|
-
lastSeenAt: now,
|
|
614
|
-
lastUsedAt: null,
|
|
615
|
-
})
|
|
616
|
-
.run();
|
|
617
|
-
|
|
618
|
-
const result = await handleMemoryUpdate(
|
|
619
|
-
{
|
|
620
|
-
memory_id: "item-update-verify",
|
|
621
|
-
statement: "User corrected statement",
|
|
622
|
-
},
|
|
623
|
-
DEFAULT_CONFIG,
|
|
624
|
-
);
|
|
625
|
-
expect(result.isError).toBe(false);
|
|
626
|
-
|
|
627
|
-
const updated = db
|
|
628
|
-
.select()
|
|
629
|
-
.from(memoryItems)
|
|
630
|
-
.where(eq(memoryItems.id, "item-update-verify"))
|
|
631
|
-
.get();
|
|
632
|
-
expect(updated).toBeDefined();
|
|
633
|
-
expect(updated!.statement).toBe("User corrected statement");
|
|
634
|
-
expect(updated!.verificationState).toBe("user_confirmed");
|
|
635
|
-
});
|
|
636
|
-
|
|
637
|
-
test("private conversation cannot update default-scope item by ID", async () => {
|
|
638
|
-
const db = getDb();
|
|
639
|
-
const now = Date.now();
|
|
640
|
-
const { handleMemoryUpdate } = await import("../tools/memory/handlers.js");
|
|
641
|
-
|
|
642
|
-
// Pre-seed an item in the default scope
|
|
643
|
-
db.insert(memoryItems)
|
|
644
|
-
.values({
|
|
645
|
-
id: "item-default-no-cross",
|
|
646
|
-
kind: "fact",
|
|
647
|
-
subject: "cross-scope update",
|
|
648
|
-
statement: "Original default-scope statement",
|
|
649
|
-
status: "active",
|
|
650
|
-
confidence: 0.8,
|
|
651
|
-
importance: 0.6,
|
|
652
|
-
fingerprint: "fp-default-no-cross",
|
|
653
|
-
verificationState: "assistant_inferred",
|
|
654
|
-
scopeId: "default",
|
|
655
|
-
firstSeenAt: now,
|
|
656
|
-
lastSeenAt: now,
|
|
657
|
-
lastUsedAt: null,
|
|
658
|
-
})
|
|
659
|
-
.run();
|
|
660
|
-
|
|
661
|
-
// Attempt to update from a private scope — should fail with "not found"
|
|
662
|
-
const result = await handleMemoryUpdate(
|
|
663
|
-
{ memory_id: "item-default-no-cross", statement: "Hijacked statement" },
|
|
664
|
-
DEFAULT_CONFIG,
|
|
665
|
-
"private-thread-xyz",
|
|
666
|
-
);
|
|
667
|
-
expect(result.isError).toBe(true);
|
|
668
|
-
expect(result.content).toContain("not found");
|
|
669
|
-
|
|
670
|
-
// Verify the original item is unchanged
|
|
671
|
-
const item = db
|
|
672
|
-
.select()
|
|
673
|
-
.from(memoryItems)
|
|
674
|
-
.where(eq(memoryItems.id, "item-default-no-cross"))
|
|
675
|
-
.get();
|
|
676
|
-
expect(item).toBeDefined();
|
|
677
|
-
expect(item!.statement).toBe("Original default-scope statement");
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
test("standard conversation cannot update private-scope item by ID", async () => {
|
|
681
|
-
const db = getDb();
|
|
682
|
-
const now = Date.now();
|
|
683
|
-
const { handleMemoryUpdate } = await import("../tools/memory/handlers.js");
|
|
684
|
-
|
|
685
|
-
// Pre-seed an item in a private scope
|
|
686
|
-
db.insert(memoryItems)
|
|
687
|
-
.values({
|
|
688
|
-
id: "item-private-no-cross",
|
|
689
|
-
kind: "preference",
|
|
690
|
-
subject: "cross-scope update reverse",
|
|
691
|
-
statement: "Private scope secret preference",
|
|
692
|
-
status: "active",
|
|
693
|
-
confidence: 0.9,
|
|
694
|
-
importance: 0.7,
|
|
695
|
-
fingerprint: "fp-private-no-cross",
|
|
696
|
-
verificationState: "user_confirmed",
|
|
697
|
-
scopeId: "private-thread-abc",
|
|
698
|
-
firstSeenAt: now,
|
|
699
|
-
lastSeenAt: now,
|
|
700
|
-
lastUsedAt: null,
|
|
701
|
-
})
|
|
702
|
-
.run();
|
|
703
|
-
|
|
704
|
-
// Attempt to update from the default scope — should fail with "not found"
|
|
705
|
-
const result = await handleMemoryUpdate(
|
|
706
|
-
{
|
|
707
|
-
memory_id: "item-private-no-cross",
|
|
708
|
-
statement: "Overwritten from default",
|
|
709
|
-
},
|
|
710
|
-
DEFAULT_CONFIG,
|
|
711
|
-
"default",
|
|
712
|
-
);
|
|
713
|
-
expect(result.isError).toBe(true);
|
|
714
|
-
expect(result.content).toContain("not found");
|
|
715
|
-
|
|
716
|
-
// Verify the original item is unchanged
|
|
717
|
-
const item = db
|
|
718
|
-
.select()
|
|
719
|
-
.from(memoryItems)
|
|
720
|
-
.where(eq(memoryItems.id, "item-private-no-cross"))
|
|
721
|
-
.get();
|
|
722
|
-
expect(item).toBeDefined();
|
|
723
|
-
expect(item!.statement).toBe("Private scope secret preference");
|
|
724
|
-
});
|
|
725
|
-
|
|
726
|
-
test("sourceMessageRole=user items default to user_reported verificationState", () => {
|
|
727
|
-
const db = getDb();
|
|
728
|
-
const now = Date.now();
|
|
729
|
-
|
|
730
|
-
db.insert(memoryItems)
|
|
731
|
-
.values({
|
|
732
|
-
id: "item-src-user",
|
|
733
|
-
kind: "preference",
|
|
734
|
-
subject: "editor theme",
|
|
735
|
-
statement: "I prefer dark mode for all my editors",
|
|
736
|
-
status: "active",
|
|
737
|
-
confidence: 0.8,
|
|
738
|
-
importance: 0.7,
|
|
739
|
-
fingerprint: "fp-src-user",
|
|
740
|
-
sourceType: "extraction",
|
|
741
|
-
sourceMessageRole: "user",
|
|
742
|
-
verificationState: "user_reported",
|
|
743
|
-
firstSeenAt: now,
|
|
744
|
-
lastSeenAt: now,
|
|
745
|
-
})
|
|
746
|
-
.run();
|
|
747
|
-
|
|
748
|
-
const item = db
|
|
749
|
-
.select()
|
|
750
|
-
.from(memoryItems)
|
|
751
|
-
.where(eq(memoryItems.id, "item-src-user"))
|
|
752
|
-
.get();
|
|
753
|
-
expect(item).toBeDefined();
|
|
754
|
-
expect(item!.sourceType).toBe("extraction");
|
|
755
|
-
expect(item!.sourceMessageRole).toBe("user");
|
|
756
|
-
expect(item!.verificationState).toBe("user_reported");
|
|
757
|
-
});
|
|
758
|
-
|
|
759
|
-
test("sourceMessageRole=assistant items default to assistant_inferred verificationState", () => {
|
|
760
|
-
const db = getDb();
|
|
761
|
-
const now = Date.now();
|
|
762
|
-
|
|
763
|
-
db.insert(memoryItems)
|
|
764
|
-
.values({
|
|
765
|
-
id: "item-src-assistant",
|
|
766
|
-
kind: "preference",
|
|
767
|
-
subject: "language preference",
|
|
768
|
-
statement: "User prefers TypeScript for all projects",
|
|
769
|
-
status: "active",
|
|
770
|
-
confidence: 0.6,
|
|
771
|
-
importance: 0.5,
|
|
772
|
-
fingerprint: "fp-src-assistant",
|
|
773
|
-
sourceType: "extraction",
|
|
774
|
-
sourceMessageRole: "assistant",
|
|
775
|
-
verificationState: "assistant_inferred",
|
|
776
|
-
firstSeenAt: now,
|
|
777
|
-
lastSeenAt: now,
|
|
778
|
-
})
|
|
779
|
-
.run();
|
|
780
|
-
|
|
781
|
-
const item = db
|
|
782
|
-
.select()
|
|
783
|
-
.from(memoryItems)
|
|
784
|
-
.where(eq(memoryItems.id, "item-src-assistant"))
|
|
785
|
-
.get();
|
|
786
|
-
expect(item).toBeDefined();
|
|
787
|
-
expect(item!.sourceType).toBe("extraction");
|
|
788
|
-
expect(item!.sourceMessageRole).toBe("assistant");
|
|
789
|
-
expect(item!.verificationState).toBe("assistant_inferred");
|
|
790
|
-
});
|
|
791
|
-
|
|
792
|
-
test("verification state defaults to assistant_inferred for legacy rows", () => {
|
|
793
|
-
const db = getDb();
|
|
794
|
-
const raw = (
|
|
795
|
-
db as unknown as {
|
|
796
|
-
$client: {
|
|
797
|
-
query: (q: string) => { get: (...params: unknown[]) => unknown };
|
|
798
|
-
};
|
|
799
|
-
}
|
|
800
|
-
).$client;
|
|
801
|
-
// Simulate a legacy row without explicit verification_state
|
|
802
|
-
raw
|
|
803
|
-
.query(
|
|
804
|
-
`
|
|
805
|
-
INSERT INTO memory_items (id, kind, subject, statement, status, confidence, fingerprint, first_seen_at, last_seen_at)
|
|
806
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
807
|
-
`,
|
|
808
|
-
)
|
|
809
|
-
.get(
|
|
810
|
-
"item-legacy-verify",
|
|
811
|
-
"fact",
|
|
812
|
-
"Legacy item",
|
|
813
|
-
"This is a legacy item",
|
|
814
|
-
"active",
|
|
815
|
-
0.5,
|
|
816
|
-
"fp-legacy-verify",
|
|
817
|
-
Date.now(),
|
|
818
|
-
Date.now(),
|
|
819
|
-
);
|
|
820
|
-
|
|
821
|
-
const item = db
|
|
822
|
-
.select()
|
|
823
|
-
.from(memoryItems)
|
|
824
|
-
.where(eq(memoryItems.id, "item-legacy-verify"))
|
|
825
|
-
.get();
|
|
826
|
-
expect(item).toBeDefined();
|
|
827
|
-
expect(item!.verificationState).toBe("assistant_inferred");
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
test("recent segment helper returns newest segments first", () => {
|
|
831
|
-
const db = getDb();
|
|
832
|
-
db.insert(conversations)
|
|
833
|
-
.values({
|
|
834
|
-
id: "conv-recent",
|
|
835
|
-
title: null,
|
|
836
|
-
createdAt: 2_200,
|
|
837
|
-
updatedAt: 2_200,
|
|
838
|
-
totalInputTokens: 0,
|
|
839
|
-
totalOutputTokens: 0,
|
|
840
|
-
totalEstimatedCost: 0,
|
|
841
|
-
contextSummary: null,
|
|
842
|
-
contextCompactedMessageCount: 0,
|
|
843
|
-
contextCompactedAt: null,
|
|
844
|
-
})
|
|
845
|
-
.run();
|
|
846
|
-
db.insert(messages)
|
|
847
|
-
.values([
|
|
848
|
-
{
|
|
849
|
-
id: "msg-recent-1",
|
|
850
|
-
conversationId: "conv-recent",
|
|
851
|
-
role: "user",
|
|
852
|
-
content: JSON.stringify([{ type: "text", text: "old" }]),
|
|
853
|
-
createdAt: 2_201,
|
|
854
|
-
},
|
|
855
|
-
{
|
|
856
|
-
id: "msg-recent-2",
|
|
857
|
-
conversationId: "conv-recent",
|
|
858
|
-
role: "user",
|
|
859
|
-
content: JSON.stringify([{ type: "text", text: "newer" }]),
|
|
860
|
-
createdAt: 2_202,
|
|
861
|
-
},
|
|
862
|
-
{
|
|
863
|
-
id: "msg-recent-3",
|
|
864
|
-
conversationId: "conv-recent",
|
|
865
|
-
role: "user",
|
|
866
|
-
content: JSON.stringify([{ type: "text", text: "newest" }]),
|
|
867
|
-
createdAt: 2_203,
|
|
868
|
-
},
|
|
869
|
-
])
|
|
870
|
-
.run();
|
|
871
|
-
db.run(`
|
|
872
|
-
INSERT INTO memory_segments (
|
|
873
|
-
id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
|
|
874
|
-
) VALUES
|
|
875
|
-
('seg-recent-1', 'msg-recent-1', 'conv-recent', 'user', 0, 'old', 1, 2201, 2201),
|
|
876
|
-
('seg-recent-2', 'msg-recent-2', 'conv-recent', 'user', 0, 'newer', 1, 2202, 2202),
|
|
877
|
-
('seg-recent-3', 'msg-recent-3', 'conv-recent', 'user', 0, 'newest', 1, 2203, 2203)
|
|
878
|
-
`);
|
|
879
|
-
|
|
880
|
-
const recent = getRecentSegmentsForConversation("conv-recent", 2);
|
|
881
|
-
expect(recent).toHaveLength(2);
|
|
882
|
-
expect(recent[0]?.id).toBe("seg-recent-3");
|
|
883
|
-
expect(recent[1]?.id).toBe("seg-recent-2");
|
|
884
|
-
});
|
|
885
|
-
|
|
886
|
-
test("explicit ollama memory embedding provider is honored without extra ollama config", async () => {
|
|
887
|
-
const config = {
|
|
888
|
-
...DEFAULT_CONFIG,
|
|
889
|
-
provider: "anthropic" as const,
|
|
890
|
-
memory: {
|
|
891
|
-
...DEFAULT_CONFIG.memory,
|
|
892
|
-
embeddings: {
|
|
893
|
-
...DEFAULT_CONFIG.memory.embeddings,
|
|
894
|
-
provider: "ollama" as const,
|
|
895
|
-
},
|
|
896
|
-
},
|
|
897
|
-
};
|
|
898
|
-
|
|
899
|
-
const selection = await selectEmbeddingBackend(config);
|
|
900
|
-
expect(selection.backend?.provider).toBe("ollama");
|
|
901
|
-
expect(selection.reason).toBeNull();
|
|
902
|
-
});
|
|
903
|
-
|
|
904
|
-
test("memory backfill request resumes by default and only restarts when forced", () => {
|
|
905
|
-
const db = getDb();
|
|
906
|
-
const resumeJobId = requestMemoryBackfill();
|
|
907
|
-
const forceJobId = requestMemoryBackfill(true);
|
|
908
|
-
|
|
909
|
-
const resumeRow = db
|
|
910
|
-
.select()
|
|
911
|
-
.from(memoryJobs)
|
|
912
|
-
.where(eq(memoryJobs.id, resumeJobId))
|
|
913
|
-
.get();
|
|
914
|
-
const forceRow = db
|
|
915
|
-
.select()
|
|
916
|
-
.from(memoryJobs)
|
|
917
|
-
.where(eq(memoryJobs.id, forceJobId))
|
|
918
|
-
.get();
|
|
919
|
-
|
|
920
|
-
expect(resumeRow).not.toBeNull();
|
|
921
|
-
expect(forceRow).not.toBeNull();
|
|
922
|
-
expect(JSON.parse(resumeRow?.payload ?? "{}")).toMatchObject({
|
|
923
|
-
force: false,
|
|
924
|
-
});
|
|
925
|
-
expect(JSON.parse(forceRow?.payload ?? "{}")).toMatchObject({
|
|
926
|
-
force: true,
|
|
927
|
-
});
|
|
928
|
-
});
|
|
929
|
-
|
|
930
|
-
test("scheduled cleanup enqueue respects throttle and config retention values", () => {
|
|
931
|
-
const db = getDb();
|
|
932
|
-
const originalCleanup = { ...TEST_CONFIG.memory.cleanup };
|
|
933
|
-
TEST_CONFIG.memory.cleanup.enabled = true;
|
|
934
|
-
TEST_CONFIG.memory.cleanup.enqueueIntervalMs = 1_000;
|
|
935
|
-
TEST_CONFIG.memory.cleanup.supersededItemRetentionMs = 67_890;
|
|
936
|
-
|
|
937
|
-
try {
|
|
938
|
-
const first = maybeEnqueueScheduledCleanupJobs(TEST_CONFIG, 5_000);
|
|
939
|
-
expect(first).toBe(true);
|
|
940
|
-
|
|
941
|
-
const tooSoon = maybeEnqueueScheduledCleanupJobs(TEST_CONFIG, 5_500);
|
|
942
|
-
expect(tooSoon).toBe(false);
|
|
943
|
-
|
|
944
|
-
const jobsAfterFirst = db.select().from(memoryJobs).all();
|
|
945
|
-
const supersededJob = jobsAfterFirst.find(
|
|
946
|
-
(row) => row.type === "cleanup_stale_superseded_items",
|
|
947
|
-
);
|
|
948
|
-
expect(supersededJob).toBeDefined();
|
|
949
|
-
expect(JSON.parse(supersededJob?.payload ?? "{}")).toMatchObject({
|
|
950
|
-
retentionMs: 67_890,
|
|
951
|
-
});
|
|
952
|
-
|
|
953
|
-
const secondWindow = maybeEnqueueScheduledCleanupJobs(TEST_CONFIG, 6_500);
|
|
954
|
-
expect(secondWindow).toBe(true);
|
|
955
|
-
const jobsAfterSecond = db.select().from(memoryJobs).all();
|
|
956
|
-
expect(
|
|
957
|
-
jobsAfterSecond.filter(
|
|
958
|
-
(row) => row.type === "cleanup_stale_superseded_items",
|
|
959
|
-
).length,
|
|
960
|
-
).toBe(1);
|
|
961
|
-
} finally {
|
|
962
|
-
TEST_CONFIG.memory.cleanup = originalCleanup;
|
|
963
|
-
}
|
|
964
|
-
});
|
|
965
|
-
|
|
966
|
-
test("cleanup_stale_superseded_items removes stale superseded rows and embeddings", async () => {
|
|
967
|
-
const db = getDb();
|
|
968
|
-
const now = Date.now();
|
|
969
|
-
|
|
970
|
-
db.insert(memoryItems)
|
|
971
|
-
.values([
|
|
972
|
-
{
|
|
973
|
-
id: "cleanup-stale-item",
|
|
974
|
-
kind: "decision",
|
|
975
|
-
subject: "deploy strategy",
|
|
976
|
-
statement: "Deploy manually every Friday.",
|
|
977
|
-
status: "superseded",
|
|
978
|
-
confidence: 0.7,
|
|
979
|
-
fingerprint: "fp-cleanup-stale-item",
|
|
980
|
-
verificationState: "assistant_inferred",
|
|
981
|
-
scopeId: "default",
|
|
982
|
-
firstSeenAt: now - 200_000,
|
|
983
|
-
lastSeenAt: now - 200_000,
|
|
984
|
-
invalidAt: now - 200_000,
|
|
985
|
-
},
|
|
986
|
-
{
|
|
987
|
-
id: "cleanup-recent-item",
|
|
988
|
-
kind: "decision",
|
|
989
|
-
subject: "deploy strategy",
|
|
990
|
-
statement: "Deploy continuously via CI.",
|
|
991
|
-
status: "superseded",
|
|
992
|
-
confidence: 0.7,
|
|
993
|
-
fingerprint: "fp-cleanup-recent-item",
|
|
994
|
-
verificationState: "assistant_inferred",
|
|
995
|
-
scopeId: "default",
|
|
996
|
-
firstSeenAt: now - 200_000,
|
|
997
|
-
lastSeenAt: now - 200_000,
|
|
998
|
-
invalidAt: now - 100,
|
|
999
|
-
},
|
|
1000
|
-
])
|
|
1001
|
-
.run();
|
|
1002
|
-
|
|
1003
|
-
db.insert(memoryEmbeddings)
|
|
1004
|
-
.values([
|
|
1005
|
-
{
|
|
1006
|
-
id: "cleanup-embed-stale",
|
|
1007
|
-
targetType: "item",
|
|
1008
|
-
targetId: "cleanup-stale-item",
|
|
1009
|
-
provider: "openai",
|
|
1010
|
-
model: "text-embedding-3-small",
|
|
1011
|
-
dimensions: 3,
|
|
1012
|
-
vectorBlob: vectorToBlob([0, 0, 0]),
|
|
1013
|
-
createdAt: now - 1000,
|
|
1014
|
-
updatedAt: now - 1000,
|
|
1015
|
-
},
|
|
1016
|
-
{
|
|
1017
|
-
id: "cleanup-embed-recent",
|
|
1018
|
-
targetType: "item",
|
|
1019
|
-
targetId: "cleanup-recent-item",
|
|
1020
|
-
provider: "openai",
|
|
1021
|
-
model: "text-embedding-3-small",
|
|
1022
|
-
dimensions: 3,
|
|
1023
|
-
vectorBlob: vectorToBlob([0, 0, 0]),
|
|
1024
|
-
createdAt: now - 1000,
|
|
1025
|
-
updatedAt: now - 1000,
|
|
1026
|
-
},
|
|
1027
|
-
])
|
|
1028
|
-
.run();
|
|
1029
|
-
|
|
1030
|
-
enqueueMemoryJob("cleanup_stale_superseded_items", { retentionMs: 10_000 });
|
|
1031
|
-
const processed = await runMemoryJobsOnce();
|
|
1032
|
-
expect(processed).toBe(1);
|
|
1033
|
-
|
|
1034
|
-
const staleItem = db
|
|
1035
|
-
.select()
|
|
1036
|
-
.from(memoryItems)
|
|
1037
|
-
.where(eq(memoryItems.id, "cleanup-stale-item"))
|
|
1038
|
-
.get();
|
|
1039
|
-
const recentItem = db
|
|
1040
|
-
.select()
|
|
1041
|
-
.from(memoryItems)
|
|
1042
|
-
.where(eq(memoryItems.id, "cleanup-recent-item"))
|
|
1043
|
-
.get();
|
|
1044
|
-
const staleEmbedding = db
|
|
1045
|
-
.select()
|
|
1046
|
-
.from(memoryEmbeddings)
|
|
1047
|
-
.where(eq(memoryEmbeddings.id, "cleanup-embed-stale"))
|
|
1048
|
-
.get();
|
|
1049
|
-
const recentEmbedding = db
|
|
1050
|
-
.select()
|
|
1051
|
-
.from(memoryEmbeddings)
|
|
1052
|
-
.where(eq(memoryEmbeddings.id, "cleanup-embed-recent"))
|
|
1053
|
-
.get();
|
|
1054
|
-
|
|
1055
|
-
expect(staleItem).toBeUndefined();
|
|
1056
|
-
expect(recentItem).toBeDefined();
|
|
1057
|
-
expect(staleEmbedding).toBeUndefined();
|
|
1058
|
-
expect(recentEmbedding).toBeDefined();
|
|
1059
|
-
});
|
|
1060
|
-
|
|
1061
|
-
test("memory admin status reports cleanup backlog and 24h throughput metrics", async () => {
|
|
1062
|
-
const db = getDb();
|
|
1063
|
-
const now = Date.now();
|
|
1064
|
-
const yesterday = now - 20 * 60 * 60 * 1000;
|
|
1065
|
-
const old = now - 40 * 60 * 60 * 1000;
|
|
1066
|
-
|
|
1067
|
-
db.insert(memoryJobs)
|
|
1068
|
-
.values([
|
|
1069
|
-
{
|
|
1070
|
-
id: "cleanup-status-running-superseded",
|
|
1071
|
-
type: "cleanup_stale_superseded_items",
|
|
1072
|
-
payload: "{}",
|
|
1073
|
-
status: "running",
|
|
1074
|
-
attempts: 0,
|
|
1075
|
-
deferrals: 0,
|
|
1076
|
-
runAfter: now,
|
|
1077
|
-
lastError: null,
|
|
1078
|
-
createdAt: now,
|
|
1079
|
-
updatedAt: now,
|
|
1080
|
-
},
|
|
1081
|
-
{
|
|
1082
|
-
id: "cleanup-status-completed-superseded-recent",
|
|
1083
|
-
type: "cleanup_stale_superseded_items",
|
|
1084
|
-
payload: "{}",
|
|
1085
|
-
status: "completed",
|
|
1086
|
-
attempts: 1,
|
|
1087
|
-
deferrals: 0,
|
|
1088
|
-
runAfter: yesterday,
|
|
1089
|
-
lastError: null,
|
|
1090
|
-
createdAt: yesterday,
|
|
1091
|
-
updatedAt: yesterday,
|
|
1092
|
-
},
|
|
1093
|
-
{
|
|
1094
|
-
id: "cleanup-status-completed-superseded-old",
|
|
1095
|
-
type: "cleanup_stale_superseded_items",
|
|
1096
|
-
payload: "{}",
|
|
1097
|
-
status: "completed",
|
|
1098
|
-
attempts: 1,
|
|
1099
|
-
deferrals: 0,
|
|
1100
|
-
runAfter: old,
|
|
1101
|
-
lastError: null,
|
|
1102
|
-
createdAt: old,
|
|
1103
|
-
updatedAt: old,
|
|
1104
|
-
},
|
|
1105
|
-
])
|
|
1106
|
-
.run();
|
|
1107
|
-
|
|
1108
|
-
const status = await getMemorySystemStatus();
|
|
1109
|
-
expect(status.cleanup.supersededBacklog).toBe(1);
|
|
1110
|
-
expect(status.cleanup.supersededCompleted24h).toBe(1);
|
|
1111
|
-
});
|
|
1112
|
-
|
|
1113
|
-
test("requestMemoryCleanup queues cleanup job", () => {
|
|
1114
|
-
const db = getDb();
|
|
1115
|
-
const queued = requestMemoryCleanup(9_999);
|
|
1116
|
-
expect(queued.staleSupersededItemsJobId).toBeTruthy();
|
|
1117
|
-
|
|
1118
|
-
const supersededRow = db
|
|
1119
|
-
.select()
|
|
1120
|
-
.from(memoryJobs)
|
|
1121
|
-
.where(eq(memoryJobs.id, queued.staleSupersededItemsJobId))
|
|
1122
|
-
.get();
|
|
1123
|
-
expect(supersededRow?.type).toBe("cleanup_stale_superseded_items");
|
|
1124
|
-
});
|
|
1125
|
-
|
|
1126
|
-
test("memory recall token budgeting includes recall marker overhead", async () => {
|
|
1127
|
-
const db = getDb();
|
|
1128
|
-
const createdAt = 1_700_000_300_000;
|
|
1129
|
-
db.insert(conversations)
|
|
1130
|
-
.values({
|
|
1131
|
-
id: "conv-budget",
|
|
1132
|
-
title: null,
|
|
1133
|
-
createdAt,
|
|
1134
|
-
updatedAt: createdAt,
|
|
1135
|
-
totalInputTokens: 0,
|
|
1136
|
-
totalOutputTokens: 0,
|
|
1137
|
-
totalEstimatedCost: 0,
|
|
1138
|
-
contextSummary: null,
|
|
1139
|
-
contextCompactedMessageCount: 0,
|
|
1140
|
-
contextCompactedAt: null,
|
|
1141
|
-
})
|
|
1142
|
-
.run();
|
|
1143
|
-
db.insert(messages)
|
|
1144
|
-
.values({
|
|
1145
|
-
id: "msg-budget",
|
|
1146
|
-
conversationId: "conv-budget",
|
|
1147
|
-
role: "user",
|
|
1148
|
-
content: JSON.stringify([
|
|
1149
|
-
{ type: "text", text: "remember budget token sentinel" },
|
|
1150
|
-
]),
|
|
1151
|
-
createdAt,
|
|
1152
|
-
})
|
|
1153
|
-
.run();
|
|
1154
|
-
db.run(`
|
|
1155
|
-
INSERT INTO memory_segments (
|
|
1156
|
-
id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
|
|
1157
|
-
) VALUES (
|
|
1158
|
-
'seg-budget', 'msg-budget', 'conv-budget', 'user', 0, 'remember budget token sentinel', 6, ${createdAt}, ${createdAt}
|
|
1159
|
-
)
|
|
1160
|
-
`);
|
|
1161
|
-
|
|
1162
|
-
const candidateLine =
|
|
1163
|
-
"- <kind>segment:seg-budget</kind> remember budget token sentinel";
|
|
1164
|
-
const lineOnlyTokens = estimateTextTokens(candidateLine);
|
|
1165
|
-
const fullRecallTokens = estimateTextTokens(
|
|
1166
|
-
'<memory source="long_term_memory" confidence="approximate">\n' +
|
|
1167
|
-
`## Relevant Context\n${candidateLine}\n</memory>`,
|
|
1168
|
-
);
|
|
1169
|
-
expect(fullRecallTokens).toBeGreaterThan(lineOnlyTokens);
|
|
1170
|
-
|
|
1171
|
-
const config = {
|
|
1172
|
-
...DEFAULT_CONFIG,
|
|
1173
|
-
memory: {
|
|
1174
|
-
...DEFAULT_CONFIG.memory,
|
|
1175
|
-
embeddings: {
|
|
1176
|
-
...DEFAULT_CONFIG.memory.embeddings,
|
|
1177
|
-
required: false,
|
|
1178
|
-
},
|
|
1179
|
-
retrieval: {
|
|
1180
|
-
...DEFAULT_CONFIG.memory.retrieval,
|
|
1181
|
-
maxInjectTokens: lineOnlyTokens,
|
|
1182
|
-
},
|
|
1183
|
-
},
|
|
1184
|
-
};
|
|
1185
|
-
|
|
1186
|
-
const recall = await buildMemoryRecall(
|
|
1187
|
-
"budget sentinel",
|
|
1188
|
-
"conv-budget",
|
|
1189
|
-
config,
|
|
1190
|
-
);
|
|
1191
|
-
expect(recall.injectedText).toBe("");
|
|
1192
|
-
expect(recall.injectedTokens).toBe(0);
|
|
1193
|
-
});
|
|
1194
|
-
|
|
1195
|
-
test("memory recall respects maxInjectTokensOverride when provided", async () => {
|
|
1196
|
-
const db = getDb();
|
|
1197
|
-
const createdAt = 1_700_000_301_000;
|
|
1198
|
-
db.insert(conversations)
|
|
1199
|
-
.values({
|
|
1200
|
-
id: "conv-budget-override",
|
|
1201
|
-
title: null,
|
|
1202
|
-
createdAt,
|
|
1203
|
-
updatedAt: createdAt,
|
|
1204
|
-
totalInputTokens: 0,
|
|
1205
|
-
totalOutputTokens: 0,
|
|
1206
|
-
totalEstimatedCost: 0,
|
|
1207
|
-
contextSummary: null,
|
|
1208
|
-
contextCompactedMessageCount: 0,
|
|
1209
|
-
contextCompactedAt: null,
|
|
1210
|
-
})
|
|
1211
|
-
.run();
|
|
1212
|
-
|
|
1213
|
-
for (let i = 0; i < 4; i++) {
|
|
1214
|
-
const msgId = `msg-budget-override-${i}`;
|
|
1215
|
-
const segId = `seg-budget-override-${i}`;
|
|
1216
|
-
const text = `budget override sentinel item ${i} with enough text to exceed tiny limits`;
|
|
1217
|
-
db.insert(messages)
|
|
1218
|
-
.values({
|
|
1219
|
-
id: msgId,
|
|
1220
|
-
conversationId: "conv-budget-override",
|
|
1221
|
-
role: "user",
|
|
1222
|
-
content: JSON.stringify([{ type: "text", text }]),
|
|
1223
|
-
createdAt: createdAt + i,
|
|
1224
|
-
})
|
|
1225
|
-
.run();
|
|
1226
|
-
db.run(`
|
|
1227
|
-
INSERT INTO memory_segments (
|
|
1228
|
-
id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
|
|
1229
|
-
) VALUES (
|
|
1230
|
-
'${segId}', '${msgId}', 'conv-budget-override', 'user', 0, '${text}', 20, ${
|
|
1231
|
-
createdAt + i
|
|
1232
|
-
}, ${createdAt + i}
|
|
1233
|
-
)
|
|
1234
|
-
`);
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
const config = {
|
|
1238
|
-
...DEFAULT_CONFIG,
|
|
1239
|
-
memory: {
|
|
1240
|
-
...DEFAULT_CONFIG.memory,
|
|
1241
|
-
embeddings: {
|
|
1242
|
-
...DEFAULT_CONFIG.memory.embeddings,
|
|
1243
|
-
provider: "openai" as const,
|
|
1244
|
-
required: false,
|
|
1245
|
-
},
|
|
1246
|
-
retrieval: {
|
|
1247
|
-
...DEFAULT_CONFIG.memory.retrieval,
|
|
1248
|
-
maxInjectTokens: 5000,
|
|
1249
|
-
},
|
|
1250
|
-
},
|
|
1251
|
-
};
|
|
1252
|
-
|
|
1253
|
-
const override = 120;
|
|
1254
|
-
const recall = await buildMemoryRecall(
|
|
1255
|
-
"budget override sentinel",
|
|
1256
|
-
"conv-budget-override",
|
|
1257
|
-
config,
|
|
1258
|
-
{ maxInjectTokensOverride: override },
|
|
1259
|
-
);
|
|
1260
|
-
expect(recall.injectedTokens).toBeLessThanOrEqual(override);
|
|
1261
|
-
});
|
|
1262
|
-
|
|
1263
|
-
test("claimMemoryJobs only returns rows it actually claimed", () => {
|
|
1264
|
-
const db = getDb();
|
|
1265
|
-
const jobId = enqueueMemoryJob("build_conversation_summary", {
|
|
1266
|
-
conversationId: "conv-lock",
|
|
1267
|
-
});
|
|
1268
|
-
db.run(`
|
|
1269
|
-
CREATE TEMP TRIGGER memory_jobs_claim_ignore
|
|
1270
|
-
BEFORE UPDATE ON memory_jobs
|
|
1271
|
-
WHEN NEW.status = 'running' AND OLD.id = '${jobId}'
|
|
1272
|
-
BEGIN
|
|
1273
|
-
SELECT RAISE(IGNORE);
|
|
1274
|
-
END;
|
|
1275
|
-
`);
|
|
1276
|
-
|
|
1277
|
-
try {
|
|
1278
|
-
const claimed = claimMemoryJobs(10);
|
|
1279
|
-
expect(claimed).toHaveLength(0);
|
|
1280
|
-
const row = db
|
|
1281
|
-
.select()
|
|
1282
|
-
.from(memoryJobs)
|
|
1283
|
-
.where(eq(memoryJobs.id, jobId))
|
|
1284
|
-
.get();
|
|
1285
|
-
expect(row?.status).toBe("pending");
|
|
1286
|
-
} finally {
|
|
1287
|
-
db.run("DROP TRIGGER IF EXISTS memory_jobs_claim_ignore");
|
|
1288
|
-
}
|
|
1289
|
-
});
|
|
1290
|
-
|
|
1291
|
-
test("formatAbsoluteTime returns YYYY-MM-DD HH:mm TZ format", () => {
|
|
1292
|
-
// Use a fixed epoch-ms value; the rendered string depends on the local timezone,
|
|
1293
|
-
// so we verify the structural format rather than exact values.
|
|
1294
|
-
const epochMs = 1_707_850_200_000; // 2024-02-13 in UTC
|
|
1295
|
-
const result = formatAbsoluteTime(epochMs);
|
|
1296
|
-
|
|
1297
|
-
// Should match pattern: YYYY-MM-DD HH:mm <TZ abbreviation>
|
|
1298
|
-
expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2} \S+$/);
|
|
1299
|
-
|
|
1300
|
-
// Year should be 2024
|
|
1301
|
-
expect(result).toContain("2024-02");
|
|
1302
|
-
});
|
|
1303
|
-
|
|
1304
|
-
test("formatAbsoluteTime uses local timezone abbreviation", () => {
|
|
1305
|
-
const epochMs = Date.now();
|
|
1306
|
-
const result = formatAbsoluteTime(epochMs);
|
|
1307
|
-
|
|
1308
|
-
// Extract the TZ part from the result
|
|
1309
|
-
const parts = result.split(" ");
|
|
1310
|
-
const tz = parts[parts.length - 1];
|
|
1311
|
-
|
|
1312
|
-
// The TZ abbreviation should be a non-empty string (e.g. PST, EST, UTC, GMT+8)
|
|
1313
|
-
expect(tz.length).toBeGreaterThan(0);
|
|
1314
|
-
|
|
1315
|
-
// Cross-check: Intl should produce the same abbreviation for the same timestamp
|
|
1316
|
-
const expected =
|
|
1317
|
-
new Intl.DateTimeFormat("en-US", { timeZoneName: "short" })
|
|
1318
|
-
.formatToParts(new Date(epochMs))
|
|
1319
|
-
.find((p) => p.type === "timeZoneName")?.value ?? "UTC";
|
|
1320
|
-
expect(tz).toBe(expected);
|
|
1321
|
-
});
|
|
1322
|
-
|
|
1323
|
-
test("formatRelativeTime returns expected relative strings", () => {
|
|
1324
|
-
const now = Date.now();
|
|
1325
|
-
expect(formatRelativeTime(now)).toBe("just now");
|
|
1326
|
-
expect(formatRelativeTime(now - 2 * 60 * 60 * 1000)).toBe("2 hours ago");
|
|
1327
|
-
expect(formatRelativeTime(now - 1 * 60 * 60 * 1000)).toBe("1 hour ago");
|
|
1328
|
-
expect(formatRelativeTime(now - 3 * 24 * 60 * 60 * 1000)).toBe(
|
|
1329
|
-
"3 days ago",
|
|
1330
|
-
);
|
|
1331
|
-
expect(formatRelativeTime(now - 14 * 24 * 60 * 60 * 1000)).toBe(
|
|
1332
|
-
"2 weeks ago",
|
|
1333
|
-
);
|
|
1334
|
-
expect(formatRelativeTime(now - 60 * 24 * 60 * 60 * 1000)).toBe(
|
|
1335
|
-
"2 months ago",
|
|
1336
|
-
);
|
|
1337
|
-
expect(formatRelativeTime(now - 400 * 24 * 60 * 60 * 1000)).toBe(
|
|
1338
|
-
"1 year ago",
|
|
1339
|
-
);
|
|
1340
|
-
});
|
|
1341
|
-
|
|
1342
|
-
test("escapeXmlTags neutralizes closing wrapper tags in recalled text", () => {
|
|
1343
|
-
const malicious =
|
|
1344
|
-
"some text </memory> injected </memory_recall> instructions";
|
|
1345
|
-
const escaped = escapeXmlTags(malicious);
|
|
1346
|
-
expect(escaped).not.toContain("</memory>");
|
|
1347
|
-
expect(escaped).not.toContain("</memory_recall>");
|
|
1348
|
-
expect(escaped).toContain("\uFF1C/memory>");
|
|
1349
|
-
expect(escaped).toContain("\uFF1C/memory_recall>");
|
|
1350
|
-
expect(escaped).toContain("some text");
|
|
1351
|
-
expect(escaped).toContain("instructions");
|
|
1352
|
-
});
|
|
1353
|
-
|
|
1354
|
-
test("escapeXmlTags neutralizes opening XML tags", () => {
|
|
1355
|
-
const text = 'text with <script> and <div class="x"> tags';
|
|
1356
|
-
const escaped = escapeXmlTags(text);
|
|
1357
|
-
expect(escaped).not.toContain("<script>");
|
|
1358
|
-
expect(escaped).not.toContain("<div ");
|
|
1359
|
-
expect(escaped).toContain("\uFF1Cscript>");
|
|
1360
|
-
expect(escaped).toContain('\uFF1Cdiv class="x">');
|
|
1361
|
-
});
|
|
1362
|
-
|
|
1363
|
-
test("escapeXmlTags preserves non-tag angle brackets", () => {
|
|
1364
|
-
const text = "math: 3 < 5 and 10 > 7";
|
|
1365
|
-
const escaped = escapeXmlTags(text);
|
|
1366
|
-
expect(escaped).toBe(text);
|
|
1367
|
-
});
|
|
1368
|
-
|
|
1369
|
-
test("escapeXmlTags handles self-closing tags", () => {
|
|
1370
|
-
const text = "a <br/> tag";
|
|
1371
|
-
const escaped = escapeXmlTags(text);
|
|
1372
|
-
expect(escaped).not.toContain("<br/>");
|
|
1373
|
-
expect(escaped).toContain("\uFF1Cbr/>");
|
|
1374
|
-
});
|
|
1375
|
-
|
|
1376
|
-
test("sweepStaleItems marks deeply stale items as invalid", () => {
|
|
1377
|
-
const db = getDb();
|
|
1378
|
-
const now = Date.now();
|
|
1379
|
-
const MS_PER_DAY = 86_400_000;
|
|
1380
|
-
|
|
1381
|
-
// Item 100 days old with kind=event (default maxAgeDays=30, so 2x=60 — past the deep-stale threshold)
|
|
1382
|
-
db.insert(memoryItems)
|
|
1383
|
-
.values({
|
|
1384
|
-
id: "item-deeply-stale",
|
|
1385
|
-
kind: "event",
|
|
1386
|
-
subject: "sweep test",
|
|
1387
|
-
statement: "Old event that should be swept",
|
|
1388
|
-
status: "active",
|
|
1389
|
-
confidence: 0.8,
|
|
1390
|
-
importance: 0.5,
|
|
1391
|
-
fingerprint: "fp-sweep-stale",
|
|
1392
|
-
firstSeenAt: now - 100 * MS_PER_DAY,
|
|
1393
|
-
lastSeenAt: now - 100 * MS_PER_DAY,
|
|
1394
|
-
accessCount: 0,
|
|
1395
|
-
verificationState: "assistant_inferred",
|
|
1396
|
-
})
|
|
1397
|
-
.run();
|
|
1398
|
-
|
|
1399
|
-
// Fresh event item — should NOT be swept
|
|
1400
|
-
db.insert(memoryItems)
|
|
1401
|
-
.values({
|
|
1402
|
-
id: "item-sweep-fresh",
|
|
1403
|
-
kind: "event",
|
|
1404
|
-
subject: "sweep test",
|
|
1405
|
-
statement: "Recent event that should not be swept",
|
|
1406
|
-
status: "active",
|
|
1407
|
-
confidence: 0.8,
|
|
1408
|
-
importance: 0.5,
|
|
1409
|
-
fingerprint: "fp-sweep-fresh",
|
|
1410
|
-
firstSeenAt: now - 5 * MS_PER_DAY,
|
|
1411
|
-
lastSeenAt: now - 5 * MS_PER_DAY,
|
|
1412
|
-
accessCount: 0,
|
|
1413
|
-
verificationState: "assistant_inferred",
|
|
1414
|
-
})
|
|
1415
|
-
.run();
|
|
1416
|
-
|
|
1417
|
-
const marked = sweepStaleItems(DEFAULT_CONFIG);
|
|
1418
|
-
expect(marked).toBeGreaterThanOrEqual(1);
|
|
1419
|
-
|
|
1420
|
-
const staleItem = db
|
|
1421
|
-
.select()
|
|
1422
|
-
.from(memoryItems)
|
|
1423
|
-
.where(eq(memoryItems.id, "item-deeply-stale"))
|
|
1424
|
-
.get();
|
|
1425
|
-
expect(staleItem).toBeDefined();
|
|
1426
|
-
expect(staleItem!.invalidAt).not.toBeNull();
|
|
1427
|
-
|
|
1428
|
-
const freshItem = db
|
|
1429
|
-
.select()
|
|
1430
|
-
.from(memoryItems)
|
|
1431
|
-
.where(eq(memoryItems.id, "item-sweep-fresh"))
|
|
1432
|
-
.get();
|
|
1433
|
-
expect(freshItem).toBeDefined();
|
|
1434
|
-
expect(freshItem!.invalidAt).toBeNull();
|
|
1435
|
-
});
|
|
1436
|
-
|
|
1437
|
-
test("sweepStaleItems shields items with recent lastUsedAt", () => {
|
|
1438
|
-
const db = getDb();
|
|
1439
|
-
const now = Date.now();
|
|
1440
|
-
const MS_PER_DAY = 86_400_000;
|
|
1441
|
-
|
|
1442
|
-
// Old event (100 days) but recently retrieved (lastUsedAt = 2 days ago)
|
|
1443
|
-
// reinforcementShieldDays defaults to 14, so this should be shielded
|
|
1444
|
-
db.insert(memoryItems)
|
|
1445
|
-
.values({
|
|
1446
|
-
id: "item-sweep-shielded",
|
|
1447
|
-
kind: "event",
|
|
1448
|
-
subject: "sweep shield test",
|
|
1449
|
-
statement: "Old event that was recently used",
|
|
1450
|
-
status: "active",
|
|
1451
|
-
confidence: 0.8,
|
|
1452
|
-
importance: 0.5,
|
|
1453
|
-
fingerprint: "fp-sweep-shielded",
|
|
1454
|
-
firstSeenAt: now - 100 * MS_PER_DAY,
|
|
1455
|
-
lastSeenAt: now - 100 * MS_PER_DAY,
|
|
1456
|
-
lastUsedAt: now - 2 * MS_PER_DAY,
|
|
1457
|
-
accessCount: 3,
|
|
1458
|
-
verificationState: "assistant_inferred",
|
|
1459
|
-
})
|
|
1460
|
-
.run();
|
|
1461
|
-
|
|
1462
|
-
const marked = sweepStaleItems(DEFAULT_CONFIG);
|
|
1463
|
-
|
|
1464
|
-
// Sweep ran but shielded item was not marked — should return 0
|
|
1465
|
-
expect(marked).toBe(0);
|
|
1466
|
-
|
|
1467
|
-
const shieldedItem = db
|
|
1468
|
-
.select()
|
|
1469
|
-
.from(memoryItems)
|
|
1470
|
-
.where(eq(memoryItems.id, "item-sweep-shielded"))
|
|
1471
|
-
.get();
|
|
1472
|
-
expect(shieldedItem).toBeDefined();
|
|
1473
|
-
expect(shieldedItem!.invalidAt).toBeNull();
|
|
1474
|
-
});
|
|
1475
|
-
|
|
1476
|
-
test("scope columns: memory items default to scope_id=default", () => {
|
|
1477
|
-
const db = getDb();
|
|
1478
|
-
const now = Date.now();
|
|
1479
|
-
|
|
1480
|
-
db.insert(memoryItems)
|
|
1481
|
-
.values({
|
|
1482
|
-
id: "item-scope-default",
|
|
1483
|
-
kind: "fact",
|
|
1484
|
-
subject: "scope test",
|
|
1485
|
-
statement: "This item should have default scope",
|
|
1486
|
-
status: "active",
|
|
1487
|
-
confidence: 0.8,
|
|
1488
|
-
importance: 0.5,
|
|
1489
|
-
fingerprint: "fp-scope-default",
|
|
1490
|
-
firstSeenAt: now,
|
|
1491
|
-
lastSeenAt: now,
|
|
1492
|
-
accessCount: 0,
|
|
1493
|
-
verificationState: "user_confirmed",
|
|
1494
|
-
})
|
|
1495
|
-
.run();
|
|
1496
|
-
|
|
1497
|
-
const item = db
|
|
1498
|
-
.select()
|
|
1499
|
-
.from(memoryItems)
|
|
1500
|
-
.where(eq(memoryItems.id, "item-scope-default"))
|
|
1501
|
-
.get();
|
|
1502
|
-
expect(item).toBeDefined();
|
|
1503
|
-
expect(item!.scopeId).toBe("default");
|
|
1504
|
-
});
|
|
1505
|
-
|
|
1506
|
-
test("scope columns: memory items can be inserted with explicit scope_id", () => {
|
|
1507
|
-
const db = getDb();
|
|
1508
|
-
const now = Date.now();
|
|
1509
|
-
|
|
1510
|
-
db.insert(memoryItems)
|
|
1511
|
-
.values({
|
|
1512
|
-
id: "item-scope-custom",
|
|
1513
|
-
kind: "fact",
|
|
1514
|
-
subject: "scope test",
|
|
1515
|
-
statement: "This item has a custom scope",
|
|
1516
|
-
status: "active",
|
|
1517
|
-
confidence: 0.8,
|
|
1518
|
-
importance: 0.5,
|
|
1519
|
-
fingerprint: "fp-scope-custom",
|
|
1520
|
-
scopeId: "project-abc",
|
|
1521
|
-
firstSeenAt: now,
|
|
1522
|
-
lastSeenAt: now,
|
|
1523
|
-
accessCount: 0,
|
|
1524
|
-
verificationState: "user_confirmed",
|
|
1525
|
-
})
|
|
1526
|
-
.run();
|
|
1527
|
-
|
|
1528
|
-
const item = db
|
|
1529
|
-
.select()
|
|
1530
|
-
.from(memoryItems)
|
|
1531
|
-
.where(eq(memoryItems.id, "item-scope-custom"))
|
|
1532
|
-
.get();
|
|
1533
|
-
expect(item).toBeDefined();
|
|
1534
|
-
expect(item!.scopeId).toBe("project-abc");
|
|
1535
|
-
});
|
|
1536
|
-
|
|
1537
|
-
test("scope columns: segments get scopeId from indexer input", async () => {
|
|
1538
|
-
const db = getDb();
|
|
1539
|
-
const now = Date.now();
|
|
1540
|
-
|
|
1541
|
-
db.insert(conversations)
|
|
1542
|
-
.values({
|
|
1543
|
-
id: "conv-scope-test",
|
|
1544
|
-
title: null,
|
|
1545
|
-
createdAt: now,
|
|
1546
|
-
updatedAt: now,
|
|
1547
|
-
totalInputTokens: 0,
|
|
1548
|
-
totalOutputTokens: 0,
|
|
1549
|
-
totalEstimatedCost: 0,
|
|
1550
|
-
contextSummary: null,
|
|
1551
|
-
contextCompactedMessageCount: 0,
|
|
1552
|
-
contextCompactedAt: null,
|
|
1553
|
-
})
|
|
1554
|
-
.run();
|
|
1555
|
-
db.insert(messages)
|
|
1556
|
-
.values({
|
|
1557
|
-
id: "msg-scope-test",
|
|
1558
|
-
conversationId: "conv-scope-test",
|
|
1559
|
-
role: "user",
|
|
1560
|
-
content: JSON.stringify([
|
|
1561
|
-
{
|
|
1562
|
-
type: "text",
|
|
1563
|
-
text: "Remember my scope preference for organizing projects by team and priority level.",
|
|
1564
|
-
},
|
|
1565
|
-
]),
|
|
1566
|
-
createdAt: now,
|
|
1567
|
-
})
|
|
1568
|
-
.run();
|
|
1569
|
-
|
|
1570
|
-
await indexMessageNow(
|
|
1571
|
-
{
|
|
1572
|
-
messageId: "msg-scope-test",
|
|
1573
|
-
conversationId: "conv-scope-test",
|
|
1574
|
-
role: "user",
|
|
1575
|
-
content: JSON.stringify([
|
|
1576
|
-
{
|
|
1577
|
-
type: "text",
|
|
1578
|
-
text: "Remember my scope preference for organizing projects by team and priority level.",
|
|
1579
|
-
},
|
|
1580
|
-
]),
|
|
1581
|
-
createdAt: now,
|
|
1582
|
-
scopeId: "project-xyz",
|
|
1583
|
-
},
|
|
1584
|
-
DEFAULT_CONFIG.memory,
|
|
1585
|
-
);
|
|
1586
|
-
|
|
1587
|
-
const segments = db
|
|
1588
|
-
.select()
|
|
1589
|
-
.from(memorySegments)
|
|
1590
|
-
.where(eq(memorySegments.messageId, "msg-scope-test"))
|
|
1591
|
-
.all();
|
|
1592
|
-
expect(segments.length).toBeGreaterThan(0);
|
|
1593
|
-
for (const seg of segments) {
|
|
1594
|
-
expect(seg.scopeId).toBe("project-xyz");
|
|
1595
|
-
}
|
|
1596
|
-
});
|
|
1597
|
-
|
|
1598
|
-
test("scope filtering: retrieval excludes items from other scopes", async () => {
|
|
1599
|
-
const db = getDb();
|
|
1600
|
-
const now = Date.now();
|
|
1601
|
-
const convId = "conv-scope-filter";
|
|
1602
|
-
|
|
1603
|
-
db.insert(conversations)
|
|
1604
|
-
.values({
|
|
1605
|
-
id: convId,
|
|
1606
|
-
title: null,
|
|
1607
|
-
createdAt: now,
|
|
1608
|
-
updatedAt: now,
|
|
1609
|
-
totalInputTokens: 0,
|
|
1610
|
-
totalOutputTokens: 0,
|
|
1611
|
-
totalEstimatedCost: 0,
|
|
1612
|
-
contextSummary: null,
|
|
1613
|
-
contextCompactedMessageCount: 0,
|
|
1614
|
-
contextCompactedAt: null,
|
|
1615
|
-
})
|
|
1616
|
-
.run();
|
|
1617
|
-
db.insert(messages)
|
|
1618
|
-
.values({
|
|
1619
|
-
id: "msg-scope-filter",
|
|
1620
|
-
conversationId: convId,
|
|
1621
|
-
role: "user",
|
|
1622
|
-
content: JSON.stringify([{ type: "text", text: "scope test" }]),
|
|
1623
|
-
createdAt: now,
|
|
1624
|
-
})
|
|
1625
|
-
.run();
|
|
1626
|
-
|
|
1627
|
-
// Insert segment in scope "project-a"
|
|
1628
|
-
db.run(`
|
|
1629
|
-
INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
|
|
1630
|
-
VALUES ('seg-scope-a', 'msg-scope-filter', '${convId}', 'user', 0, 'The quick brown fox jumps over the lazy dog in project alpha', 12, 'project-a', ${now}, ${now})
|
|
1631
|
-
`);
|
|
1632
|
-
|
|
1633
|
-
// Insert segment in scope "project-b"
|
|
1634
|
-
db.run(`
|
|
1635
|
-
INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
|
|
1636
|
-
VALUES ('seg-scope-b', 'msg-scope-filter', '${convId}', 'user', 1, 'The quick brown fox jumps over the lazy dog in project beta', 12, 'project-b', ${now}, ${now})
|
|
1637
|
-
`);
|
|
1638
|
-
|
|
1639
|
-
// Insert item in scope "project-a"
|
|
1640
|
-
db.insert(memoryItems)
|
|
1641
|
-
.values({
|
|
1642
|
-
id: "item-scope-a",
|
|
1643
|
-
kind: "fact",
|
|
1644
|
-
subject: "fox",
|
|
1645
|
-
statement: "The fox is quick and brown in project alpha",
|
|
1646
|
-
status: "active",
|
|
1647
|
-
confidence: 0.9,
|
|
1648
|
-
importance: 0.8,
|
|
1649
|
-
fingerprint: "fp-scope-a",
|
|
1650
|
-
verificationState: "user_confirmed",
|
|
1651
|
-
scopeId: "project-a",
|
|
1652
|
-
firstSeenAt: now,
|
|
1653
|
-
lastSeenAt: now,
|
|
1654
|
-
})
|
|
1655
|
-
.run();
|
|
1656
|
-
|
|
1657
|
-
// Insert item in scope "project-b"
|
|
1658
|
-
db.insert(memoryItems)
|
|
1659
|
-
.values({
|
|
1660
|
-
id: "item-scope-b",
|
|
1661
|
-
kind: "fact",
|
|
1662
|
-
subject: "fox",
|
|
1663
|
-
statement: "The fox is quick and brown in project beta",
|
|
1664
|
-
status: "active",
|
|
1665
|
-
confidence: 0.9,
|
|
1666
|
-
importance: 0.8,
|
|
1667
|
-
fingerprint: "fp-scope-b",
|
|
1668
|
-
verificationState: "user_confirmed",
|
|
1669
|
-
scopeId: "project-b",
|
|
1670
|
-
firstSeenAt: now,
|
|
1671
|
-
lastSeenAt: now,
|
|
1672
|
-
})
|
|
1673
|
-
.run();
|
|
1674
|
-
|
|
1675
|
-
// Query with scopeId "project-a" — should only find project-a items
|
|
1676
|
-
const config = {
|
|
1677
|
-
...TEST_CONFIG,
|
|
1678
|
-
memory: {
|
|
1679
|
-
...TEST_CONFIG.memory,
|
|
1680
|
-
embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
|
|
1681
|
-
},
|
|
1682
|
-
};
|
|
1683
|
-
const result = await buildMemoryRecall("quick brown fox", convId, config, {
|
|
1684
|
-
scopeId: "project-a",
|
|
1685
|
-
});
|
|
1686
|
-
|
|
1687
|
-
// Qdrant is mocked empty; no candidates pass tier classification, so topCandidates is empty.
|
|
1688
|
-
expect(result.enabled).toBe(true);
|
|
1689
|
-
});
|
|
1690
|
-
|
|
1691
|
-
test("scope filtering: allow_global_fallback includes default scope", async () => {
|
|
1692
|
-
const db = getDb();
|
|
1693
|
-
const now = Date.now();
|
|
1694
|
-
const convId = "conv-scope-fallback";
|
|
1695
|
-
|
|
1696
|
-
db.insert(conversations)
|
|
1697
|
-
.values({
|
|
1698
|
-
id: convId,
|
|
1699
|
-
title: null,
|
|
1700
|
-
createdAt: now,
|
|
1701
|
-
updatedAt: now,
|
|
1702
|
-
totalInputTokens: 0,
|
|
1703
|
-
totalOutputTokens: 0,
|
|
1704
|
-
totalEstimatedCost: 0,
|
|
1705
|
-
contextSummary: null,
|
|
1706
|
-
contextCompactedMessageCount: 0,
|
|
1707
|
-
contextCompactedAt: null,
|
|
1708
|
-
})
|
|
1709
|
-
.run();
|
|
1710
|
-
db.insert(messages)
|
|
1711
|
-
.values({
|
|
1712
|
-
id: "msg-scope-fallback",
|
|
1713
|
-
conversationId: convId,
|
|
1714
|
-
role: "user",
|
|
1715
|
-
content: JSON.stringify([{ type: "text", text: "fallback test" }]),
|
|
1716
|
-
createdAt: now,
|
|
1717
|
-
})
|
|
1718
|
-
.run();
|
|
1719
|
-
|
|
1720
|
-
// Insert segment in default scope
|
|
1721
|
-
db.run(`
|
|
1722
|
-
INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
|
|
1723
|
-
VALUES ('seg-default-scope', 'msg-scope-fallback', '${convId}', 'user', 0, 'Universal knowledge about programming languages and paradigms', 10, 'default', ${now}, ${now})
|
|
1724
|
-
`);
|
|
1725
|
-
|
|
1726
|
-
// Insert segment in custom scope
|
|
1727
|
-
db.run(`
|
|
1728
|
-
INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
|
|
1729
|
-
VALUES ('seg-custom-scope', 'msg-scope-fallback', '${convId}', 'user', 1, 'Project-specific knowledge about programming languages and paradigms', 10, 'my-project', ${now}, ${now})
|
|
1730
|
-
`);
|
|
1731
|
-
|
|
1732
|
-
// With allow_global_fallback (the default), querying with scopeId "my-project"
|
|
1733
|
-
// should include both "my-project" and "default" scope items
|
|
1734
|
-
const config = {
|
|
1735
|
-
...TEST_CONFIG,
|
|
1736
|
-
memory: {
|
|
1737
|
-
...TEST_CONFIG.memory,
|
|
1738
|
-
embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
|
|
1739
|
-
},
|
|
1740
|
-
};
|
|
1741
|
-
const result = await buildMemoryRecall(
|
|
1742
|
-
"programming languages",
|
|
1743
|
-
convId,
|
|
1744
|
-
config,
|
|
1745
|
-
{ scopeId: "my-project" },
|
|
1746
|
-
);
|
|
1747
|
-
|
|
1748
|
-
// With allow_global_fallback, semantic search includes both scopes.
|
|
1749
|
-
expect(result.enabled).toBe(true);
|
|
1750
|
-
});
|
|
1751
|
-
|
|
1752
|
-
test("scope filtering: strict policy excludes default scope", async () => {
|
|
1753
|
-
const db = getDb();
|
|
1754
|
-
const now = Date.now();
|
|
1755
|
-
const convId = "conv-scope-strict";
|
|
1756
|
-
|
|
1757
|
-
db.insert(conversations)
|
|
1758
|
-
.values({
|
|
1759
|
-
id: convId,
|
|
1760
|
-
title: null,
|
|
1761
|
-
createdAt: now,
|
|
1762
|
-
updatedAt: now,
|
|
1763
|
-
totalInputTokens: 0,
|
|
1764
|
-
totalOutputTokens: 0,
|
|
1765
|
-
totalEstimatedCost: 0,
|
|
1766
|
-
contextSummary: null,
|
|
1767
|
-
contextCompactedMessageCount: 1,
|
|
1768
|
-
contextCompactedAt: null,
|
|
1769
|
-
})
|
|
1770
|
-
.run();
|
|
1771
|
-
db.insert(messages)
|
|
1772
|
-
.values({
|
|
1773
|
-
id: "msg-scope-strict",
|
|
1774
|
-
conversationId: convId,
|
|
1775
|
-
role: "user",
|
|
1776
|
-
content: JSON.stringify([{ type: "text", text: "strict test" }]),
|
|
1777
|
-
createdAt: now,
|
|
1778
|
-
})
|
|
1779
|
-
.run();
|
|
1780
|
-
|
|
1781
|
-
// Insert segment in default scope
|
|
1782
|
-
db.run(`
|
|
1783
|
-
INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
|
|
1784
|
-
VALUES ('seg-strict-default', 'msg-scope-strict', '${convId}', 'user', 0, 'Global memory about database optimization techniques', 8, 'default', ${now}, ${now})
|
|
1785
|
-
`);
|
|
1786
|
-
|
|
1787
|
-
// Insert segment in custom scope
|
|
1788
|
-
db.run(`
|
|
1789
|
-
INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
|
|
1790
|
-
VALUES ('seg-strict-custom', 'msg-scope-strict', '${convId}', 'user', 1, 'Project-specific memory about database optimization techniques', 8, 'strict-project', ${now}, ${now})
|
|
1791
|
-
`);
|
|
1792
|
-
|
|
1793
|
-
// Mock Qdrant to return both segments as semantic hits
|
|
1794
|
-
mockQdrantResults = [
|
|
1795
|
-
{
|
|
1796
|
-
id: "emb-strict-default",
|
|
1797
|
-
score: 0.9,
|
|
1798
|
-
payload: {
|
|
1799
|
-
target_type: "segment",
|
|
1800
|
-
target_id: "seg-strict-default",
|
|
1801
|
-
text: "Global memory about database optimization techniques",
|
|
1802
|
-
conversation_id: convId,
|
|
1803
|
-
message_id: "msg-scope-strict",
|
|
1804
|
-
created_at: now,
|
|
1805
|
-
},
|
|
1806
|
-
},
|
|
1807
|
-
{
|
|
1808
|
-
id: "emb-strict-custom",
|
|
1809
|
-
score: 0.85,
|
|
1810
|
-
payload: {
|
|
1811
|
-
target_type: "segment",
|
|
1812
|
-
target_id: "seg-strict-custom",
|
|
1813
|
-
text: "Project-specific memory about database optimization techniques",
|
|
1814
|
-
conversation_id: convId,
|
|
1815
|
-
message_id: "msg-scope-strict",
|
|
1816
|
-
created_at: now,
|
|
1817
|
-
},
|
|
1818
|
-
},
|
|
1819
|
-
];
|
|
1820
|
-
|
|
1821
|
-
// With strict policy, querying with scopeId should only include that scope
|
|
1822
|
-
const strictConfig = {
|
|
1823
|
-
...TEST_CONFIG,
|
|
1824
|
-
memory: {
|
|
1825
|
-
...TEST_CONFIG.memory,
|
|
1826
|
-
embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
|
|
1827
|
-
retrieval: {
|
|
1828
|
-
...TEST_CONFIG.memory.retrieval,
|
|
1829
|
-
scopePolicy: "strict" as const,
|
|
1830
|
-
},
|
|
1831
|
-
},
|
|
1832
|
-
};
|
|
1833
|
-
|
|
1834
|
-
const result = await buildMemoryRecall(
|
|
1835
|
-
"database optimization",
|
|
1836
|
-
convId,
|
|
1837
|
-
strictConfig,
|
|
1838
|
-
{ scopeId: "strict-project" },
|
|
1839
|
-
);
|
|
1840
|
-
|
|
1841
|
-
// With strict policy, only "strict-project" scope segments should be found.
|
|
1842
|
-
// The default scope segment should be excluded.
|
|
1843
|
-
// Assert the returned candidate is specifically from the strict-project scope,
|
|
1844
|
-
// not the default scope segment (privacy boundary check).
|
|
1845
|
-
expect(result.topCandidates.length).toBe(1);
|
|
1846
|
-
expect(result.topCandidates[0].key).toBe("segment:seg-strict-custom");
|
|
1847
|
-
expect(result.injectedText).toContain("Project-specific memory");
|
|
1848
|
-
expect(result.injectedText).not.toContain("Global memory");
|
|
1849
|
-
});
|
|
1850
|
-
|
|
1851
|
-
test("scope columns: summaries default to scope_id=default", () => {
|
|
1852
|
-
const db = getDb();
|
|
1853
|
-
const now = Date.now();
|
|
1854
|
-
|
|
1855
|
-
db.insert(memorySummaries)
|
|
1856
|
-
.values({
|
|
1857
|
-
id: "summary-scope-test",
|
|
1858
|
-
scope: "weekly_global",
|
|
1859
|
-
scopeKey: "2025-W01",
|
|
1860
|
-
summary: "Test summary for scope",
|
|
1861
|
-
tokenEstimate: 10,
|
|
1862
|
-
startAt: now - 7 * 86_400_000,
|
|
1863
|
-
endAt: now,
|
|
1864
|
-
createdAt: now,
|
|
1865
|
-
updatedAt: now,
|
|
1866
|
-
})
|
|
1867
|
-
.run();
|
|
1868
|
-
|
|
1869
|
-
const summary = db
|
|
1870
|
-
.select()
|
|
1871
|
-
.from(memorySummaries)
|
|
1872
|
-
.where(eq(memorySummaries.id, "summary-scope-test"))
|
|
1873
|
-
.get();
|
|
1874
|
-
expect(summary).toBeDefined();
|
|
1875
|
-
expect(summary!.scopeId).toBe("default");
|
|
1876
|
-
});
|
|
1877
|
-
|
|
1878
|
-
test("scopePolicyOverride with fallbackToDefault includes both scopes even when global policy is strict", async () => {
|
|
1879
|
-
const db = getDb();
|
|
1880
|
-
const now = Date.now();
|
|
1881
|
-
const convId = "conv-scope-override-fallback";
|
|
1882
|
-
|
|
1883
|
-
db.insert(conversations)
|
|
1884
|
-
.values({
|
|
1885
|
-
id: convId,
|
|
1886
|
-
title: null,
|
|
1887
|
-
createdAt: now,
|
|
1888
|
-
updatedAt: now,
|
|
1889
|
-
totalInputTokens: 0,
|
|
1890
|
-
totalOutputTokens: 0,
|
|
1891
|
-
totalEstimatedCost: 0,
|
|
1892
|
-
contextSummary: null,
|
|
1893
|
-
contextCompactedMessageCount: 0,
|
|
1894
|
-
contextCompactedAt: null,
|
|
1895
|
-
})
|
|
1896
|
-
.run();
|
|
1897
|
-
db.insert(messages)
|
|
1898
|
-
.values({
|
|
1899
|
-
id: "msg-override-fallback",
|
|
1900
|
-
conversationId: convId,
|
|
1901
|
-
role: "user",
|
|
1902
|
-
content: JSON.stringify([
|
|
1903
|
-
{ type: "text", text: "override fallback test" },
|
|
1904
|
-
]),
|
|
1905
|
-
createdAt: now,
|
|
1906
|
-
})
|
|
1907
|
-
.run();
|
|
1908
|
-
|
|
1909
|
-
// Insert segment in default scope
|
|
1910
|
-
db.run(`
|
|
1911
|
-
INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
|
|
1912
|
-
VALUES ('seg-ovr-default', 'msg-override-fallback', '${convId}', 'user', 0, 'Global memory about microservices architecture patterns', 10, 'default', ${now}, ${now})
|
|
1913
|
-
`);
|
|
1914
|
-
|
|
1915
|
-
// Insert segment in private conversation scope
|
|
1916
|
-
db.run(`
|
|
1917
|
-
INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
|
|
1918
|
-
VALUES ('seg-ovr-private', 'msg-override-fallback', '${convId}', 'user', 1, 'Private thread memory about microservices architecture patterns', 10, 'private-thread-42', ${now}, ${now})
|
|
1919
|
-
`);
|
|
1920
|
-
|
|
1921
|
-
// Global policy is strict, but override requests fallback to default
|
|
1922
|
-
const strictConfig = {
|
|
1923
|
-
...TEST_CONFIG,
|
|
1924
|
-
memory: {
|
|
1925
|
-
...TEST_CONFIG.memory,
|
|
1926
|
-
embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
|
|
1927
|
-
retrieval: {
|
|
1928
|
-
...TEST_CONFIG.memory.retrieval,
|
|
1929
|
-
scopePolicy: "strict" as const,
|
|
1930
|
-
},
|
|
1931
|
-
},
|
|
1932
|
-
};
|
|
1933
|
-
|
|
1934
|
-
const result = await buildMemoryRecall(
|
|
1935
|
-
"microservices architecture",
|
|
1936
|
-
convId,
|
|
1937
|
-
strictConfig,
|
|
1938
|
-
{
|
|
1939
|
-
scopePolicyOverride: {
|
|
1940
|
-
scopeId: "private-thread-42",
|
|
1941
|
-
fallbackToDefault: true,
|
|
1942
|
-
},
|
|
1943
|
-
},
|
|
1944
|
-
);
|
|
1945
|
-
|
|
1946
|
-
// Override with fallbackToDefault=true should include both
|
|
1947
|
-
// "private-thread-42" and "default" scopes, despite strict global policy.
|
|
1948
|
-
expect(result.enabled).toBe(true);
|
|
1949
|
-
});
|
|
1950
|
-
|
|
1951
|
-
test("scopePolicyOverride without fallback excludes default scope even when global policy is allow_global_fallback", async () => {
|
|
1952
|
-
const db = getDb();
|
|
1953
|
-
const now = Date.now();
|
|
1954
|
-
const convId = "conv-scope-override-nofallback";
|
|
1955
|
-
|
|
1956
|
-
db.insert(conversations)
|
|
1957
|
-
.values({
|
|
1958
|
-
id: convId,
|
|
1959
|
-
title: null,
|
|
1960
|
-
createdAt: now,
|
|
1961
|
-
updatedAt: now,
|
|
1962
|
-
totalInputTokens: 0,
|
|
1963
|
-
totalOutputTokens: 0,
|
|
1964
|
-
totalEstimatedCost: 0,
|
|
1965
|
-
contextSummary: null,
|
|
1966
|
-
contextCompactedMessageCount: 0,
|
|
1967
|
-
contextCompactedAt: null,
|
|
1968
|
-
})
|
|
1969
|
-
.run();
|
|
1970
|
-
db.insert(messages)
|
|
1971
|
-
.values({
|
|
1972
|
-
id: "msg-override-nofallback",
|
|
1973
|
-
conversationId: convId,
|
|
1974
|
-
role: "user",
|
|
1975
|
-
content: JSON.stringify([
|
|
1976
|
-
{ type: "text", text: "override no fallback" },
|
|
1977
|
-
]),
|
|
1978
|
-
createdAt: now,
|
|
1979
|
-
})
|
|
1980
|
-
.run();
|
|
1981
|
-
|
|
1982
|
-
// Insert segment in default scope
|
|
1983
|
-
db.run(`
|
|
1984
|
-
INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
|
|
1985
|
-
VALUES ('seg-ovr-nf-default', 'msg-override-nofallback', '${convId}', 'user', 0, 'Global memory about container orchestration strategies', 10, 'default', ${now}, ${now})
|
|
1986
|
-
`);
|
|
1987
|
-
|
|
1988
|
-
// Insert segment in isolated scope
|
|
1989
|
-
db.run(`
|
|
1990
|
-
INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
|
|
1991
|
-
VALUES ('seg-ovr-nf-isolated', 'msg-override-nofallback', '${convId}', 'user', 1, 'Isolated memory about container orchestration strategies', 10, 'isolated-scope', ${now}, ${now})
|
|
1992
|
-
`);
|
|
1993
|
-
|
|
1994
|
-
// Global policy allows fallback, but override says no fallback
|
|
1995
|
-
const fallbackConfig = {
|
|
1996
|
-
...TEST_CONFIG,
|
|
1997
|
-
memory: {
|
|
1998
|
-
...TEST_CONFIG.memory,
|
|
1999
|
-
embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
|
|
2000
|
-
retrieval: {
|
|
2001
|
-
...TEST_CONFIG.memory.retrieval,
|
|
2002
|
-
scopePolicy: "allow_global_fallback" as const,
|
|
2003
|
-
},
|
|
2004
|
-
},
|
|
2005
|
-
};
|
|
2006
|
-
|
|
2007
|
-
const result = await buildMemoryRecall(
|
|
2008
|
-
"container orchestration",
|
|
2009
|
-
convId,
|
|
2010
|
-
fallbackConfig,
|
|
2011
|
-
{
|
|
2012
|
-
scopePolicyOverride: {
|
|
2013
|
-
scopeId: "isolated-scope",
|
|
2014
|
-
fallbackToDefault: false,
|
|
2015
|
-
},
|
|
2016
|
-
},
|
|
2017
|
-
);
|
|
2018
|
-
|
|
2019
|
-
// Override disables fallback — only isolated scope segments found.
|
|
2020
|
-
expect(result.enabled).toBe(true);
|
|
2021
|
-
});
|
|
2022
|
-
|
|
2023
|
-
test("scopePolicyOverride takes precedence over scopeId option", async () => {
|
|
2024
|
-
const db = getDb();
|
|
2025
|
-
const now = Date.now();
|
|
2026
|
-
const convId = "conv-scope-override-precedence";
|
|
2027
|
-
|
|
2028
|
-
db.insert(conversations)
|
|
2029
|
-
.values({
|
|
2030
|
-
id: convId,
|
|
2031
|
-
title: null,
|
|
2032
|
-
createdAt: now,
|
|
2033
|
-
updatedAt: now,
|
|
2034
|
-
totalInputTokens: 0,
|
|
2035
|
-
totalOutputTokens: 0,
|
|
2036
|
-
totalEstimatedCost: 0,
|
|
2037
|
-
contextSummary: null,
|
|
2038
|
-
contextCompactedMessageCount: 1,
|
|
2039
|
-
contextCompactedAt: null,
|
|
2040
|
-
})
|
|
2041
|
-
.run();
|
|
2042
|
-
db.insert(messages)
|
|
2043
|
-
.values({
|
|
2044
|
-
id: "msg-override-precedence",
|
|
2045
|
-
conversationId: convId,
|
|
2046
|
-
role: "user",
|
|
2047
|
-
content: JSON.stringify([{ type: "text", text: "precedence test" }]),
|
|
2048
|
-
createdAt: now,
|
|
2049
|
-
})
|
|
2050
|
-
.run();
|
|
2051
|
-
|
|
2052
|
-
// Insert segment in scope-a (what scopeId would resolve to)
|
|
2053
|
-
db.run(`
|
|
2054
|
-
INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
|
|
2055
|
-
VALUES ('seg-ovr-prec-a', 'msg-override-precedence', '${convId}', 'user', 0, 'Scope A memory about distributed caching patterns', 10, 'scope-a', ${now}, ${now})
|
|
2056
|
-
`);
|
|
2057
|
-
|
|
2058
|
-
// Insert segment in scope-b (what the override targets)
|
|
2059
|
-
db.run(`
|
|
2060
|
-
INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
|
|
2061
|
-
VALUES ('seg-ovr-prec-b', 'msg-override-precedence', '${convId}', 'user', 1, 'Scope B memory about distributed caching patterns', 10, 'scope-b', ${now}, ${now})
|
|
2062
|
-
`);
|
|
2063
|
-
|
|
2064
|
-
// Mock Qdrant to return both segments
|
|
2065
|
-
mockQdrantResults = [
|
|
2066
|
-
{
|
|
2067
|
-
id: "emb-ovr-prec-a",
|
|
2068
|
-
score: 0.9,
|
|
2069
|
-
payload: {
|
|
2070
|
-
target_type: "segment",
|
|
2071
|
-
target_id: "seg-ovr-prec-a",
|
|
2072
|
-
text: "Scope A memory about distributed caching patterns",
|
|
2073
|
-
conversation_id: convId,
|
|
2074
|
-
message_id: "msg-override-precedence",
|
|
2075
|
-
created_at: now,
|
|
2076
|
-
},
|
|
2077
|
-
},
|
|
2078
|
-
{
|
|
2079
|
-
id: "emb-ovr-prec-b",
|
|
2080
|
-
score: 0.85,
|
|
2081
|
-
payload: {
|
|
2082
|
-
target_type: "segment",
|
|
2083
|
-
target_id: "seg-ovr-prec-b",
|
|
2084
|
-
text: "Scope B memory about distributed caching patterns",
|
|
2085
|
-
conversation_id: convId,
|
|
2086
|
-
message_id: "msg-override-precedence",
|
|
2087
|
-
created_at: now,
|
|
2088
|
-
},
|
|
2089
|
-
},
|
|
2090
|
-
];
|
|
2091
|
-
|
|
2092
|
-
const config = {
|
|
2093
|
-
...TEST_CONFIG,
|
|
2094
|
-
memory: {
|
|
2095
|
-
...TEST_CONFIG.memory,
|
|
2096
|
-
embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
|
|
2097
|
-
retrieval: {
|
|
2098
|
-
...TEST_CONFIG.memory.retrieval,
|
|
2099
|
-
scopePolicy: "strict" as const,
|
|
2100
|
-
},
|
|
2101
|
-
},
|
|
2102
|
-
};
|
|
2103
|
-
|
|
2104
|
-
// scopeId says 'scope-a', but override says 'scope-b' — override wins
|
|
2105
|
-
const result = await buildMemoryRecall(
|
|
2106
|
-
"distributed caching",
|
|
2107
|
-
convId,
|
|
2108
|
-
config,
|
|
2109
|
-
{
|
|
2110
|
-
scopeId: "scope-a",
|
|
2111
|
-
scopePolicyOverride: {
|
|
2112
|
-
scopeId: "scope-b",
|
|
2113
|
-
fallbackToDefault: false,
|
|
2114
|
-
},
|
|
2115
|
-
},
|
|
2116
|
-
);
|
|
2117
|
-
|
|
2118
|
-
// Only scope-b segment should be found (override takes precedence)
|
|
2119
|
-
// Verify identity of the returned candidate (scope-b, not scope-a)
|
|
2120
|
-
expect(result.injectedText).toContain("Scope B memory");
|
|
2121
|
-
expect(result.injectedText).not.toContain("Scope A memory");
|
|
2122
|
-
});
|
|
2123
|
-
|
|
2124
|
-
test("scopePolicyOverride with default as primary scope and fallback=true returns only default", async () => {
|
|
2125
|
-
const db = getDb();
|
|
2126
|
-
const now = Date.now();
|
|
2127
|
-
const convId = "conv-scope-override-default-primary";
|
|
2128
|
-
|
|
2129
|
-
db.insert(conversations)
|
|
2130
|
-
.values({
|
|
2131
|
-
id: convId,
|
|
2132
|
-
title: null,
|
|
2133
|
-
createdAt: now,
|
|
2134
|
-
updatedAt: now,
|
|
2135
|
-
totalInputTokens: 0,
|
|
2136
|
-
totalOutputTokens: 0,
|
|
2137
|
-
totalEstimatedCost: 0,
|
|
2138
|
-
contextSummary: null,
|
|
2139
|
-
contextCompactedMessageCount: 1,
|
|
2140
|
-
contextCompactedAt: null,
|
|
2141
|
-
})
|
|
2142
|
-
.run();
|
|
2143
|
-
db.insert(messages)
|
|
2144
|
-
.values({
|
|
2145
|
-
id: "msg-override-default-primary",
|
|
2146
|
-
conversationId: convId,
|
|
2147
|
-
role: "user",
|
|
2148
|
-
content: JSON.stringify([{ type: "text", text: "default primary" }]),
|
|
2149
|
-
createdAt: now,
|
|
2150
|
-
})
|
|
2151
|
-
.run();
|
|
2152
|
-
|
|
2153
|
-
// Insert segment in default scope
|
|
2154
|
-
db.run(`
|
|
2155
|
-
INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
|
|
2156
|
-
VALUES ('seg-ovr-dp-default', 'msg-override-default-primary', '${convId}', 'user', 0, 'Default scope memory about event driven design', 10, 'default', ${now}, ${now})
|
|
2157
|
-
`);
|
|
2158
|
-
|
|
2159
|
-
// Insert segment in other scope
|
|
2160
|
-
db.run(`
|
|
2161
|
-
INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
|
|
2162
|
-
VALUES ('seg-ovr-dp-other', 'msg-override-default-primary', '${convId}', 'user', 1, 'Other scope memory about event driven design', 10, 'other-scope', ${now}, ${now})
|
|
2163
|
-
`);
|
|
2164
|
-
|
|
2165
|
-
// Mock Qdrant to return both segments
|
|
2166
|
-
mockQdrantResults = [
|
|
2167
|
-
{
|
|
2168
|
-
id: "emb-ovr-dp-default",
|
|
2169
|
-
score: 0.9,
|
|
2170
|
-
payload: {
|
|
2171
|
-
target_type: "segment",
|
|
2172
|
-
target_id: "seg-ovr-dp-default",
|
|
2173
|
-
text: "Default scope memory about event driven design",
|
|
2174
|
-
conversation_id: convId,
|
|
2175
|
-
message_id: "msg-override-default-primary",
|
|
2176
|
-
created_at: now,
|
|
2177
|
-
},
|
|
2178
|
-
},
|
|
2179
|
-
{
|
|
2180
|
-
id: "emb-ovr-dp-other",
|
|
2181
|
-
score: 0.85,
|
|
2182
|
-
payload: {
|
|
2183
|
-
target_type: "segment",
|
|
2184
|
-
target_id: "seg-ovr-dp-other",
|
|
2185
|
-
text: "Other scope memory about event driven design",
|
|
2186
|
-
conversation_id: convId,
|
|
2187
|
-
message_id: "msg-override-default-primary",
|
|
2188
|
-
created_at: now,
|
|
2189
|
-
},
|
|
2190
|
-
},
|
|
2191
|
-
];
|
|
2192
|
-
|
|
2193
|
-
const config = {
|
|
2194
|
-
...TEST_CONFIG,
|
|
2195
|
-
memory: {
|
|
2196
|
-
...TEST_CONFIG.memory,
|
|
2197
|
-
embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
|
|
2198
|
-
},
|
|
2199
|
-
};
|
|
2200
|
-
|
|
2201
|
-
// When primary scope IS 'default' with fallback=true, no duplication —
|
|
2202
|
-
// just ['default'] is used
|
|
2203
|
-
const result = await buildMemoryRecall(
|
|
2204
|
-
"event driven design",
|
|
2205
|
-
convId,
|
|
2206
|
-
config,
|
|
2207
|
-
{
|
|
2208
|
-
scopePolicyOverride: {
|
|
2209
|
-
scopeId: "default",
|
|
2210
|
-
fallbackToDefault: true,
|
|
2211
|
-
},
|
|
2212
|
-
},
|
|
2213
|
-
);
|
|
2214
|
-
|
|
2215
|
-
// Only default scope segment should be found (other-scope excluded)
|
|
2216
|
-
// Verify identity: default-scope segment returned, other-scope excluded
|
|
2217
|
-
expect(result.injectedText).toContain("Default scope memory");
|
|
2218
|
-
expect(result.injectedText).not.toContain("Other scope memory");
|
|
2219
|
-
});
|
|
2220
|
-
|
|
2221
|
-
// PR-17: addMessage() passes conversation scope to the indexer
|
|
2222
|
-
test("addMessage inherits private conversation scope on memory segments", async () => {
|
|
2223
|
-
const conv = createConversation({
|
|
2224
|
-
title: "Private conversation",
|
|
2225
|
-
conversationType: "private",
|
|
2226
|
-
});
|
|
2227
|
-
expect(conv.memoryScopeId).toMatch(/^private:/);
|
|
2228
|
-
|
|
2229
|
-
const msg = await addMessage(
|
|
2230
|
-
conv.id,
|
|
2231
|
-
"user",
|
|
2232
|
-
"My secret project details for the private conversation.",
|
|
2233
|
-
);
|
|
2234
|
-
|
|
2235
|
-
const db = getDb();
|
|
2236
|
-
const segments = db
|
|
2237
|
-
.select()
|
|
2238
|
-
.from(memorySegments)
|
|
2239
|
-
.where(eq(memorySegments.messageId, msg.id))
|
|
2240
|
-
.all();
|
|
2241
|
-
|
|
2242
|
-
expect(segments.length).toBeGreaterThan(0);
|
|
2243
|
-
for (const seg of segments) {
|
|
2244
|
-
expect(seg.scopeId).toBe(conv.memoryScopeId);
|
|
2245
|
-
}
|
|
2246
|
-
});
|
|
2247
|
-
|
|
2248
|
-
test("addMessage uses default scope for standard conversations", async () => {
|
|
2249
|
-
const conv = createConversation({
|
|
2250
|
-
title: "Standard conversation",
|
|
2251
|
-
conversationType: "standard",
|
|
2252
|
-
});
|
|
2253
|
-
expect(conv.memoryScopeId).toBe("default");
|
|
2254
|
-
|
|
2255
|
-
const msg = await addMessage(
|
|
2256
|
-
conv.id,
|
|
2257
|
-
"user",
|
|
2258
|
-
"Normal conversation content for testing scope defaults.",
|
|
2259
|
-
);
|
|
2260
|
-
|
|
2261
|
-
const db = getDb();
|
|
2262
|
-
const segments = db
|
|
2263
|
-
.select()
|
|
2264
|
-
.from(memorySegments)
|
|
2265
|
-
.where(eq(memorySegments.messageId, msg.id))
|
|
2266
|
-
.all();
|
|
2267
|
-
|
|
2268
|
-
expect(segments.length).toBeGreaterThan(0);
|
|
2269
|
-
for (const seg of segments) {
|
|
2270
|
-
expect(seg.scopeId).toBe("default");
|
|
2271
|
-
}
|
|
2272
|
-
});
|
|
2273
|
-
|
|
2274
|
-
// PR-18: batch_extract jobs carry scopeId through the async pipeline
|
|
2275
|
-
test("batch_extract job payload includes scopeId from private conversation", async () => {
|
|
2276
|
-
// These tests verify job payload contents, so LLM extraction must be
|
|
2277
|
-
// enabled — otherwise the indexer skips enqueuing batch_extract entirely.
|
|
2278
|
-
// Set batchSize=1 so a single message triggers immediate batch_extract.
|
|
2279
|
-
TEST_CONFIG.memory.extraction.useLLM = true;
|
|
2280
|
-
TEST_CONFIG.memory.extraction.batchSize = 1;
|
|
2281
|
-
try {
|
|
2282
|
-
const conv = createConversation({
|
|
2283
|
-
title: "Private scope job test",
|
|
2284
|
-
conversationType: "private",
|
|
2285
|
-
});
|
|
2286
|
-
expect(conv.memoryScopeId).toMatch(/^private:/);
|
|
2287
|
-
|
|
2288
|
-
await addMessage(
|
|
2289
|
-
conv.id,
|
|
2290
|
-
"user",
|
|
2291
|
-
"Important data that should trigger extraction in private scope.",
|
|
2292
|
-
);
|
|
2293
|
-
|
|
2294
|
-
const db = getDb();
|
|
2295
|
-
const extractJobs = db
|
|
2296
|
-
.select()
|
|
2297
|
-
.from(memoryJobs)
|
|
2298
|
-
.where(eq(memoryJobs.type, "batch_extract"))
|
|
2299
|
-
.all()
|
|
2300
|
-
.filter(
|
|
2301
|
-
(j) =>
|
|
2302
|
-
JSON.parse(j.payload).conversationId === conv.id &&
|
|
2303
|
-
JSON.parse(j.payload).scopeId != null,
|
|
2304
|
-
);
|
|
2305
|
-
|
|
2306
|
-
expect(extractJobs.length).toBeGreaterThan(0);
|
|
2307
|
-
const lastJob = extractJobs[extractJobs.length - 1];
|
|
2308
|
-
const payload = JSON.parse(lastJob.payload) as Record<string, unknown>;
|
|
2309
|
-
expect(payload.scopeId).toBe(conv.memoryScopeId);
|
|
2310
|
-
} finally {
|
|
2311
|
-
TEST_CONFIG.memory.extraction.useLLM = false;
|
|
2312
|
-
TEST_CONFIG.memory.extraction.batchSize = 10;
|
|
2313
|
-
}
|
|
2314
|
-
});
|
|
2315
|
-
|
|
2316
|
-
test("batch_extract job payload defaults scopeId to default for standard conversations", async () => {
|
|
2317
|
-
TEST_CONFIG.memory.extraction.useLLM = true;
|
|
2318
|
-
TEST_CONFIG.memory.extraction.batchSize = 1;
|
|
2319
|
-
try {
|
|
2320
|
-
const conv = createConversation({
|
|
2321
|
-
title: "Standard scope job test",
|
|
2322
|
-
conversationType: "standard",
|
|
2323
|
-
});
|
|
2324
|
-
expect(conv.memoryScopeId).toBe("default");
|
|
2325
|
-
|
|
2326
|
-
await addMessage(
|
|
2327
|
-
conv.id,
|
|
2328
|
-
"user",
|
|
2329
|
-
"Regular content for extraction in default scope.",
|
|
2330
|
-
);
|
|
2331
|
-
|
|
2332
|
-
const db = getDb();
|
|
2333
|
-
const extractJobs = db
|
|
2334
|
-
.select()
|
|
2335
|
-
.from(memoryJobs)
|
|
2336
|
-
.where(eq(memoryJobs.type, "batch_extract"))
|
|
2337
|
-
.all()
|
|
2338
|
-
.filter(
|
|
2339
|
-
(j) =>
|
|
2340
|
-
JSON.parse(j.payload).conversationId === conv.id &&
|
|
2341
|
-
JSON.parse(j.payload).scopeId != null,
|
|
2342
|
-
);
|
|
2343
|
-
|
|
2344
|
-
expect(extractJobs.length).toBeGreaterThan(0);
|
|
2345
|
-
const lastJob = extractJobs[extractJobs.length - 1];
|
|
2346
|
-
const payload = JSON.parse(lastJob.payload) as Record<string, unknown>;
|
|
2347
|
-
expect(payload.scopeId).toBe("default");
|
|
2348
|
-
} finally {
|
|
2349
|
-
TEST_CONFIG.memory.extraction.useLLM = false;
|
|
2350
|
-
TEST_CONFIG.memory.extraction.batchSize = 10;
|
|
2351
|
-
}
|
|
2352
|
-
});
|
|
2353
|
-
|
|
2354
|
-
// PR-19: memory_save respects explicit scopeId parameter
|
|
2355
|
-
test("handleMemorySave places items in the requested scope", async () => {
|
|
2356
|
-
const { handleMemorySave } = await import("../tools/memory/handlers.js");
|
|
2357
|
-
|
|
2358
|
-
// Save without explicit scopeId — defaults to "default"
|
|
2359
|
-
const r1 = await handleMemorySave(
|
|
2360
|
-
{
|
|
2361
|
-
statement: "I prefer TypeScript over JavaScript for all new projects.",
|
|
2362
|
-
kind: "preference",
|
|
2363
|
-
},
|
|
2364
|
-
DEFAULT_CONFIG,
|
|
2365
|
-
"conv-scope-pass",
|
|
2366
|
-
"msg-scope-pass",
|
|
2367
|
-
);
|
|
2368
|
-
expect(r1.isError).toBe(false);
|
|
2369
|
-
|
|
2370
|
-
// Save with explicit private scopeId
|
|
2371
|
-
const r2 = await handleMemorySave(
|
|
2372
|
-
{
|
|
2373
|
-
statement: "I dislike using var in JavaScript, prefer const and let.",
|
|
2374
|
-
kind: "preference",
|
|
2375
|
-
},
|
|
2376
|
-
DEFAULT_CONFIG,
|
|
2377
|
-
"conv-scope-pass-2",
|
|
2378
|
-
"msg-scope-pass-2",
|
|
2379
|
-
"private:thread-42",
|
|
2380
|
-
);
|
|
2381
|
-
expect(r2.isError).toBe(false);
|
|
2382
|
-
|
|
2383
|
-
const db = getDb();
|
|
2384
|
-
const defaultItems = db
|
|
2385
|
-
.select()
|
|
2386
|
-
.from(memoryItems)
|
|
2387
|
-
.where(eq(memoryItems.scopeId, "default"))
|
|
2388
|
-
.all();
|
|
2389
|
-
const privateItems = db
|
|
2390
|
-
.select()
|
|
2391
|
-
.from(memoryItems)
|
|
2392
|
-
.where(eq(memoryItems.scopeId, "private:thread-42"))
|
|
2393
|
-
.all();
|
|
2394
|
-
|
|
2395
|
-
expect(defaultItems.length).toBe(1);
|
|
2396
|
-
expect(privateItems.length).toBe(1);
|
|
2397
|
-
});
|
|
2398
|
-
|
|
2399
|
-
// PR-20: same statement in different scopes produces separate active items
|
|
2400
|
-
test("same statement in different scopes produces separate active memory items", async () => {
|
|
2401
|
-
const { handleMemorySave } = await import("../tools/memory/handlers.js");
|
|
2402
|
-
|
|
2403
|
-
const statement = "I prefer dark mode for all my editors and terminals.";
|
|
2404
|
-
|
|
2405
|
-
// Save into default scope
|
|
2406
|
-
const r1 = await handleMemorySave(
|
|
2407
|
-
{ statement, kind: "preference" },
|
|
2408
|
-
DEFAULT_CONFIG,
|
|
2409
|
-
"conv-scope-separate-1",
|
|
2410
|
-
"msg-scope-default",
|
|
2411
|
-
"default",
|
|
2412
|
-
);
|
|
2413
|
-
expect(r1.isError).toBe(false);
|
|
2414
|
-
|
|
2415
|
-
// Save identical statement into a private scope
|
|
2416
|
-
const r2 = await handleMemorySave(
|
|
2417
|
-
{ statement, kind: "preference" },
|
|
2418
|
-
DEFAULT_CONFIG,
|
|
2419
|
-
"conv-scope-separate-2",
|
|
2420
|
-
"msg-scope-private",
|
|
2421
|
-
"private:thread-99",
|
|
2422
|
-
);
|
|
2423
|
-
expect(r2.isError).toBe(false);
|
|
2424
|
-
|
|
2425
|
-
const db = getDb();
|
|
2426
|
-
// Both scopes should have separate active items
|
|
2427
|
-
const defaultItems = db
|
|
2428
|
-
.select()
|
|
2429
|
-
.from(memoryItems)
|
|
2430
|
-
.where(
|
|
2431
|
-
and(
|
|
2432
|
-
eq(memoryItems.scopeId, "default"),
|
|
2433
|
-
eq(memoryItems.status, "active"),
|
|
2434
|
-
),
|
|
2435
|
-
)
|
|
2436
|
-
.all();
|
|
2437
|
-
const privateItems = db
|
|
2438
|
-
.select()
|
|
2439
|
-
.from(memoryItems)
|
|
2440
|
-
.where(
|
|
2441
|
-
and(
|
|
2442
|
-
eq(memoryItems.scopeId, "private:thread-99"),
|
|
2443
|
-
eq(memoryItems.status, "active"),
|
|
2444
|
-
),
|
|
2445
|
-
)
|
|
2446
|
-
.all();
|
|
2447
|
-
|
|
2448
|
-
expect(defaultItems.length).toBeGreaterThan(0);
|
|
2449
|
-
expect(privateItems.length).toBeGreaterThan(0);
|
|
2450
|
-
|
|
2451
|
-
// Scope-salted fingerprints: same content in different scopes yields distinct fingerprints
|
|
2452
|
-
const defaultFingerprints = new Set(defaultItems.map((i) => i.fingerprint));
|
|
2453
|
-
const matchingPrivate = privateItems.filter((i) =>
|
|
2454
|
-
defaultFingerprints.has(i.fingerprint),
|
|
2455
|
-
);
|
|
2456
|
-
expect(matchingPrivate.length).toBe(0);
|
|
2457
|
-
});
|
|
2458
|
-
|
|
2459
|
-
// PR-21: identical fact in default vs private scopes gets distinct fingerprints
|
|
2460
|
-
test("identical content in different scopes produces distinct fingerprints", async () => {
|
|
2461
|
-
const { handleMemorySave } = await import("../tools/memory/handlers.js");
|
|
2462
|
-
|
|
2463
|
-
const statement = "I prefer using Vim keybindings in all my text editors.";
|
|
2464
|
-
|
|
2465
|
-
await handleMemorySave(
|
|
2466
|
-
{ statement, kind: "preference" },
|
|
2467
|
-
DEFAULT_CONFIG,
|
|
2468
|
-
"conv-fp-salt-1",
|
|
2469
|
-
"msg-fp-salt-default",
|
|
2470
|
-
"default",
|
|
2471
|
-
);
|
|
2472
|
-
await handleMemorySave(
|
|
2473
|
-
{ statement, kind: "preference" },
|
|
2474
|
-
DEFAULT_CONFIG,
|
|
2475
|
-
"conv-fp-salt-2",
|
|
2476
|
-
"msg-fp-salt-private",
|
|
2477
|
-
"private:fp-test",
|
|
2478
|
-
);
|
|
2479
|
-
|
|
2480
|
-
const db = getDb();
|
|
2481
|
-
const defaultItems = db
|
|
2482
|
-
.select()
|
|
2483
|
-
.from(memoryItems)
|
|
2484
|
-
.where(eq(memoryItems.scopeId, "default"))
|
|
2485
|
-
.all()
|
|
2486
|
-
.filter((i) => i.statement === statement);
|
|
2487
|
-
const privateItems = db
|
|
2488
|
-
.select()
|
|
2489
|
-
.from(memoryItems)
|
|
2490
|
-
.where(eq(memoryItems.scopeId, "private:fp-test"))
|
|
2491
|
-
.all()
|
|
2492
|
-
.filter((i) => i.statement === statement);
|
|
2493
|
-
|
|
2494
|
-
expect(defaultItems.length).toBe(1);
|
|
2495
|
-
expect(privateItems.length).toBe(1);
|
|
2496
|
-
// Same content, different scopes — fingerprints must differ
|
|
2497
|
-
expect(defaultItems[0].fingerprint).not.toBe(privateItems[0].fingerprint);
|
|
2498
|
-
// But the actual content should be identical
|
|
2499
|
-
expect(defaultItems[0].kind).toBe(privateItems[0].kind);
|
|
2500
|
-
expect(defaultItems[0].subject).toBe(privateItems[0].subject);
|
|
2501
|
-
expect(defaultItems[0].statement).toBe(privateItems[0].statement);
|
|
2502
|
-
});
|
|
2503
|
-
|
|
2504
|
-
// PR-20: default scope items are not affected by private scope operations
|
|
2505
|
-
test("default scope items are not superseded by private scope operations", async () => {
|
|
2506
|
-
const { handleMemorySave } = await import("../tools/memory/handlers.js");
|
|
2507
|
-
|
|
2508
|
-
// Save a decision in the default scope
|
|
2509
|
-
const r1 = await handleMemorySave(
|
|
2510
|
-
{
|
|
2511
|
-
statement: "We decided to use PostgreSQL for the production database.",
|
|
2512
|
-
kind: "decision",
|
|
2513
|
-
},
|
|
2514
|
-
DEFAULT_CONFIG,
|
|
2515
|
-
"conv-scope-isolate-1",
|
|
2516
|
-
"msg-decision-default",
|
|
2517
|
-
"default",
|
|
2518
|
-
);
|
|
2519
|
-
expect(r1.isError).toBe(false);
|
|
2520
|
-
|
|
2521
|
-
const db = getDb();
|
|
2522
|
-
const defaultBefore = db
|
|
2523
|
-
.select()
|
|
2524
|
-
.from(memoryItems)
|
|
2525
|
-
.where(
|
|
2526
|
-
and(
|
|
2527
|
-
eq(memoryItems.scopeId, "default"),
|
|
2528
|
-
eq(memoryItems.status, "active"),
|
|
2529
|
-
),
|
|
2530
|
-
)
|
|
2531
|
-
.all();
|
|
2532
|
-
expect(defaultBefore.length).toBeGreaterThan(0);
|
|
2533
|
-
|
|
2534
|
-
// Now save a different decision in a private scope
|
|
2535
|
-
const r2 = await handleMemorySave(
|
|
2536
|
-
{
|
|
2537
|
-
statement:
|
|
2538
|
-
"We decided to use SQLite for the production database instead.",
|
|
2539
|
-
kind: "decision",
|
|
2540
|
-
},
|
|
2541
|
-
DEFAULT_CONFIG,
|
|
2542
|
-
"conv-scope-isolate-2",
|
|
2543
|
-
"msg-decision-private",
|
|
2544
|
-
"private:thread-55",
|
|
2545
|
-
);
|
|
2546
|
-
expect(r2.isError).toBe(false);
|
|
2547
|
-
|
|
2548
|
-
// The default scope items should still be active — private scope must not affect them
|
|
2549
|
-
const defaultAfter = db
|
|
2550
|
-
.select()
|
|
2551
|
-
.from(memoryItems)
|
|
2552
|
-
.where(
|
|
2553
|
-
and(
|
|
2554
|
-
eq(memoryItems.scopeId, "default"),
|
|
2555
|
-
eq(memoryItems.status, "active"),
|
|
2556
|
-
),
|
|
2557
|
-
)
|
|
2558
|
-
.all();
|
|
2559
|
-
|
|
2560
|
-
expect(defaultAfter.length).toBe(defaultBefore.length);
|
|
2561
|
-
for (const item of defaultAfter) {
|
|
2562
|
-
expect(item.status).toBe("active");
|
|
2563
|
-
}
|
|
2564
|
-
});
|
|
2565
|
-
|
|
2566
|
-
test("private conversation summary inherits private scope_id", async () => {
|
|
2567
|
-
const db = getDb();
|
|
2568
|
-
const conv = createConversation({ conversationType: "private" });
|
|
2569
|
-
const scopeId = getConversationMemoryScopeId(conv.id);
|
|
2570
|
-
expect(scopeId).toMatch(/^private:/);
|
|
2571
|
-
|
|
2572
|
-
// Insert messages and segments so the summarizer has input
|
|
2573
|
-
db.insert(messages)
|
|
2574
|
-
.values({
|
|
2575
|
-
id: "msg-priv-sum-1",
|
|
2576
|
-
conversationId: conv.id,
|
|
2577
|
-
role: "user",
|
|
2578
|
-
content: JSON.stringify([
|
|
2579
|
-
{ type: "text", text: "Secret project details" },
|
|
2580
|
-
]),
|
|
2581
|
-
createdAt: conv.createdAt + 1,
|
|
2582
|
-
})
|
|
2583
|
-
.run();
|
|
2584
|
-
db.run(`
|
|
2585
|
-
INSERT INTO memory_segments (
|
|
2586
|
-
id, message_id, conversation_id, role, segment_index, text,
|
|
2587
|
-
token_estimate, scope_id, created_at, updated_at
|
|
2588
|
-
) VALUES (
|
|
2589
|
-
'seg-priv-sum-1', 'msg-priv-sum-1', '${conv.id}', 'user', 0,
|
|
2590
|
-
'Secret project details', 5, '${scopeId}',
|
|
2591
|
-
${conv.createdAt + 1}, ${conv.createdAt + 1}
|
|
2592
|
-
)
|
|
2593
|
-
`);
|
|
2594
|
-
|
|
2595
|
-
// Run the conversation summarizer
|
|
2596
|
-
const fakeJob = {
|
|
2597
|
-
id: "job-priv-sum",
|
|
2598
|
-
type: "build_conversation_summary" as const,
|
|
2599
|
-
payload: { conversationId: conv.id },
|
|
2600
|
-
status: "running" as const,
|
|
2601
|
-
attempts: 0,
|
|
2602
|
-
deferrals: 0,
|
|
2603
|
-
runAfter: 0,
|
|
2604
|
-
lastError: null,
|
|
2605
|
-
startedAt: Date.now(),
|
|
2606
|
-
createdAt: Date.now(),
|
|
2607
|
-
updatedAt: Date.now(),
|
|
2608
|
-
};
|
|
2609
|
-
await buildConversationSummaryJob(fakeJob, TEST_CONFIG);
|
|
2610
|
-
|
|
2611
|
-
const summary = db
|
|
2612
|
-
.select()
|
|
2613
|
-
.from(memorySummaries)
|
|
2614
|
-
.where(
|
|
2615
|
-
and(
|
|
2616
|
-
eq(memorySummaries.scope, "conversation"),
|
|
2617
|
-
eq(memorySummaries.scopeKey, conv.id),
|
|
2618
|
-
),
|
|
2619
|
-
)
|
|
2620
|
-
.get();
|
|
2621
|
-
|
|
2622
|
-
expect(summary).toBeDefined();
|
|
2623
|
-
expect(summary!.scopeId).toBe(scopeId);
|
|
2624
|
-
});
|
|
2625
|
-
|
|
2626
|
-
test("default-scope summary retrieval excludes private summaries", async () => {
|
|
2627
|
-
const db = getDb();
|
|
2628
|
-
const now = Date.now();
|
|
2629
|
-
|
|
2630
|
-
// Create a private conversation and build its summary
|
|
2631
|
-
const privConv = createConversation({ conversationType: "private" });
|
|
2632
|
-
const privScope = getConversationMemoryScopeId(privConv.id);
|
|
2633
|
-
|
|
2634
|
-
db.insert(messages)
|
|
2635
|
-
.values({
|
|
2636
|
-
id: "msg-scope-excl-1",
|
|
2637
|
-
conversationId: privConv.id,
|
|
2638
|
-
role: "user",
|
|
2639
|
-
content: JSON.stringify([{ type: "text", text: "Private memo" }]),
|
|
2640
|
-
createdAt: now + 1,
|
|
2641
|
-
})
|
|
2642
|
-
.run();
|
|
2643
|
-
db.run(`
|
|
2644
|
-
INSERT INTO memory_segments (
|
|
2645
|
-
id, message_id, conversation_id, role, segment_index, text,
|
|
2646
|
-
token_estimate, scope_id, created_at, updated_at
|
|
2647
|
-
) VALUES (
|
|
2648
|
-
'seg-scope-excl-1', 'msg-scope-excl-1', '${privConv.id}', 'user', 0,
|
|
2649
|
-
'Private memo', 3, '${privScope}',
|
|
2650
|
-
${now + 1}, ${now + 1}
|
|
2651
|
-
)
|
|
2652
|
-
`);
|
|
2653
|
-
|
|
2654
|
-
await buildConversationSummaryJob(
|
|
2655
|
-
{
|
|
2656
|
-
id: "job-scope-excl-priv",
|
|
2657
|
-
type: "build_conversation_summary" as const,
|
|
2658
|
-
payload: { conversationId: privConv.id },
|
|
2659
|
-
status: "running" as const,
|
|
2660
|
-
attempts: 0,
|
|
2661
|
-
deferrals: 0,
|
|
2662
|
-
runAfter: 0,
|
|
2663
|
-
lastError: null,
|
|
2664
|
-
startedAt: now,
|
|
2665
|
-
createdAt: now,
|
|
2666
|
-
updatedAt: now,
|
|
2667
|
-
},
|
|
2668
|
-
TEST_CONFIG,
|
|
2669
|
-
);
|
|
2670
|
-
|
|
2671
|
-
// Create a standard conversation and build its summary
|
|
2672
|
-
const stdConv = createConversation({ title: "Standard conv" });
|
|
2673
|
-
const stdScope = getConversationMemoryScopeId(stdConv.id);
|
|
2674
|
-
expect(stdScope).toBe("default");
|
|
2675
|
-
|
|
2676
|
-
db.insert(messages)
|
|
2677
|
-
.values({
|
|
2678
|
-
id: "msg-scope-excl-2",
|
|
2679
|
-
conversationId: stdConv.id,
|
|
2680
|
-
role: "user",
|
|
2681
|
-
content: JSON.stringify([{ type: "text", text: "Public notes" }]),
|
|
2682
|
-
createdAt: now + 2,
|
|
2683
|
-
})
|
|
2684
|
-
.run();
|
|
2685
|
-
db.run(`
|
|
2686
|
-
INSERT INTO memory_segments (
|
|
2687
|
-
id, message_id, conversation_id, role, segment_index, text,
|
|
2688
|
-
token_estimate, scope_id, created_at, updated_at
|
|
2689
|
-
) VALUES (
|
|
2690
|
-
'seg-scope-excl-2', 'msg-scope-excl-2', '${stdConv.id}', 'user', 0,
|
|
2691
|
-
'Public notes', 3, 'default',
|
|
2692
|
-
${now + 2}, ${now + 2}
|
|
2693
|
-
)
|
|
2694
|
-
`);
|
|
2695
|
-
|
|
2696
|
-
await buildConversationSummaryJob(
|
|
2697
|
-
{
|
|
2698
|
-
id: "job-scope-excl-std",
|
|
2699
|
-
type: "build_conversation_summary" as const,
|
|
2700
|
-
payload: { conversationId: stdConv.id },
|
|
2701
|
-
status: "running" as const,
|
|
2702
|
-
attempts: 0,
|
|
2703
|
-
deferrals: 0,
|
|
2704
|
-
runAfter: 0,
|
|
2705
|
-
lastError: null,
|
|
2706
|
-
startedAt: now,
|
|
2707
|
-
createdAt: now,
|
|
2708
|
-
updatedAt: now,
|
|
2709
|
-
},
|
|
2710
|
-
TEST_CONFIG,
|
|
2711
|
-
);
|
|
2712
|
-
|
|
2713
|
-
// Query summaries scoped to 'default' — should only include the standard one
|
|
2714
|
-
const defaultSummaries = db
|
|
2715
|
-
.select()
|
|
2716
|
-
.from(memorySummaries)
|
|
2717
|
-
.where(eq(memorySummaries.scopeId, "default"))
|
|
2718
|
-
.all();
|
|
2719
|
-
const privateSummaries = db
|
|
2720
|
-
.select()
|
|
2721
|
-
.from(memorySummaries)
|
|
2722
|
-
.where(eq(memorySummaries.scopeId, privScope))
|
|
2723
|
-
.all();
|
|
2724
|
-
|
|
2725
|
-
expect(defaultSummaries).toHaveLength(1);
|
|
2726
|
-
expect(defaultSummaries[0].scopeKey).toBe(stdConv.id);
|
|
2727
|
-
|
|
2728
|
-
expect(privateSummaries).toHaveLength(1);
|
|
2729
|
-
expect(privateSummaries[0].scopeKey).toBe(privConv.id);
|
|
2730
|
-
});
|
|
2731
|
-
|
|
2732
|
-
// ── End-to-end memory-boundary regression tests ─────────────────────
|
|
2733
|
-
|
|
2734
|
-
test("e2e: private-only facts are recalled in private conversation but not in standard conversation", async () => {
|
|
2735
|
-
const db = getDb();
|
|
2736
|
-
const { handleMemorySave } = await import("../tools/memory/handlers.js");
|
|
2737
|
-
const now = Date.now();
|
|
2738
|
-
|
|
2739
|
-
// 1. Create a private conversation and save a distinctive fact
|
|
2740
|
-
const privConv = createConversation({
|
|
2741
|
-
title: "Private e2e test",
|
|
2742
|
-
conversationType: "private",
|
|
2743
|
-
});
|
|
2744
|
-
const privScope = getConversationMemoryScopeId(privConv.id);
|
|
2745
|
-
expect(privScope).toMatch(/^private:/);
|
|
2746
|
-
|
|
2747
|
-
db.insert(messages)
|
|
2748
|
-
.values({
|
|
2749
|
-
id: "msg-priv-e2e-zephyr",
|
|
2750
|
-
conversationId: privConv.id,
|
|
2751
|
-
role: "user",
|
|
2752
|
-
content: JSON.stringify([
|
|
2753
|
-
{
|
|
2754
|
-
type: "text",
|
|
2755
|
-
text: "I prefer using the Zephyr framework for all backend microservices.",
|
|
2756
|
-
},
|
|
2757
|
-
]),
|
|
2758
|
-
createdAt: now,
|
|
2759
|
-
})
|
|
2760
|
-
.run();
|
|
2761
|
-
|
|
2762
|
-
const r1 = await handleMemorySave(
|
|
2763
|
-
{
|
|
2764
|
-
statement:
|
|
2765
|
-
"I prefer using the Zephyr framework for all backend microservices.",
|
|
2766
|
-
kind: "preference",
|
|
2767
|
-
},
|
|
2768
|
-
DEFAULT_CONFIG,
|
|
2769
|
-
privConv.id,
|
|
2770
|
-
"msg-priv-e2e-zephyr",
|
|
2771
|
-
privScope,
|
|
2772
|
-
);
|
|
2773
|
-
expect(r1.isError).toBe(false);
|
|
2774
|
-
|
|
2775
|
-
// Verify items were stored with the private scope
|
|
2776
|
-
const privateItems = db
|
|
2777
|
-
.select()
|
|
2778
|
-
.from(memoryItems)
|
|
2779
|
-
.where(eq(memoryItems.scopeId, privScope))
|
|
2780
|
-
.all();
|
|
2781
|
-
expect(privateItems.length).toBeGreaterThan(0);
|
|
2782
|
-
expect(
|
|
2783
|
-
privateItems.some((i) => i.statement.toLowerCase().includes("zephyr")),
|
|
2784
|
-
).toBe(true);
|
|
2785
|
-
|
|
2786
|
-
// Add item source (handleMemorySave doesn't create sources; semantic search requires them)
|
|
2787
|
-
db.insert(memoryItemSources)
|
|
2788
|
-
.values({
|
|
2789
|
-
memoryItemId: privateItems[0].id,
|
|
2790
|
-
messageId: "msg-priv-e2e-zephyr",
|
|
2791
|
-
evidence: "Zephyr framework preference",
|
|
2792
|
-
createdAt: now,
|
|
2793
|
-
})
|
|
2794
|
-
.run();
|
|
2795
|
-
|
|
2796
|
-
// Mark the source message as compacted so the item isn't filtered
|
|
2797
|
-
// as "already in context"
|
|
2798
|
-
db.update(conversations)
|
|
2799
|
-
.set({ contextCompactedMessageCount: 1 })
|
|
2800
|
-
.where(eq(conversations.id, privConv.id))
|
|
2801
|
-
.run();
|
|
2802
|
-
|
|
2803
|
-
const privateItemKeys = privateItems.map((i) => `item:${i.id}`);
|
|
2804
|
-
|
|
2805
|
-
// 2. Mock Qdrant to return the private item
|
|
2806
|
-
mockQdrantResults = [
|
|
2807
|
-
{
|
|
2808
|
-
id: "emb-zephyr",
|
|
2809
|
-
score: 0.9,
|
|
2810
|
-
payload: {
|
|
2811
|
-
target_type: "item",
|
|
2812
|
-
target_id: privateItems[0].id,
|
|
2813
|
-
text: privateItems[0].statement,
|
|
2814
|
-
kind: "preference",
|
|
2815
|
-
status: "active",
|
|
2816
|
-
created_at: now,
|
|
2817
|
-
last_seen_at: now,
|
|
2818
|
-
},
|
|
2819
|
-
},
|
|
2820
|
-
];
|
|
2821
|
-
|
|
2822
|
-
// 3. Create a standard conversation
|
|
2823
|
-
const stdConv = createConversation({
|
|
2824
|
-
title: "Standard e2e test",
|
|
2825
|
-
conversationType: "standard",
|
|
2826
|
-
});
|
|
2827
|
-
const stdScope = getConversationMemoryScopeId(stdConv.id);
|
|
2828
|
-
expect(stdScope).toBe("default");
|
|
2829
|
-
|
|
2830
|
-
db.insert(messages)
|
|
2831
|
-
.values({
|
|
2832
|
-
id: "msg-std-e2e-noleak",
|
|
2833
|
-
conversationId: stdConv.id,
|
|
2834
|
-
role: "user",
|
|
2835
|
-
content: JSON.stringify([
|
|
2836
|
-
{ type: "text", text: "placeholder for standard conv" },
|
|
2837
|
-
]),
|
|
2838
|
-
createdAt: now,
|
|
2839
|
-
})
|
|
2840
|
-
.run();
|
|
2841
|
-
|
|
2842
|
-
const recallConfig = {
|
|
2843
|
-
...TEST_CONFIG,
|
|
2844
|
-
memory: {
|
|
2845
|
-
...TEST_CONFIG.memory,
|
|
2846
|
-
embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
|
|
2847
|
-
},
|
|
2848
|
-
};
|
|
2849
|
-
|
|
2850
|
-
// 4. Private conversation recall — should find the Zephyr fact
|
|
2851
|
-
const privRecall = await buildMemoryRecall(
|
|
2852
|
-
"Zephyr framework microservices",
|
|
2853
|
-
privConv.id,
|
|
2854
|
-
recallConfig,
|
|
2855
|
-
{
|
|
2856
|
-
scopePolicyOverride: {
|
|
2857
|
-
scopeId: privScope,
|
|
2858
|
-
fallbackToDefault: true,
|
|
2859
|
-
},
|
|
2860
|
-
},
|
|
2861
|
-
);
|
|
2862
|
-
expect(privRecall.enabled).toBe(true);
|
|
2863
|
-
const privCandidateKeys = privRecall.topCandidates.map((c) => c.key);
|
|
2864
|
-
expect(privCandidateKeys.some((k) => privateItemKeys.includes(k))).toBe(
|
|
2865
|
-
true,
|
|
2866
|
-
);
|
|
2867
|
-
|
|
2868
|
-
// 5. Standard conversation recall — must NOT find the Zephyr fact (no leak)
|
|
2869
|
-
const stdRecall = await buildMemoryRecall(
|
|
2870
|
-
"Zephyr framework microservices",
|
|
2871
|
-
stdConv.id,
|
|
2872
|
-
recallConfig,
|
|
2873
|
-
{
|
|
2874
|
-
scopeId: stdScope,
|
|
2875
|
-
scopePolicyOverride: undefined,
|
|
2876
|
-
},
|
|
2877
|
-
);
|
|
2878
|
-
const stdCandidateKeys = stdRecall.topCandidates.map((c) => c.key);
|
|
2879
|
-
const hasZephyrInStandard = privateItemKeys.some((k) =>
|
|
2880
|
-
stdCandidateKeys.includes(k),
|
|
2881
|
-
);
|
|
2882
|
-
expect(hasZephyrInStandard).toBe(false);
|
|
2883
|
-
expect(stdRecall.injectedText.toLowerCase()).not.toContain("zephyr");
|
|
2884
|
-
});
|
|
2885
|
-
|
|
2886
|
-
test("e2e: private conversation still recalls facts from default memory scope", async () => {
|
|
2887
|
-
const db = getDb();
|
|
2888
|
-
const { handleMemorySave } = await import("../tools/memory/handlers.js");
|
|
2889
|
-
const now = Date.now();
|
|
2890
|
-
|
|
2891
|
-
// 1. Save a fact to default scope via a standard conversation
|
|
2892
|
-
const stdConv = createConversation({
|
|
2893
|
-
title: "Default scope source",
|
|
2894
|
-
conversationType: "standard",
|
|
2895
|
-
});
|
|
2896
|
-
const stdScope = getConversationMemoryScopeId(stdConv.id);
|
|
2897
|
-
expect(stdScope).toBe("default");
|
|
2898
|
-
|
|
2899
|
-
db.insert(messages)
|
|
2900
|
-
.values({
|
|
2901
|
-
id: "msg-std-e2e-obsidian",
|
|
2902
|
-
conversationId: stdConv.id,
|
|
2903
|
-
role: "user",
|
|
2904
|
-
content: JSON.stringify([
|
|
2905
|
-
{
|
|
2906
|
-
type: "text",
|
|
2907
|
-
text: "I prefer using the Obsidian editor for all my note-taking workflows.",
|
|
2908
|
-
},
|
|
2909
|
-
]),
|
|
2910
|
-
createdAt: now,
|
|
2911
|
-
})
|
|
2912
|
-
.run();
|
|
2913
|
-
|
|
2914
|
-
const r1 = await handleMemorySave(
|
|
2915
|
-
{
|
|
2916
|
-
statement:
|
|
2917
|
-
"I prefer using the Obsidian editor for all my note-taking workflows.",
|
|
2918
|
-
kind: "preference",
|
|
2919
|
-
},
|
|
2920
|
-
DEFAULT_CONFIG,
|
|
2921
|
-
stdConv.id,
|
|
2922
|
-
"msg-std-e2e-obsidian",
|
|
2923
|
-
"default",
|
|
2924
|
-
);
|
|
2925
|
-
expect(r1.isError).toBe(false);
|
|
2926
|
-
|
|
2927
|
-
// Verify items landed in the default scope
|
|
2928
|
-
const defaultItems = db
|
|
2929
|
-
.select()
|
|
2930
|
-
.from(memoryItems)
|
|
2931
|
-
.where(
|
|
2932
|
-
and(
|
|
2933
|
-
eq(memoryItems.scopeId, "default"),
|
|
2934
|
-
eq(memoryItems.status, "active"),
|
|
2935
|
-
),
|
|
2936
|
-
)
|
|
2937
|
-
.all();
|
|
2938
|
-
const obsidianItem = defaultItems.find((i) =>
|
|
2939
|
-
i.statement.toLowerCase().includes("obsidian"),
|
|
2940
|
-
);
|
|
2941
|
-
expect(obsidianItem).toBeDefined();
|
|
2942
|
-
|
|
2943
|
-
// Add item source (handleMemorySave doesn't create sources; semantic search requires them)
|
|
2944
|
-
db.insert(memoryItemSources)
|
|
2945
|
-
.values({
|
|
2946
|
-
memoryItemId: obsidianItem!.id,
|
|
2947
|
-
messageId: "msg-std-e2e-obsidian",
|
|
2948
|
-
evidence: "Obsidian editor preference",
|
|
2949
|
-
createdAt: now,
|
|
2950
|
-
})
|
|
2951
|
-
.run();
|
|
2952
|
-
|
|
2953
|
-
// 2. Create a private conversation
|
|
2954
|
-
const privConv = createConversation({
|
|
2955
|
-
title: "Private fallback test",
|
|
2956
|
-
conversationType: "private",
|
|
2957
|
-
});
|
|
2958
|
-
const privScope = getConversationMemoryScopeId(privConv.id);
|
|
2959
|
-
expect(privScope).toMatch(/^private:/);
|
|
2960
|
-
|
|
2961
|
-
db.insert(messages)
|
|
2962
|
-
.values({
|
|
2963
|
-
id: "msg-priv-e2e-fallback",
|
|
2964
|
-
conversationId: privConv.id,
|
|
2965
|
-
role: "user",
|
|
2966
|
-
content: JSON.stringify([
|
|
2967
|
-
{ type: "text", text: "placeholder for private conv fallback test" },
|
|
2968
|
-
]),
|
|
2969
|
-
createdAt: now + 1,
|
|
2970
|
-
})
|
|
2971
|
-
.run();
|
|
2972
|
-
|
|
2973
|
-
// Mock Qdrant to return the default-scope Obsidian item
|
|
2974
|
-
mockQdrantResults = [
|
|
2975
|
-
{
|
|
2976
|
-
id: "emb-obsidian",
|
|
2977
|
-
score: 0.9,
|
|
2978
|
-
payload: {
|
|
2979
|
-
target_type: "item",
|
|
2980
|
-
target_id: obsidianItem!.id,
|
|
2981
|
-
text: obsidianItem!.statement,
|
|
2982
|
-
kind: "preference",
|
|
2983
|
-
status: "active",
|
|
2984
|
-
created_at: now,
|
|
2985
|
-
last_seen_at: now,
|
|
2986
|
-
},
|
|
2987
|
-
},
|
|
2988
|
-
];
|
|
2989
|
-
|
|
2990
|
-
const recallConfig = {
|
|
2991
|
-
...TEST_CONFIG,
|
|
2992
|
-
memory: {
|
|
2993
|
-
...TEST_CONFIG.memory,
|
|
2994
|
-
embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
|
|
2995
|
-
},
|
|
2996
|
-
};
|
|
2997
|
-
|
|
2998
|
-
// 3. Private conversation recall with fallback to default — should find the Obsidian fact
|
|
2999
|
-
const privRecall = await buildMemoryRecall(
|
|
3000
|
-
"Obsidian editor note-taking",
|
|
3001
|
-
privConv.id,
|
|
3002
|
-
recallConfig,
|
|
3003
|
-
{
|
|
3004
|
-
scopePolicyOverride: {
|
|
3005
|
-
scopeId: privScope,
|
|
3006
|
-
fallbackToDefault: true,
|
|
3007
|
-
},
|
|
3008
|
-
},
|
|
3009
|
-
);
|
|
3010
|
-
expect(privRecall).toBeDefined();
|
|
3011
|
-
expect(privRecall.injectedText.toLowerCase()).toContain("obsidian");
|
|
3012
|
-
});
|
|
3013
|
-
|
|
3014
|
-
// Backfill preserves private conversation scope on memory segments
|
|
3015
|
-
test("backfillJob preserves private conversation scope during reindex", async () => {
|
|
3016
|
-
const db = getDb();
|
|
3017
|
-
|
|
3018
|
-
// Create a private conversation with a message
|
|
3019
|
-
const conv = createConversation({
|
|
3020
|
-
title: "Backfill scope test",
|
|
3021
|
-
conversationType: "private",
|
|
3022
|
-
});
|
|
3023
|
-
expect(conv.memoryScopeId).toMatch(/^private:/);
|
|
3024
|
-
|
|
3025
|
-
// Insert a message directly (bypassing addMessage to avoid pre-indexing)
|
|
3026
|
-
const msgId = "msg-backfill-scope-test";
|
|
3027
|
-
db.insert(messages)
|
|
3028
|
-
.values({
|
|
3029
|
-
id: msgId,
|
|
3030
|
-
conversationId: conv.id,
|
|
3031
|
-
role: "user",
|
|
3032
|
-
content:
|
|
3033
|
-
"My confidential backfill test content for private conversation preservation.",
|
|
3034
|
-
createdAt: conv.createdAt + 1,
|
|
3035
|
-
})
|
|
3036
|
-
.run();
|
|
3037
|
-
|
|
3038
|
-
// Run the backfill job — it should look up the conversation scope
|
|
3039
|
-
const fakeJob = {
|
|
3040
|
-
id: "job-backfill-scope",
|
|
3041
|
-
type: "backfill" as const,
|
|
3042
|
-
payload: { force: true },
|
|
3043
|
-
status: "running" as const,
|
|
3044
|
-
attempts: 0,
|
|
3045
|
-
deferrals: 0,
|
|
3046
|
-
runAfter: 0,
|
|
3047
|
-
lastError: null,
|
|
3048
|
-
startedAt: Date.now(),
|
|
3049
|
-
createdAt: Date.now(),
|
|
3050
|
-
updatedAt: Date.now(),
|
|
3051
|
-
};
|
|
3052
|
-
await backfillJob(fakeJob, TEST_CONFIG);
|
|
3053
|
-
|
|
3054
|
-
// Verify the segments were indexed with the private scope
|
|
3055
|
-
const segments = db
|
|
3056
|
-
.select()
|
|
3057
|
-
.from(memorySegments)
|
|
3058
|
-
.where(eq(memorySegments.messageId, msgId))
|
|
3059
|
-
.all();
|
|
3060
|
-
|
|
3061
|
-
expect(segments.length).toBeGreaterThan(0);
|
|
3062
|
-
for (const seg of segments) {
|
|
3063
|
-
expect(seg.scopeId).toBe(conv.memoryScopeId);
|
|
3064
|
-
}
|
|
3065
|
-
});
|
|
3066
|
-
|
|
3067
|
-
test("backfillJob preserves provenance trust gating during reindex", async () => {
|
|
3068
|
-
const db = getDb();
|
|
3069
|
-
|
|
3070
|
-
const conv = createConversation("Backfill provenance trust gate");
|
|
3071
|
-
const msgId = "msg-backfill-untrusted-provenance";
|
|
3072
|
-
db.insert(messages)
|
|
3073
|
-
.values({
|
|
3074
|
-
id: msgId,
|
|
3075
|
-
conversationId: conv.id,
|
|
3076
|
-
role: "user",
|
|
3077
|
-
content:
|
|
3078
|
-
"Untrusted sender says preferences should not become durable profile memory.",
|
|
3079
|
-
metadata: JSON.stringify({
|
|
3080
|
-
provenanceTrustClass: "trusted_contact",
|
|
3081
|
-
provenanceSourceChannel: "telegram",
|
|
3082
|
-
}),
|
|
3083
|
-
createdAt: conv.createdAt + 1,
|
|
3084
|
-
})
|
|
3085
|
-
.run();
|
|
3086
|
-
|
|
3087
|
-
const fakeJob = {
|
|
3088
|
-
id: "job-backfill-untrusted-provenance",
|
|
3089
|
-
type: "backfill" as const,
|
|
3090
|
-
payload: { force: true },
|
|
3091
|
-
status: "running" as const,
|
|
3092
|
-
attempts: 0,
|
|
3093
|
-
deferrals: 0,
|
|
3094
|
-
runAfter: 0,
|
|
3095
|
-
lastError: null,
|
|
3096
|
-
startedAt: Date.now(),
|
|
3097
|
-
createdAt: Date.now(),
|
|
3098
|
-
updatedAt: Date.now(),
|
|
3099
|
-
};
|
|
3100
|
-
await backfillJob(fakeJob, TEST_CONFIG);
|
|
3101
|
-
|
|
3102
|
-
const segments = db
|
|
3103
|
-
.select()
|
|
3104
|
-
.from(memorySegments)
|
|
3105
|
-
.where(eq(memorySegments.messageId, msgId))
|
|
3106
|
-
.all();
|
|
3107
|
-
expect(segments.length).toBeGreaterThan(0);
|
|
3108
|
-
|
|
3109
|
-
const extractJobs = db
|
|
3110
|
-
.select()
|
|
3111
|
-
.from(memoryJobs)
|
|
3112
|
-
.where(eq(memoryJobs.type, "extract_items"))
|
|
3113
|
-
.all()
|
|
3114
|
-
.filter((job) => JSON.parse(job.payload).messageId === msgId);
|
|
3115
|
-
expect(extractJobs).toHaveLength(0);
|
|
3116
|
-
});
|
|
3117
|
-
|
|
3118
|
-
test("provenance fields are preserved in stored message metadata", async () => {
|
|
3119
|
-
const conv = createConversation("provenance-preserve");
|
|
3120
|
-
const metadata = {
|
|
3121
|
-
userMessageChannel: "telegram" as const,
|
|
3122
|
-
provenanceTrustClass: "trusted_contact" as const,
|
|
3123
|
-
provenanceSourceChannel: "telegram" as const,
|
|
3124
|
-
provenanceGuardianExternalUserId: "guardian-123",
|
|
3125
|
-
provenanceRequesterIdentifier: "Alice",
|
|
3126
|
-
};
|
|
3127
|
-
const msg = await addMessage(
|
|
3128
|
-
conv.id,
|
|
3129
|
-
"user",
|
|
3130
|
-
"Hello from telegram",
|
|
3131
|
-
metadata,
|
|
3132
|
-
);
|
|
3133
|
-
|
|
3134
|
-
const db = getDb();
|
|
3135
|
-
const stored = db
|
|
3136
|
-
.select({ metadata: messages.metadata })
|
|
3137
|
-
.from(messages)
|
|
3138
|
-
.where(eq(messages.id, msg.id))
|
|
3139
|
-
.get();
|
|
3140
|
-
|
|
3141
|
-
expect(stored).toBeTruthy();
|
|
3142
|
-
const parsed = JSON.parse(stored!.metadata!);
|
|
3143
|
-
expect(parsed.provenanceTrustClass).toBe("trusted_contact");
|
|
3144
|
-
expect(parsed.provenanceSourceChannel).toBe("telegram");
|
|
3145
|
-
expect(parsed.provenanceGuardianExternalUserId).toBe("guardian-123");
|
|
3146
|
-
expect(parsed.provenanceRequesterIdentifier).toBe("Alice");
|
|
3147
|
-
});
|
|
3148
|
-
|
|
3149
|
-
test("messageMetadataSchema validates provenance fields", () => {
|
|
3150
|
-
const valid = messageMetadataSchema.safeParse({
|
|
3151
|
-
provenanceTrustClass: "guardian",
|
|
3152
|
-
provenanceSourceChannel: "vellum",
|
|
3153
|
-
});
|
|
3154
|
-
expect(valid.success).toBe(true);
|
|
3155
|
-
|
|
3156
|
-
const validNonGuardian = messageMetadataSchema.safeParse({
|
|
3157
|
-
provenanceTrustClass: "trusted_contact",
|
|
3158
|
-
provenanceSourceChannel: "telegram",
|
|
3159
|
-
provenanceGuardianExternalUserId: "g-123",
|
|
3160
|
-
provenanceRequesterIdentifier: "Bob",
|
|
3161
|
-
});
|
|
3162
|
-
expect(validNonGuardian.success).toBe(true);
|
|
3163
|
-
|
|
3164
|
-
const validUnverified = messageMetadataSchema.safeParse({
|
|
3165
|
-
provenanceTrustClass: "unknown",
|
|
3166
|
-
});
|
|
3167
|
-
expect(validUnverified.success).toBe(true);
|
|
3168
|
-
});
|
|
3169
|
-
|
|
3170
|
-
test("provenanceFromTrustContext returns unverified_channel default when no context", () => {
|
|
3171
|
-
const result = provenanceFromTrustContext(null);
|
|
3172
|
-
expect(result.provenanceTrustClass).toBe("unknown");
|
|
3173
|
-
expect(result.provenanceSourceChannel).toBeUndefined();
|
|
3174
|
-
|
|
3175
|
-
const result2 = provenanceFromTrustContext(undefined);
|
|
3176
|
-
expect(result2.provenanceTrustClass).toBe("unknown");
|
|
3177
|
-
});
|
|
3178
|
-
|
|
3179
|
-
test("provenanceFromTrustContext extracts fields from guardian context", () => {
|
|
3180
|
-
const ctx = {
|
|
3181
|
-
sourceChannel: "telegram" as const,
|
|
3182
|
-
trustClass: "trusted_contact" as const,
|
|
3183
|
-
guardianExternalUserId: "g-456",
|
|
3184
|
-
requesterIdentifier: "Charlie",
|
|
3185
|
-
};
|
|
3186
|
-
const result = provenanceFromTrustContext(ctx);
|
|
3187
|
-
expect(result.provenanceTrustClass).toBe("trusted_contact");
|
|
3188
|
-
expect(result.provenanceSourceChannel).toBe("telegram");
|
|
3189
|
-
expect(result.provenanceGuardianExternalUserId).toBe("g-456");
|
|
3190
|
-
expect(result.provenanceRequesterIdentifier).toBe("Charlie");
|
|
3191
|
-
});
|
|
3192
|
-
|
|
3193
|
-
test("indexMessageNow receives provenanceTrustClass when metadata includes it", async () => {
|
|
3194
|
-
const conv = createConversation("provenance-indexer");
|
|
3195
|
-
const metadata = {
|
|
3196
|
-
provenanceTrustClass: "trusted_contact" as const,
|
|
3197
|
-
provenanceSourceChannel: "telegram" as const,
|
|
3198
|
-
};
|
|
3199
|
-
// addMessage parses metadata and passes provenanceTrustClass to indexMessageNow.
|
|
3200
|
-
// We verify indirectly: the message is persisted with metadata and segments are indexed.
|
|
3201
|
-
const msg = await addMessage(
|
|
3202
|
-
conv.id,
|
|
3203
|
-
"user",
|
|
3204
|
-
"Test provenance indexing message with enough content to segment",
|
|
3205
|
-
metadata,
|
|
3206
|
-
);
|
|
3207
|
-
expect(msg.id).toBeTruthy();
|
|
3208
|
-
|
|
3209
|
-
// Verify segments were created (indexMessageNow was called successfully)
|
|
3210
|
-
const segments = getDb()
|
|
3211
|
-
.select()
|
|
3212
|
-
.from(memorySegments)
|
|
3213
|
-
.where(eq(memorySegments.messageId, msg.id))
|
|
3214
|
-
.all();
|
|
3215
|
-
expect(segments.length).toBeGreaterThan(0);
|
|
3216
|
-
});
|
|
3217
|
-
|
|
3218
|
-
// ── Trust-aware extraction gating tests (M3) ───────────────────────
|
|
3219
|
-
|
|
3220
|
-
test("untrusted actor messages do not enqueue batch_extract", async () => {
|
|
3221
|
-
const db = getDb();
|
|
3222
|
-
const now = Date.now();
|
|
3223
|
-
db.insert(conversations)
|
|
3224
|
-
.values({
|
|
3225
|
-
id: "conv-untrusted-gate",
|
|
3226
|
-
title: null,
|
|
3227
|
-
createdAt: now,
|
|
3228
|
-
updatedAt: now,
|
|
3229
|
-
totalInputTokens: 0,
|
|
3230
|
-
totalOutputTokens: 0,
|
|
3231
|
-
totalEstimatedCost: 0,
|
|
3232
|
-
contextSummary: null,
|
|
3233
|
-
contextCompactedMessageCount: 0,
|
|
3234
|
-
contextCompactedAt: null,
|
|
3235
|
-
})
|
|
3236
|
-
.run();
|
|
3237
|
-
db.insert(messages)
|
|
3238
|
-
.values({
|
|
3239
|
-
id: "msg-untrusted-gate",
|
|
3240
|
-
conversationId: "conv-untrusted-gate",
|
|
3241
|
-
role: "user",
|
|
3242
|
-
content: JSON.stringify([
|
|
3243
|
-
{
|
|
3244
|
-
type: "text",
|
|
3245
|
-
text: "Untrusted user preference for dark mode across all editor themes and interfaces.",
|
|
3246
|
-
},
|
|
3247
|
-
]),
|
|
3248
|
-
createdAt: now,
|
|
3249
|
-
})
|
|
3250
|
-
.run();
|
|
3251
|
-
|
|
3252
|
-
const result = await indexMessageNow(
|
|
3253
|
-
{
|
|
3254
|
-
messageId: "msg-untrusted-gate",
|
|
3255
|
-
conversationId: "conv-untrusted-gate",
|
|
3256
|
-
role: "user",
|
|
3257
|
-
content: JSON.stringify([
|
|
3258
|
-
{
|
|
3259
|
-
type: "text",
|
|
3260
|
-
text: "Untrusted user preference for dark mode across all editor themes and interfaces.",
|
|
3261
|
-
},
|
|
3262
|
-
]),
|
|
3263
|
-
createdAt: now,
|
|
3264
|
-
provenanceTrustClass: "trusted_contact",
|
|
3265
|
-
},
|
|
3266
|
-
DEFAULT_CONFIG.memory,
|
|
3267
|
-
);
|
|
3268
|
-
|
|
3269
|
-
expect(result.indexedSegments).toBeGreaterThan(0);
|
|
3270
|
-
|
|
3271
|
-
// No batch_extract jobs should be enqueued for untrusted actors
|
|
3272
|
-
const extractJobs = db
|
|
3273
|
-
.select()
|
|
3274
|
-
.from(memoryJobs)
|
|
3275
|
-
.where(eq(memoryJobs.type, "batch_extract"))
|
|
3276
|
-
.all()
|
|
3277
|
-
.filter(
|
|
3278
|
-
(j) => JSON.parse(j.payload).conversationId === "conv-untrusted-gate",
|
|
3279
|
-
);
|
|
3280
|
-
expect(extractJobs.length).toBe(0);
|
|
3281
|
-
|
|
3282
|
-
// enqueuedJobs reflects embed jobs only (no extraction for untrusted)
|
|
3283
|
-
expect(result.enqueuedJobs).toBe(result.indexedSegments);
|
|
3284
|
-
});
|
|
3285
|
-
|
|
3286
|
-
test("trusted guardian messages still enqueue extraction", async () => {
|
|
3287
|
-
const db = getDb();
|
|
3288
|
-
const now = Date.now();
|
|
3289
|
-
db.insert(conversations)
|
|
3290
|
-
.values({
|
|
3291
|
-
id: "conv-trusted-gate",
|
|
3292
|
-
title: null,
|
|
3293
|
-
createdAt: now,
|
|
3294
|
-
updatedAt: now,
|
|
3295
|
-
totalInputTokens: 0,
|
|
3296
|
-
totalOutputTokens: 0,
|
|
3297
|
-
totalEstimatedCost: 0,
|
|
3298
|
-
contextSummary: null,
|
|
3299
|
-
contextCompactedMessageCount: 0,
|
|
3300
|
-
contextCompactedAt: null,
|
|
3301
|
-
})
|
|
3302
|
-
.run();
|
|
3303
|
-
db.insert(messages)
|
|
3304
|
-
.values({
|
|
3305
|
-
id: "msg-trusted-gate",
|
|
3306
|
-
conversationId: "conv-trusted-gate",
|
|
3307
|
-
role: "user",
|
|
3308
|
-
content: JSON.stringify([
|
|
3309
|
-
{
|
|
3310
|
-
type: "text",
|
|
3311
|
-
text: "Trusted guardian preference for light mode with high contrast accessibility settings.",
|
|
3312
|
-
},
|
|
3313
|
-
]),
|
|
3314
|
-
createdAt: now,
|
|
3315
|
-
})
|
|
3316
|
-
.run();
|
|
3317
|
-
|
|
3318
|
-
const result = await indexMessageNow(
|
|
3319
|
-
{
|
|
3320
|
-
messageId: "msg-trusted-gate",
|
|
3321
|
-
conversationId: "conv-trusted-gate",
|
|
3322
|
-
role: "user",
|
|
3323
|
-
content: JSON.stringify([
|
|
3324
|
-
{
|
|
3325
|
-
type: "text",
|
|
3326
|
-
text: "Trusted guardian preference for light mode with high contrast accessibility settings.",
|
|
3327
|
-
},
|
|
3328
|
-
]),
|
|
3329
|
-
createdAt: now,
|
|
3330
|
-
provenanceTrustClass: "guardian",
|
|
3331
|
-
},
|
|
3332
|
-
DEFAULT_CONFIG.memory,
|
|
3333
|
-
);
|
|
3334
|
-
|
|
3335
|
-
expect(result.indexedSegments).toBeGreaterThan(0);
|
|
3336
|
-
|
|
3337
|
-
// batch_extract job should be enqueued (debounced) for trusted guardian
|
|
3338
|
-
const extractJobs = db
|
|
3339
|
-
.select()
|
|
3340
|
-
.from(memoryJobs)
|
|
3341
|
-
.where(eq(memoryJobs.type, "batch_extract"))
|
|
3342
|
-
.all()
|
|
3343
|
-
.filter(
|
|
3344
|
-
(j) => JSON.parse(j.payload).conversationId === "conv-trusted-gate",
|
|
3345
|
-
);
|
|
3346
|
-
expect(extractJobs.length).toBe(1);
|
|
3347
|
-
});
|
|
3348
|
-
|
|
3349
|
-
test("legacy messages without provenance still enqueue extraction", async () => {
|
|
3350
|
-
const db = getDb();
|
|
3351
|
-
const now = Date.now();
|
|
3352
|
-
db.insert(conversations)
|
|
3353
|
-
.values({
|
|
3354
|
-
id: "conv-legacy-gate",
|
|
3355
|
-
title: null,
|
|
3356
|
-
createdAt: now,
|
|
3357
|
-
updatedAt: now,
|
|
3358
|
-
totalInputTokens: 0,
|
|
3359
|
-
totalOutputTokens: 0,
|
|
3360
|
-
totalEstimatedCost: 0,
|
|
3361
|
-
contextSummary: null,
|
|
3362
|
-
contextCompactedMessageCount: 0,
|
|
3363
|
-
contextCompactedAt: null,
|
|
3364
|
-
})
|
|
3365
|
-
.run();
|
|
3366
|
-
db.insert(messages)
|
|
3367
|
-
.values({
|
|
3368
|
-
id: "msg-legacy-gate",
|
|
3369
|
-
conversationId: "conv-legacy-gate",
|
|
3370
|
-
role: "user",
|
|
3371
|
-
content: JSON.stringify([
|
|
3372
|
-
{
|
|
3373
|
-
type: "text",
|
|
3374
|
-
text: "Legacy message with no provenance info that still needs full extraction processing.",
|
|
3375
|
-
},
|
|
3376
|
-
]),
|
|
3377
|
-
createdAt: now,
|
|
3378
|
-
})
|
|
3379
|
-
.run();
|
|
3380
|
-
|
|
3381
|
-
const result = await indexMessageNow(
|
|
3382
|
-
{
|
|
3383
|
-
messageId: "msg-legacy-gate",
|
|
3384
|
-
conversationId: "conv-legacy-gate",
|
|
3385
|
-
role: "user",
|
|
3386
|
-
content: JSON.stringify([
|
|
3387
|
-
{
|
|
3388
|
-
type: "text",
|
|
3389
|
-
text: "Legacy message with no provenance info that still needs full extraction processing.",
|
|
3390
|
-
},
|
|
3391
|
-
]),
|
|
3392
|
-
createdAt: now,
|
|
3393
|
-
// provenanceTrustClass is intentionally omitted (undefined) to test default behavior
|
|
3394
|
-
},
|
|
3395
|
-
DEFAULT_CONFIG.memory,
|
|
3396
|
-
);
|
|
3397
|
-
|
|
3398
|
-
expect(result.indexedSegments).toBeGreaterThan(0);
|
|
3399
|
-
|
|
3400
|
-
// batch_extract job should still be enqueued (debounced) for messages without provenance
|
|
3401
|
-
const extractJobs = db
|
|
3402
|
-
.select()
|
|
3403
|
-
.from(memoryJobs)
|
|
3404
|
-
.where(eq(memoryJobs.type, "batch_extract"))
|
|
3405
|
-
.all()
|
|
3406
|
-
.filter(
|
|
3407
|
-
(j) => JSON.parse(j.payload).conversationId === "conv-legacy-gate",
|
|
3408
|
-
);
|
|
3409
|
-
expect(extractJobs.length).toBe(1);
|
|
3410
|
-
});
|
|
3411
|
-
|
|
3412
|
-
test("unverified_channel messages do not enqueue batch_extract", async () => {
|
|
3413
|
-
const db = getDb();
|
|
3414
|
-
const now = Date.now();
|
|
3415
|
-
db.insert(conversations)
|
|
3416
|
-
.values({
|
|
3417
|
-
id: "conv-unverified-gate",
|
|
3418
|
-
title: null,
|
|
3419
|
-
createdAt: now,
|
|
3420
|
-
updatedAt: now,
|
|
3421
|
-
totalInputTokens: 0,
|
|
3422
|
-
totalOutputTokens: 0,
|
|
3423
|
-
totalEstimatedCost: 0,
|
|
3424
|
-
contextSummary: null,
|
|
3425
|
-
contextCompactedMessageCount: 0,
|
|
3426
|
-
contextCompactedAt: null,
|
|
3427
|
-
})
|
|
3428
|
-
.run();
|
|
3429
|
-
db.insert(messages)
|
|
3430
|
-
.values({
|
|
3431
|
-
id: "msg-unverified-gate",
|
|
3432
|
-
conversationId: "conv-unverified-gate",
|
|
3433
|
-
role: "user",
|
|
3434
|
-
content: JSON.stringify([
|
|
3435
|
-
{
|
|
3436
|
-
type: "text",
|
|
3437
|
-
text: "Unverified channel preference for compact layout with sidebar navigation always visible.",
|
|
3438
|
-
},
|
|
3439
|
-
]),
|
|
3440
|
-
createdAt: now,
|
|
3441
|
-
})
|
|
3442
|
-
.run();
|
|
3443
|
-
|
|
3444
|
-
const result = await indexMessageNow(
|
|
3445
|
-
{
|
|
3446
|
-
messageId: "msg-unverified-gate",
|
|
3447
|
-
conversationId: "conv-unverified-gate",
|
|
3448
|
-
role: "user",
|
|
3449
|
-
content: JSON.stringify([
|
|
3450
|
-
{
|
|
3451
|
-
type: "text",
|
|
3452
|
-
text: "Unverified channel preference for compact layout with sidebar navigation always visible.",
|
|
3453
|
-
},
|
|
3454
|
-
]),
|
|
3455
|
-
createdAt: now,
|
|
3456
|
-
provenanceTrustClass: "unknown",
|
|
3457
|
-
},
|
|
3458
|
-
DEFAULT_CONFIG.memory,
|
|
3459
|
-
);
|
|
3460
|
-
|
|
3461
|
-
expect(result.indexedSegments).toBeGreaterThan(0);
|
|
3462
|
-
|
|
3463
|
-
// No batch_extract jobs should be enqueued for unverified channel
|
|
3464
|
-
const extractJobs = db
|
|
3465
|
-
.select()
|
|
3466
|
-
.from(memoryJobs)
|
|
3467
|
-
.where(eq(memoryJobs.type, "batch_extract"))
|
|
3468
|
-
.all()
|
|
3469
|
-
.filter(
|
|
3470
|
-
(j) => JSON.parse(j.payload).conversationId === "conv-unverified-gate",
|
|
3471
|
-
);
|
|
3472
|
-
expect(extractJobs.length).toBe(0);
|
|
3473
|
-
|
|
3474
|
-
// enqueuedJobs reflects embed jobs only (no extraction for untrusted)
|
|
3475
|
-
expect(result.enqueuedJobs).toBe(result.indexedSegments);
|
|
3476
|
-
});
|
|
3477
|
-
|
|
3478
|
-
test("buildCoreIdentityContext includes identity files when they exist", () => {
|
|
3479
|
-
// Create workspace directory and write prompt files
|
|
3480
|
-
mkdirSync(testWorkspaceDir, { recursive: true });
|
|
3481
|
-
writeFileSync(
|
|
3482
|
-
join(testWorkspaceDir, "SOUL.md"),
|
|
3483
|
-
"You are a helpful assistant named Jarvis.",
|
|
3484
|
-
);
|
|
3485
|
-
writeFileSync(
|
|
3486
|
-
join(testWorkspaceDir, "USER.md"),
|
|
3487
|
-
"The user's name is Aaron Levin.",
|
|
3488
|
-
);
|
|
3489
|
-
|
|
3490
|
-
const context = buildCoreIdentityContext();
|
|
3491
|
-
expect(context).not.toBeNull();
|
|
3492
|
-
expect(context).toContain("helpful assistant named Jarvis");
|
|
3493
|
-
expect(context).toContain("Aaron Levin");
|
|
3494
|
-
});
|
|
3495
|
-
|
|
3496
|
-
test("buildCoreIdentityContext returns null when no prompt files exist", () => {
|
|
3497
|
-
// Remove workspace prompt files to simulate a clean state
|
|
3498
|
-
try {
|
|
3499
|
-
rmSync(join(testWorkspaceDir, "SOUL.md"), { force: true });
|
|
3500
|
-
rmSync(join(testWorkspaceDir, "IDENTITY.md"), { force: true });
|
|
3501
|
-
rmSync(join(testWorkspaceDir, "USER.md"), { force: true });
|
|
3502
|
-
} catch {
|
|
3503
|
-
// files may not exist
|
|
3504
|
-
}
|
|
3505
|
-
|
|
3506
|
-
const context = buildCoreIdentityContext();
|
|
3507
|
-
expect(context).toBeNull();
|
|
3508
|
-
});
|
|
3509
|
-
|
|
3510
|
-
// ── Inline supersession rendering tests ──────────────────────────
|
|
3511
|
-
|
|
3512
|
-
test("buildMemoryInjection renders inline supersedes tag for items with supersession chain", () => {
|
|
3513
|
-
const db = getDb();
|
|
3514
|
-
const now = Date.now();
|
|
3515
|
-
|
|
3516
|
-
// Create the superseded (predecessor) item in the DB
|
|
3517
|
-
db.insert(memoryItems)
|
|
3518
|
-
.values({
|
|
3519
|
-
id: "item-predecessor-render",
|
|
3520
|
-
kind: "preference",
|
|
3521
|
-
subject: "color",
|
|
3522
|
-
statement: "Favorite color is blue",
|
|
3523
|
-
status: "active",
|
|
3524
|
-
confidence: 0.9,
|
|
3525
|
-
importance: 0.7,
|
|
3526
|
-
fingerprint: "fp-pred-render",
|
|
3527
|
-
firstSeenAt: now - 86_400_000,
|
|
3528
|
-
lastSeenAt: now - 86_400_000,
|
|
3529
|
-
accessCount: 1,
|
|
3530
|
-
verificationState: "assistant_inferred",
|
|
3531
|
-
})
|
|
3532
|
-
.run();
|
|
3533
|
-
|
|
3534
|
-
const candidate = {
|
|
3535
|
-
key: "item:item-superseding-render",
|
|
3536
|
-
type: "item" as const,
|
|
3537
|
-
id: "item-superseding-render",
|
|
3538
|
-
source: "semantic" as const,
|
|
3539
|
-
text: "Favorite color is green",
|
|
3540
|
-
kind: "preference",
|
|
3541
|
-
confidence: 0.9,
|
|
3542
|
-
importance: 0.8,
|
|
3543
|
-
createdAt: now,
|
|
3544
|
-
semantic: 0.9,
|
|
3545
|
-
recency: 0.8,
|
|
3546
|
-
finalScore: 0.85,
|
|
3547
|
-
supersedes: "item-predecessor-render",
|
|
3548
|
-
};
|
|
3549
|
-
|
|
3550
|
-
const injection = buildMemoryInjection({
|
|
3551
|
-
candidates: [candidate],
|
|
3552
|
-
totalBudgetTokens: 5000,
|
|
3553
|
-
});
|
|
3554
|
-
|
|
3555
|
-
expect(injection).toContain("<supersedes count=");
|
|
3556
|
-
expect(injection).toContain('count="1"');
|
|
3557
|
-
expect(injection).toContain("Favorite color is blue");
|
|
3558
|
-
expect(injection).toContain("</supersedes>");
|
|
3559
|
-
// The supersedes tag should be inside the item tag
|
|
3560
|
-
expect(injection).toMatch(
|
|
3561
|
-
/<item[^>]*>.*<supersedes.*<\/supersedes><\/item>/,
|
|
3562
|
-
);
|
|
3563
|
-
|
|
3564
|
-
// Clean up
|
|
3565
|
-
db.delete(memoryItems)
|
|
3566
|
-
.where(eq(memoryItems.id, "item-predecessor-render"))
|
|
3567
|
-
.run();
|
|
3568
|
-
});
|
|
3569
|
-
|
|
3570
|
-
test("lookupSupersessionChain counts chain depth correctly", () => {
|
|
3571
|
-
const db = getDb();
|
|
3572
|
-
const now = Date.now();
|
|
3573
|
-
const MS_PER_DAY = 86_400_000;
|
|
3574
|
-
|
|
3575
|
-
// Create a chain of 3 items: grandparent → parent → child
|
|
3576
|
-
db.insert(memoryItems)
|
|
3577
|
-
.values({
|
|
3578
|
-
id: "item-chain-grandparent",
|
|
3579
|
-
kind: "fact",
|
|
3580
|
-
subject: "address",
|
|
3581
|
-
statement: "Lives at 123 Main St",
|
|
3582
|
-
status: "active",
|
|
3583
|
-
confidence: 0.8,
|
|
3584
|
-
importance: 0.6,
|
|
3585
|
-
fingerprint: "fp-chain-gp",
|
|
3586
|
-
firstSeenAt: now - 3 * MS_PER_DAY,
|
|
3587
|
-
lastSeenAt: now - 3 * MS_PER_DAY,
|
|
3588
|
-
accessCount: 1,
|
|
3589
|
-
verificationState: "assistant_inferred",
|
|
3590
|
-
})
|
|
3591
|
-
.run();
|
|
3592
|
-
|
|
3593
|
-
db.insert(memoryItems)
|
|
3594
|
-
.values({
|
|
3595
|
-
id: "item-chain-parent",
|
|
3596
|
-
kind: "fact",
|
|
3597
|
-
subject: "address",
|
|
3598
|
-
statement: "Lives at 456 Oak Ave",
|
|
3599
|
-
status: "active",
|
|
3600
|
-
confidence: 0.8,
|
|
3601
|
-
importance: 0.6,
|
|
3602
|
-
fingerprint: "fp-chain-p",
|
|
3603
|
-
supersedes: "item-chain-grandparent",
|
|
3604
|
-
firstSeenAt: now - 2 * MS_PER_DAY,
|
|
3605
|
-
lastSeenAt: now - 2 * MS_PER_DAY,
|
|
3606
|
-
accessCount: 1,
|
|
3607
|
-
verificationState: "assistant_inferred",
|
|
3608
|
-
})
|
|
3609
|
-
.run();
|
|
3610
|
-
|
|
3611
|
-
db.insert(memoryItems)
|
|
3612
|
-
.values({
|
|
3613
|
-
id: "item-chain-child",
|
|
3614
|
-
kind: "fact",
|
|
3615
|
-
subject: "address",
|
|
3616
|
-
statement: "Lives at 789 Pine Blvd",
|
|
3617
|
-
status: "active",
|
|
3618
|
-
confidence: 0.9,
|
|
3619
|
-
importance: 0.7,
|
|
3620
|
-
fingerprint: "fp-chain-c",
|
|
3621
|
-
supersedes: "item-chain-parent",
|
|
3622
|
-
firstSeenAt: now - 1 * MS_PER_DAY,
|
|
3623
|
-
lastSeenAt: now - 1 * MS_PER_DAY,
|
|
3624
|
-
accessCount: 1,
|
|
3625
|
-
verificationState: "assistant_inferred",
|
|
3626
|
-
})
|
|
3627
|
-
.run();
|
|
3628
|
-
|
|
3629
|
-
// Look up from the child's perspective (supersedes parent)
|
|
3630
|
-
const result = lookupSupersessionChain("item-chain-parent");
|
|
3631
|
-
expect(result).not.toBeNull();
|
|
3632
|
-
expect(result!.previousStatement).toBe("Lives at 456 Oak Ave");
|
|
3633
|
-
expect(result!.previousTimestamp).toBe(now - 2 * MS_PER_DAY);
|
|
3634
|
-
// Chain: parent → grandparent = depth 2
|
|
3635
|
-
expect(result!.chainDepth).toBe(2);
|
|
3636
|
-
|
|
3637
|
-
// Look up direct predecessor (grandparent has no supersedes)
|
|
3638
|
-
const gpResult = lookupSupersessionChain("item-chain-grandparent");
|
|
3639
|
-
expect(gpResult).not.toBeNull();
|
|
3640
|
-
expect(gpResult!.previousStatement).toBe("Lives at 123 Main St");
|
|
3641
|
-
expect(gpResult!.chainDepth).toBe(1);
|
|
3642
|
-
|
|
3643
|
-
// Non-existent ID returns null
|
|
3644
|
-
const nullResult = lookupSupersessionChain("item-nonexistent");
|
|
3645
|
-
expect(nullResult).toBeNull();
|
|
3646
|
-
|
|
3647
|
-
// Clean up
|
|
3648
|
-
db.delete(memoryItems).where(eq(memoryItems.id, "item-chain-child")).run();
|
|
3649
|
-
db.delete(memoryItems).where(eq(memoryItems.id, "item-chain-parent")).run();
|
|
3650
|
-
db.delete(memoryItems)
|
|
3651
|
-
.where(eq(memoryItems.id, "item-chain-grandparent"))
|
|
3652
|
-
.run();
|
|
3653
|
-
});
|
|
3654
|
-
|
|
3655
|
-
test("escapeXmlTags escapes memory_context, recalled, and item delimiter tags", () => {
|
|
3656
|
-
// Verify new tag vocabulary is escaped by the existing generic escaper
|
|
3657
|
-
expect(escapeXmlTags("</memory_context>")).toBe("\uFF1C/memory_context>");
|
|
3658
|
-
expect(escapeXmlTags("</recalled>")).toBe("\uFF1C/recalled>");
|
|
3659
|
-
expect(escapeXmlTags("</item>")).toBe("\uFF1C/item>");
|
|
3660
|
-
expect(escapeXmlTags("</segment>")).toBe("\uFF1C/segment>");
|
|
3661
|
-
expect(escapeXmlTags("</supersedes>")).toBe("\uFF1C/supersedes>");
|
|
3662
|
-
expect(escapeXmlTags("</echoes>")).toBe("\uFF1C/echoes>");
|
|
3663
|
-
|
|
3664
|
-
// Opening tags too
|
|
3665
|
-
expect(escapeXmlTags("<memory_context>")).toBe("\uFF1Cmemory_context>");
|
|
3666
|
-
expect(escapeXmlTags("<recalled>")).toBe("\uFF1Crecalled>");
|
|
3667
|
-
expect(escapeXmlTags("<item>")).toBe("\uFF1Citem>");
|
|
3668
|
-
});
|
|
3669
|
-
|
|
3670
|
-
test("buildMemoryInjection renders items without supersedes normally", () => {
|
|
3671
|
-
const now = Date.now();
|
|
3672
|
-
const candidate = {
|
|
3673
|
-
key: "item:item-no-supersedes",
|
|
3674
|
-
type: "item" as const,
|
|
3675
|
-
id: "item-no-supersedes",
|
|
3676
|
-
source: "semantic" as const,
|
|
3677
|
-
text: "User prefers dark mode",
|
|
3678
|
-
kind: "preference",
|
|
3679
|
-
confidence: 0.9,
|
|
3680
|
-
importance: 0.8,
|
|
3681
|
-
createdAt: now,
|
|
3682
|
-
semantic: 0.9,
|
|
3683
|
-
recency: 0.8,
|
|
3684
|
-
finalScore: 0.85,
|
|
3685
|
-
};
|
|
3686
|
-
|
|
3687
|
-
const injection = buildMemoryInjection({
|
|
3688
|
-
candidates: [candidate],
|
|
3689
|
-
totalBudgetTokens: 5000,
|
|
3690
|
-
});
|
|
3691
|
-
|
|
3692
|
-
expect(injection).toContain("User prefers dark mode");
|
|
3693
|
-
expect(injection).not.toContain("<supersedes");
|
|
3694
|
-
expect(injection).not.toContain("</supersedes>");
|
|
3695
|
-
});
|
|
3696
|
-
});
|