@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
package/src/memory/retriever.ts
DELETED
|
@@ -1,1331 +0,0 @@
|
|
|
1
|
-
import { and, asc, eq, inArray, notInArray, sql } from "drizzle-orm";
|
|
2
|
-
|
|
3
|
-
import type { AssistantConfig } from "../config/types.js";
|
|
4
|
-
import { estimateTextTokens } from "../context/token-estimator.js";
|
|
5
|
-
import type { Message } from "../providers/types.js";
|
|
6
|
-
import { getLogger } from "../util/logger.js";
|
|
7
|
-
import {
|
|
8
|
-
abortableSleep,
|
|
9
|
-
computeRetryDelay,
|
|
10
|
-
isRetryableNetworkError,
|
|
11
|
-
} from "../util/retry.js";
|
|
12
|
-
import { getConversationDirName } from "./conversation-directories.js";
|
|
13
|
-
import { getDb } from "./db.js";
|
|
14
|
-
import {
|
|
15
|
-
embedWithBackend,
|
|
16
|
-
generateSparseEmbedding,
|
|
17
|
-
getMemoryBackendStatus,
|
|
18
|
-
logMemoryEmbeddingWarning,
|
|
19
|
-
} from "./embedding-backend.js";
|
|
20
|
-
import { isQdrantBreakerOpen } from "./qdrant-circuit-breaker.js";
|
|
21
|
-
import { expandQueryWithHyDE } from "./query-expansion.js";
|
|
22
|
-
import {
|
|
23
|
-
conversations,
|
|
24
|
-
memoryItems,
|
|
25
|
-
memoryItemSources,
|
|
26
|
-
messages,
|
|
27
|
-
} from "./schema.js";
|
|
28
|
-
import { buildMemoryInjection } from "./search/formatting.js";
|
|
29
|
-
import { applyMMR } from "./search/mmr.js";
|
|
30
|
-
import { isQdrantConnectionError, semanticSearch } from "./search/semantic.js";
|
|
31
|
-
import { computeStaleness } from "./search/staleness.js";
|
|
32
|
-
import {
|
|
33
|
-
filterByMinScore,
|
|
34
|
-
type TieredCandidate,
|
|
35
|
-
} from "./search/tier-classifier.js";
|
|
36
|
-
import type {
|
|
37
|
-
Candidate,
|
|
38
|
-
DegradationReason,
|
|
39
|
-
DegradationStatus,
|
|
40
|
-
MemoryRecallCandiateDebug,
|
|
41
|
-
MemoryRecallOptions,
|
|
42
|
-
MemoryRecallResult,
|
|
43
|
-
ScopePolicyOverride,
|
|
44
|
-
} from "./search/types.js";
|
|
45
|
-
|
|
46
|
-
// Re-export public types and functions so existing importers continue to work
|
|
47
|
-
export {
|
|
48
|
-
escapeXmlTags,
|
|
49
|
-
formatAbsoluteTime,
|
|
50
|
-
formatRelativeTime,
|
|
51
|
-
lookupSupersessionChain,
|
|
52
|
-
} from "./search/formatting.js";
|
|
53
|
-
export type {
|
|
54
|
-
DegradationReason,
|
|
55
|
-
DegradationStatus,
|
|
56
|
-
MemoryRecallCandiateDebug,
|
|
57
|
-
MemoryRecallResult,
|
|
58
|
-
ScopePolicyOverride,
|
|
59
|
-
} from "./search/types.js";
|
|
60
|
-
|
|
61
|
-
const log = getLogger("memory-retriever");
|
|
62
|
-
|
|
63
|
-
const EMBED_MAX_RETRIES = 3;
|
|
64
|
-
const EMBED_BASE_DELAY_MS = 500;
|
|
65
|
-
|
|
66
|
-
/** MMR diversity penalty applied to near-duplicate items after score filtering.
|
|
67
|
-
* 0 = no penalty, 1 = maximum penalty. */
|
|
68
|
-
const MMR_PENALTY = 0.6;
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Wrap embedWithBackend with retry + exponential backoff for transient failures
|
|
72
|
-
* (network errors, 429s, 5xx). Aborts immediately if the caller's signal fires.
|
|
73
|
-
*/
|
|
74
|
-
export async function embedWithRetry(
|
|
75
|
-
config: AssistantConfig,
|
|
76
|
-
texts: string[],
|
|
77
|
-
opts?: { signal?: AbortSignal },
|
|
78
|
-
): ReturnType<typeof embedWithBackend> {
|
|
79
|
-
let lastError: unknown;
|
|
80
|
-
for (let attempt = 0; attempt <= EMBED_MAX_RETRIES; attempt++) {
|
|
81
|
-
try {
|
|
82
|
-
return await embedWithBackend(config, texts, opts);
|
|
83
|
-
} catch (err) {
|
|
84
|
-
lastError = err;
|
|
85
|
-
if (opts?.signal?.aborted || isAbortError(err)) throw err;
|
|
86
|
-
const isTransient =
|
|
87
|
-
isRetryableNetworkError(err) || isHttpStatusError(err);
|
|
88
|
-
if (!isTransient || attempt === EMBED_MAX_RETRIES) throw err;
|
|
89
|
-
const delay = computeRetryDelay(attempt, EMBED_BASE_DELAY_MS);
|
|
90
|
-
log.warn(
|
|
91
|
-
{ err, attempt: attempt + 1, delayMs: Math.round(delay) },
|
|
92
|
-
"Transient embedding failure, retrying",
|
|
93
|
-
);
|
|
94
|
-
await abortableSleep(delay, opts?.signal);
|
|
95
|
-
if (opts?.signal?.aborted) throw err;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
throw lastError;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Build the list of scope IDs to include in queries.
|
|
103
|
-
* - If a `scopePolicyOverride` is provided, it takes precedence over both
|
|
104
|
-
* `scopeId` and `scopePolicy` — the override's `scopeId` is used as the
|
|
105
|
-
* primary scope and `fallbackToDefault` controls whether 'default' is
|
|
106
|
-
* included.
|
|
107
|
-
* - If no scopeId is provided, returns undefined (no filtering).
|
|
108
|
-
* - If scopePolicy is 'allow_global_fallback', includes both the
|
|
109
|
-
* requested scope and the 'default' scope.
|
|
110
|
-
* - If scopePolicy is 'strict', only includes the requested scope.
|
|
111
|
-
*/
|
|
112
|
-
function buildScopeFilter(
|
|
113
|
-
scopeId: string | undefined,
|
|
114
|
-
scopePolicy: string,
|
|
115
|
-
scopePolicyOverride?: ScopePolicyOverride,
|
|
116
|
-
): string[] | undefined {
|
|
117
|
-
// Per-call override takes precedence over global config
|
|
118
|
-
if (scopePolicyOverride) {
|
|
119
|
-
const primary = scopePolicyOverride.scopeId;
|
|
120
|
-
if (scopePolicyOverride.fallbackToDefault && primary !== "default") {
|
|
121
|
-
return [primary, "default"];
|
|
122
|
-
}
|
|
123
|
-
return [primary];
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (!scopeId) return undefined;
|
|
127
|
-
if (scopePolicy === "allow_global_fallback") {
|
|
128
|
-
return scopeId === "default" ? ["default"] : [scopeId, "default"];
|
|
129
|
-
}
|
|
130
|
-
return [scopeId];
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Build a structured degradation status describing which retrieval
|
|
135
|
-
* capabilities are unavailable and what fallback sources remain.
|
|
136
|
-
*/
|
|
137
|
-
function buildDegradationStatus(
|
|
138
|
-
reason: DegradationReason,
|
|
139
|
-
_config: AssistantConfig,
|
|
140
|
-
): DegradationStatus {
|
|
141
|
-
return {
|
|
142
|
-
semanticUnavailable: true,
|
|
143
|
-
reason,
|
|
144
|
-
fallbackSources: [],
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Result of the embedding generation stage. */
|
|
149
|
-
interface EmbeddingResult {
|
|
150
|
-
queryVector: number[] | null;
|
|
151
|
-
provider: string | undefined;
|
|
152
|
-
model: string | undefined;
|
|
153
|
-
degraded: boolean;
|
|
154
|
-
degradation: DegradationStatus | undefined;
|
|
155
|
-
reason: string | undefined;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Generate an embedding vector for the query. Handles backend availability
|
|
160
|
-
* checks, retry with backoff, and graceful degradation when embeddings are
|
|
161
|
-
* optional.
|
|
162
|
-
*
|
|
163
|
-
* Returns `null` when the caller should return an early-exit `emptyResult`
|
|
164
|
-
* (the empty result is included). Otherwise returns the embedding state.
|
|
165
|
-
*/
|
|
166
|
-
async function generateQueryEmbedding(
|
|
167
|
-
query: string,
|
|
168
|
-
config: AssistantConfig,
|
|
169
|
-
signal: AbortSignal | undefined,
|
|
170
|
-
start: number,
|
|
171
|
-
): Promise<EmbeddingResult | { earlyExit: MemoryRecallResult }> {
|
|
172
|
-
const backendStatus = await getMemoryBackendStatus(config);
|
|
173
|
-
let queryVector: number[] | null = null;
|
|
174
|
-
let provider: string | undefined;
|
|
175
|
-
let model: string | undefined;
|
|
176
|
-
let degraded = backendStatus.degraded;
|
|
177
|
-
let degradation: DegradationStatus | undefined;
|
|
178
|
-
let reason = backendStatus.reason ?? undefined;
|
|
179
|
-
|
|
180
|
-
if (backendStatus.provider) {
|
|
181
|
-
try {
|
|
182
|
-
const embedded = await embedWithRetry(config, [query], { signal });
|
|
183
|
-
queryVector = embedded.vectors[0] ?? null;
|
|
184
|
-
provider = embedded.provider;
|
|
185
|
-
model = embedded.model;
|
|
186
|
-
degraded = false;
|
|
187
|
-
reason = undefined;
|
|
188
|
-
} catch (err) {
|
|
189
|
-
if (signal?.aborted || isAbortError(err)) {
|
|
190
|
-
return {
|
|
191
|
-
earlyExit: emptyResult({
|
|
192
|
-
enabled: true,
|
|
193
|
-
degraded: false,
|
|
194
|
-
reason: "memory.aborted",
|
|
195
|
-
provider: backendStatus.provider,
|
|
196
|
-
model: backendStatus.model ?? undefined,
|
|
197
|
-
latencyMs: Date.now() - start,
|
|
198
|
-
}),
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
logMemoryEmbeddingWarning(err, "query");
|
|
202
|
-
degraded = true;
|
|
203
|
-
reason = `memory.embedding_failure: ${
|
|
204
|
-
err instanceof Error ? err.message : String(err)
|
|
205
|
-
}`;
|
|
206
|
-
degradation = buildDegradationStatus(
|
|
207
|
-
"embedding_generation_failed",
|
|
208
|
-
config,
|
|
209
|
-
);
|
|
210
|
-
if (config.memory.embeddings.required) {
|
|
211
|
-
return {
|
|
212
|
-
earlyExit: emptyResult({
|
|
213
|
-
enabled: true,
|
|
214
|
-
degraded,
|
|
215
|
-
degradation,
|
|
216
|
-
reason,
|
|
217
|
-
provider: backendStatus.provider,
|
|
218
|
-
model: backendStatus.model ?? undefined,
|
|
219
|
-
latencyMs: Date.now() - start,
|
|
220
|
-
}),
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
} else if (config.memory.embeddings.required) {
|
|
225
|
-
degradation = buildDegradationStatus("embedding_provider_down", config);
|
|
226
|
-
return {
|
|
227
|
-
earlyExit: emptyResult({
|
|
228
|
-
enabled: true,
|
|
229
|
-
degraded: true,
|
|
230
|
-
degradation,
|
|
231
|
-
reason: reason ?? "memory.embedding_backend_missing",
|
|
232
|
-
latencyMs: Date.now() - start,
|
|
233
|
-
}),
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return { queryVector, provider, model, degraded, degradation, reason };
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/** Result from HyDE-expanded search. */
|
|
241
|
-
interface HyDESearchResult {
|
|
242
|
-
candidates: Candidate[];
|
|
243
|
-
hydeExpanded: boolean;
|
|
244
|
-
hydeDocCount: number;
|
|
245
|
-
/** Whether any HyDE doc produced a sparse vector with non-empty indices */
|
|
246
|
-
hydeSparseUsed: boolean;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Run HyDE-expanded search: generate hypothetical documents, embed them
|
|
251
|
-
* alongside the raw query in parallel, run parallel semantic searches,
|
|
252
|
-
* and merge all candidate arrays.
|
|
253
|
-
*
|
|
254
|
-
* Falls back to raw-query-only search on any HyDE failure (expansion
|
|
255
|
-
* error, embedding error for hypothetical docs). The raw query search
|
|
256
|
-
* always runs regardless of HyDE success.
|
|
257
|
-
*/
|
|
258
|
-
async function runHyDESearch(
|
|
259
|
-
query: string,
|
|
260
|
-
rawQueryVector: number[],
|
|
261
|
-
config: AssistantConfig,
|
|
262
|
-
signal: AbortSignal | undefined,
|
|
263
|
-
provider: string,
|
|
264
|
-
model: string,
|
|
265
|
-
limit: number,
|
|
266
|
-
excludeMessageIds: string[],
|
|
267
|
-
scopeIds: string[] | undefined,
|
|
268
|
-
sparseVector: { indices: number[]; values: number[] } | undefined,
|
|
269
|
-
): Promise<HyDESearchResult> {
|
|
270
|
-
// Always search with the raw query — this is our baseline
|
|
271
|
-
const rawSearchPromise = semanticSearch(
|
|
272
|
-
rawQueryVector,
|
|
273
|
-
provider,
|
|
274
|
-
model,
|
|
275
|
-
limit,
|
|
276
|
-
excludeMessageIds,
|
|
277
|
-
scopeIds,
|
|
278
|
-
sparseVector,
|
|
279
|
-
);
|
|
280
|
-
// Suppress unhandled rejection if Qdrant rejects before we await
|
|
281
|
-
rawSearchPromise.catch(() => {});
|
|
282
|
-
|
|
283
|
-
// Attempt HyDE expansion — returns [] on any failure
|
|
284
|
-
let hypotheticalDocs: string[];
|
|
285
|
-
try {
|
|
286
|
-
hypotheticalDocs = await expandQueryWithHyDE(query, config, signal);
|
|
287
|
-
} catch (err) {
|
|
288
|
-
if (isAbortError(err)) throw err;
|
|
289
|
-
// expandQueryWithHyDE already catches internally, but be defensive
|
|
290
|
-
hypotheticalDocs = [];
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
if (hypotheticalDocs.length === 0) {
|
|
294
|
-
// No hypothetical docs — fall back to raw query only
|
|
295
|
-
const rawResults = await rawSearchPromise;
|
|
296
|
-
return {
|
|
297
|
-
candidates: rawResults,
|
|
298
|
-
hydeExpanded: false,
|
|
299
|
-
hydeDocCount: 0,
|
|
300
|
-
hydeSparseUsed: false,
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
log.debug(
|
|
305
|
-
{ hydeDocCount: hypotheticalDocs.length },
|
|
306
|
-
"HyDE expansion produced hypothetical documents",
|
|
307
|
-
);
|
|
308
|
-
|
|
309
|
-
// Embed all hypothetical docs in parallel with the raw search
|
|
310
|
-
let hydeVectors: number[][] = [];
|
|
311
|
-
try {
|
|
312
|
-
const hydeEmbedResult = await embedWithRetry(config, hypotheticalDocs, {
|
|
313
|
-
signal,
|
|
314
|
-
});
|
|
315
|
-
hydeVectors = hydeEmbedResult.vectors;
|
|
316
|
-
} catch (err) {
|
|
317
|
-
log.warn(
|
|
318
|
-
{ err: err instanceof Error ? err.message : String(err) },
|
|
319
|
-
"Failed to embed HyDE hypothetical docs; falling back to raw query",
|
|
320
|
-
);
|
|
321
|
-
const rawResults = await rawSearchPromise;
|
|
322
|
-
return {
|
|
323
|
-
candidates: rawResults,
|
|
324
|
-
hydeExpanded: false,
|
|
325
|
-
hydeDocCount: 0,
|
|
326
|
-
hydeSparseUsed: false,
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Run parallel semantic searches for each hypothetical doc embedding,
|
|
331
|
-
// generating per-doc sparse embeddings so sparse and dense components match.
|
|
332
|
-
let hydeSparseUsed = false;
|
|
333
|
-
const hydeSearchPromises = hydeVectors.map((vector, i) => {
|
|
334
|
-
const docSparseVector = generateSparseEmbedding(hypotheticalDocs[i]!);
|
|
335
|
-
if (docSparseVector.indices.length > 0) hydeSparseUsed = true;
|
|
336
|
-
return semanticSearch(
|
|
337
|
-
vector,
|
|
338
|
-
provider,
|
|
339
|
-
model,
|
|
340
|
-
limit,
|
|
341
|
-
excludeMessageIds,
|
|
342
|
-
scopeIds,
|
|
343
|
-
docSparseVector,
|
|
344
|
-
).catch((err) => {
|
|
345
|
-
log.warn(
|
|
346
|
-
{ err: err instanceof Error ? err.message : String(err) },
|
|
347
|
-
"HyDE hypothetical doc search failed; skipping",
|
|
348
|
-
);
|
|
349
|
-
return [] as Candidate[];
|
|
350
|
-
});
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
// Await all searches in parallel (raw + hypothetical)
|
|
354
|
-
const [rawResults, ...hydeResults] = await Promise.all([
|
|
355
|
-
rawSearchPromise,
|
|
356
|
-
...hydeSearchPromises,
|
|
357
|
-
]);
|
|
358
|
-
|
|
359
|
-
// Merge all candidate arrays into a single flat array
|
|
360
|
-
const allCandidates = [rawResults, ...hydeResults].flat();
|
|
361
|
-
|
|
362
|
-
return {
|
|
363
|
-
candidates: allCandidates,
|
|
364
|
-
hydeExpanded: true,
|
|
365
|
-
hydeDocCount: hypotheticalDocs.length,
|
|
366
|
-
hydeSparseUsed,
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Memory recall pipeline: hybrid search → score filtering →
|
|
372
|
-
* staleness annotation → unified XML injection.
|
|
373
|
-
*
|
|
374
|
-
* Pipeline steps:
|
|
375
|
-
* 1. Build query text (caller provides via buildMemoryQuery)
|
|
376
|
-
* 2. Generate dense + sparse embeddings
|
|
377
|
-
* 3. Hybrid search on Qdrant (dense + sparse RRF fusion)
|
|
378
|
-
* 4. Deduplicate results
|
|
379
|
-
* 5. Filter by minimum score threshold
|
|
380
|
-
* 6. Enrich candidates with source labels and item metadata
|
|
381
|
-
* 7. Compute staleness per item (for debugging/logging)
|
|
382
|
-
* 8. Build unified XML injection with budget allocation
|
|
383
|
-
*/
|
|
384
|
-
export async function buildMemoryRecall(
|
|
385
|
-
query: string,
|
|
386
|
-
conversationId: string,
|
|
387
|
-
config: AssistantConfig,
|
|
388
|
-
options?: MemoryRecallOptions,
|
|
389
|
-
): Promise<MemoryRecallResult> {
|
|
390
|
-
const start = Date.now();
|
|
391
|
-
const excludeMessageIds =
|
|
392
|
-
options?.excludeMessageIds?.filter((id) => id.length > 0) ?? [];
|
|
393
|
-
const signal = options?.signal;
|
|
394
|
-
|
|
395
|
-
if (!config.memory.enabled) {
|
|
396
|
-
return emptyResult({
|
|
397
|
-
enabled: false,
|
|
398
|
-
degraded: false,
|
|
399
|
-
reason: "memory.disabled",
|
|
400
|
-
latencyMs: Date.now() - start,
|
|
401
|
-
});
|
|
402
|
-
}
|
|
403
|
-
if (signal?.aborted) {
|
|
404
|
-
return emptyResult({
|
|
405
|
-
enabled: true,
|
|
406
|
-
degraded: false,
|
|
407
|
-
reason: "memory.aborted",
|
|
408
|
-
latencyMs: Date.now() - start,
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// ── Step 1+2: Generate dense and sparse embeddings ──────────────
|
|
413
|
-
const embeddingResult = await generateQueryEmbedding(
|
|
414
|
-
query,
|
|
415
|
-
config,
|
|
416
|
-
signal,
|
|
417
|
-
start,
|
|
418
|
-
);
|
|
419
|
-
if ("earlyExit" in embeddingResult) return embeddingResult.earlyExit;
|
|
420
|
-
|
|
421
|
-
const { queryVector, provider, model } = embeddingResult;
|
|
422
|
-
|
|
423
|
-
// Generate sparse embedding for the query text (TF-IDF based)
|
|
424
|
-
const sparseVector = generateSparseEmbedding(query);
|
|
425
|
-
const sparseVectorAvailable = sparseVector.indices.length > 0;
|
|
426
|
-
|
|
427
|
-
// ── Step 3: Hybrid search on Qdrant ─────────────────────────────
|
|
428
|
-
const scopePolicy = config.memory.retrieval.scopePolicy;
|
|
429
|
-
const scopeIds = buildScopeFilter(
|
|
430
|
-
options?.scopeId,
|
|
431
|
-
scopePolicy,
|
|
432
|
-
options?.scopePolicyOverride,
|
|
433
|
-
);
|
|
434
|
-
|
|
435
|
-
const HYBRID_LIMIT = 40;
|
|
436
|
-
|
|
437
|
-
let hybridCandidates: Candidate[] = [];
|
|
438
|
-
let semanticSearchFailed = false;
|
|
439
|
-
let sparseVectorUsed = false;
|
|
440
|
-
let hydeExpanded = false;
|
|
441
|
-
let hydeDocCount = 0;
|
|
442
|
-
const hybridSearchStart = Date.now();
|
|
443
|
-
|
|
444
|
-
const qdrantBreakerOpen = isQdrantBreakerOpen();
|
|
445
|
-
if (queryVector && !qdrantBreakerOpen) {
|
|
446
|
-
try {
|
|
447
|
-
if (options?.hydeEnabled) {
|
|
448
|
-
// ── HyDE path: expand query into hypothetical docs and search in parallel ──
|
|
449
|
-
const hydeCandidates = await runHyDESearch(
|
|
450
|
-
query,
|
|
451
|
-
queryVector,
|
|
452
|
-
config,
|
|
453
|
-
signal,
|
|
454
|
-
provider ?? "unknown",
|
|
455
|
-
model ?? "unknown",
|
|
456
|
-
HYBRID_LIMIT,
|
|
457
|
-
excludeMessageIds,
|
|
458
|
-
scopeIds,
|
|
459
|
-
sparseVectorAvailable ? sparseVector : undefined,
|
|
460
|
-
);
|
|
461
|
-
hybridCandidates = hydeCandidates.candidates;
|
|
462
|
-
hydeExpanded = hydeCandidates.hydeExpanded;
|
|
463
|
-
hydeDocCount = hydeCandidates.hydeDocCount;
|
|
464
|
-
sparseVectorUsed = sparseVectorAvailable || hydeCandidates.hydeSparseUsed;
|
|
465
|
-
} else {
|
|
466
|
-
// ── Standard path: single raw query search ──
|
|
467
|
-
hybridCandidates = await semanticSearch(
|
|
468
|
-
queryVector,
|
|
469
|
-
provider ?? "unknown",
|
|
470
|
-
model ?? "unknown",
|
|
471
|
-
HYBRID_LIMIT,
|
|
472
|
-
excludeMessageIds,
|
|
473
|
-
scopeIds,
|
|
474
|
-
sparseVectorAvailable ? sparseVector : undefined,
|
|
475
|
-
);
|
|
476
|
-
sparseVectorUsed = sparseVectorAvailable;
|
|
477
|
-
}
|
|
478
|
-
} catch (err) {
|
|
479
|
-
semanticSearchFailed = true;
|
|
480
|
-
if (isQdrantConnectionError(err)) {
|
|
481
|
-
log.warn({ err }, "Qdrant unavailable — hybrid search disabled");
|
|
482
|
-
} else {
|
|
483
|
-
log.warn({ err }, "Hybrid search failed");
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
const hybridSearchMs = Date.now() - hybridSearchStart;
|
|
488
|
-
|
|
489
|
-
// ── Step 4: Deduplicate ────────────────────────────────────────
|
|
490
|
-
const candidateMap = new Map<string, Candidate>();
|
|
491
|
-
for (const c of [...hybridCandidates]) {
|
|
492
|
-
const existing = candidateMap.get(c.key);
|
|
493
|
-
if (!existing) {
|
|
494
|
-
candidateMap.set(c.key, { ...c });
|
|
495
|
-
continue;
|
|
496
|
-
}
|
|
497
|
-
// Keep highest scores from each source
|
|
498
|
-
existing.semantic = Math.max(existing.semantic, c.semantic);
|
|
499
|
-
existing.recency = Math.max(existing.recency, c.recency);
|
|
500
|
-
existing.confidence = Math.max(existing.confidence, c.confidence);
|
|
501
|
-
existing.importance = Math.max(existing.importance, c.importance);
|
|
502
|
-
if (c.text.length > existing.text.length) {
|
|
503
|
-
existing.text = c.text;
|
|
504
|
-
}
|
|
505
|
-
// Propagate metadata that the first source may lack (e.g. legacy
|
|
506
|
-
// Qdrant points missing conversation_id / message_id).
|
|
507
|
-
if (c.conversationId && !existing.conversationId) {
|
|
508
|
-
existing.conversationId = c.conversationId;
|
|
509
|
-
}
|
|
510
|
-
if (c.messageId && !existing.messageId) {
|
|
511
|
-
existing.messageId = c.messageId;
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// ── Step 4b: Filter out current-conversation segments still in context ──
|
|
516
|
-
// Segments whose source message is still in the conversation's context
|
|
517
|
-
// window are redundant (already visible to the model). However, segments
|
|
518
|
-
// from messages that were removed by context compaction should be kept —
|
|
519
|
-
// those messages are no longer in the conversation history and memory is
|
|
520
|
-
// the only way they can influence the response.
|
|
521
|
-
let inContextMessageIds: Set<string> | null = null;
|
|
522
|
-
if (conversationId) {
|
|
523
|
-
inContextMessageIds = getEffectiveInContextMessageIds(conversationId);
|
|
524
|
-
if (inContextMessageIds) {
|
|
525
|
-
for (const [key, c] of candidateMap) {
|
|
526
|
-
if (c.type === "segment") {
|
|
527
|
-
if (c.messageId) {
|
|
528
|
-
// Segment has a known source message — filter only if that
|
|
529
|
-
// message is still in the context window.
|
|
530
|
-
if (inContextMessageIds.has(c.messageId)) {
|
|
531
|
-
candidateMap.delete(key);
|
|
532
|
-
}
|
|
533
|
-
} else if (c.conversationId === conversationId) {
|
|
534
|
-
// Segment from the current conversation but missing messageId
|
|
535
|
-
// (e.g. legacy Qdrant points without message_id payload).
|
|
536
|
-
// We can't determine whether it's compacted, so err on the
|
|
537
|
-
// side of filtering to avoid token bloat from redundant segments.
|
|
538
|
-
candidateMap.delete(key);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// ── Item filtering: exclude items whose ALL sources are in-context ──
|
|
544
|
-
// Items distilled from messages the model can already see are redundant.
|
|
545
|
-
// However, items with ANY source outside the in-context set carry
|
|
546
|
-
// cross-conversation information and must be preserved.
|
|
547
|
-
const itemCandidateIds = [...candidateMap.values()]
|
|
548
|
-
.filter((c) => c.type === "item")
|
|
549
|
-
.map((c) => c.id);
|
|
550
|
-
|
|
551
|
-
if (itemCandidateIds.length > 0) {
|
|
552
|
-
try {
|
|
553
|
-
const db = getDb();
|
|
554
|
-
const allSources = db
|
|
555
|
-
.select({
|
|
556
|
-
memoryItemId: memoryItemSources.memoryItemId,
|
|
557
|
-
messageId: memoryItemSources.messageId,
|
|
558
|
-
})
|
|
559
|
-
.from(memoryItemSources)
|
|
560
|
-
.where(inArray(memoryItemSources.memoryItemId, itemCandidateIds))
|
|
561
|
-
.all();
|
|
562
|
-
|
|
563
|
-
// Build item ID → source message IDs map
|
|
564
|
-
const itemSourceMap = new Map<string, string[]>();
|
|
565
|
-
for (const s of allSources) {
|
|
566
|
-
const existing = itemSourceMap.get(s.memoryItemId);
|
|
567
|
-
if (existing) existing.push(s.messageId);
|
|
568
|
-
else itemSourceMap.set(s.memoryItemId, [s.messageId]);
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
// Filter items whose ALL sources are in-context
|
|
572
|
-
const contextIds = inContextMessageIds;
|
|
573
|
-
for (const [key, c] of candidateMap) {
|
|
574
|
-
if (c.type !== "item") continue;
|
|
575
|
-
const sourceMessageIds = itemSourceMap.get(c.id);
|
|
576
|
-
if (!sourceMessageIds || sourceMessageIds.length === 0) continue;
|
|
577
|
-
if (sourceMessageIds.every((mid) => contextIds.has(mid))) {
|
|
578
|
-
candidateMap.delete(key);
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
} catch (err) {
|
|
582
|
-
log.warn(
|
|
583
|
-
{ err },
|
|
584
|
-
"Failed to fetch item sources for in-context filtering; skipping",
|
|
585
|
-
);
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// Compute RRF-style final scores for the merged candidates
|
|
592
|
-
const allCandidates = [...candidateMap.values()];
|
|
593
|
-
for (const c of allCandidates) {
|
|
594
|
-
// Multiplicative scoring: importance, confidence, and recency amplify semantic
|
|
595
|
-
// relevance but can't substitute for it. An irrelevant item (semantic ≈ 0)
|
|
596
|
-
// stays low regardless of metadata. Multiplier range: 0.35 (all zero) to 1.0.
|
|
597
|
-
const metadataMultiplier =
|
|
598
|
-
0.35 + c.importance * 0.3 + c.confidence * 0.1 + c.recency * 0.25;
|
|
599
|
-
c.finalScore = c.semantic * metadataMultiplier;
|
|
600
|
-
}
|
|
601
|
-
allCandidates.sort((a, b) => b.finalScore - a.finalScore);
|
|
602
|
-
|
|
603
|
-
// ── Step 5: Filter by minimum score threshold ───────────────────
|
|
604
|
-
const filtered = filterByMinScore(allCandidates);
|
|
605
|
-
|
|
606
|
-
// ── Step 5b: MMR diversity ranking ─────────────────────────────
|
|
607
|
-
const mmrRanked = applyMMR(filtered, MMR_PENALTY);
|
|
608
|
-
|
|
609
|
-
// MMR rewrites finalScore, so re-enforce the min-score threshold to
|
|
610
|
-
// drop candidates whose adjusted score fell below the cutoff.
|
|
611
|
-
const diversified = filterByMinScore(mmrRanked);
|
|
612
|
-
|
|
613
|
-
// ── Step 5c: Enrich candidates with source labels ──────────────
|
|
614
|
-
enrichSourceLabels(diversified);
|
|
615
|
-
|
|
616
|
-
// ── Serendipity: sample random memories for unexpected connections ──
|
|
617
|
-
const SERENDIPITY_COUNT = 3;
|
|
618
|
-
const serendipityCandidates = sampleSerendipityItems(
|
|
619
|
-
diversified,
|
|
620
|
-
SERENDIPITY_COUNT,
|
|
621
|
-
scopeIds,
|
|
622
|
-
);
|
|
623
|
-
|
|
624
|
-
// Filter serendipity items whose ALL sources are in-context (same logic
|
|
625
|
-
// as Step 4b) to prevent current-turn content leaking via random sampling.
|
|
626
|
-
if (inContextMessageIds && serendipityCandidates.length > 0) {
|
|
627
|
-
filterInContextItems(serendipityCandidates, inContextMessageIds);
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
enrichSourceLabels(serendipityCandidates);
|
|
631
|
-
|
|
632
|
-
// ── Step 6: Enrich with item metadata for staleness ─────────────
|
|
633
|
-
const itemIds = diversified.filter((c) => c.type === "item").map((c) => c.id);
|
|
634
|
-
const itemMetadataMap = enrichItemMetadata(itemIds);
|
|
635
|
-
|
|
636
|
-
// ── Step 6b: Enrich item candidates with supersedes data ────────
|
|
637
|
-
const itemCandidatesForSupersedes = diversified.filter(
|
|
638
|
-
(c) => c.type === "item",
|
|
639
|
-
);
|
|
640
|
-
if (itemCandidatesForSupersedes.length > 0) {
|
|
641
|
-
try {
|
|
642
|
-
const db = getDb();
|
|
643
|
-
const supersedesRows = db
|
|
644
|
-
.select({ id: memoryItems.id, supersedes: memoryItems.supersedes })
|
|
645
|
-
.from(memoryItems)
|
|
646
|
-
.where(
|
|
647
|
-
inArray(
|
|
648
|
-
memoryItems.id,
|
|
649
|
-
itemCandidatesForSupersedes.map((c) => c.id),
|
|
650
|
-
),
|
|
651
|
-
)
|
|
652
|
-
.all();
|
|
653
|
-
const supersedesMap = new Map(
|
|
654
|
-
supersedesRows.map((r) => [r.id, r.supersedes]),
|
|
655
|
-
);
|
|
656
|
-
for (const c of itemCandidatesForSupersedes) {
|
|
657
|
-
const sup = supersedesMap.get(c.id);
|
|
658
|
-
if (sup) c.supersedes = sup;
|
|
659
|
-
}
|
|
660
|
-
} catch (err) {
|
|
661
|
-
log.warn({ err }, "Failed to enrich candidates with supersedes data");
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
// ── Step 7: Compute staleness per item (for debugging/logging) ─
|
|
666
|
-
const now = Date.now();
|
|
667
|
-
for (const c of diversified) {
|
|
668
|
-
if (c.type !== "item") continue;
|
|
669
|
-
const meta = itemMetadataMap.get(c.id);
|
|
670
|
-
if (!meta) continue;
|
|
671
|
-
const { level } = computeStaleness(
|
|
672
|
-
{
|
|
673
|
-
kind: c.kind,
|
|
674
|
-
firstSeenAt: meta.firstSeenAt,
|
|
675
|
-
sourceConversationCount: meta.sourceConversationCount,
|
|
676
|
-
},
|
|
677
|
-
now,
|
|
678
|
-
);
|
|
679
|
-
c.staleness = level;
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
// ── Step 8: Budget allocation and unified injection ────────────
|
|
683
|
-
const maxInjectTokens = Math.max(
|
|
684
|
-
1,
|
|
685
|
-
Math.floor(
|
|
686
|
-
options?.maxInjectTokensOverride ??
|
|
687
|
-
config.memory.retrieval.maxInjectTokens,
|
|
688
|
-
),
|
|
689
|
-
);
|
|
690
|
-
|
|
691
|
-
const injectedText = buildMemoryInjection({
|
|
692
|
-
candidates: diversified,
|
|
693
|
-
serendipityItems: serendipityCandidates,
|
|
694
|
-
totalBudgetTokens: maxInjectTokens,
|
|
695
|
-
});
|
|
696
|
-
|
|
697
|
-
// ── Assemble result ─────────────────────────────────────────────
|
|
698
|
-
const selectedCount = diversified.length + serendipityCandidates.length;
|
|
699
|
-
|
|
700
|
-
const stalenessStats = {
|
|
701
|
-
fresh: diversified.filter((c) => c.staleness === "fresh").length,
|
|
702
|
-
aging: diversified.filter((c) => c.staleness === "aging").length,
|
|
703
|
-
stale: diversified.filter((c) => c.staleness === "stale").length,
|
|
704
|
-
very_stale: diversified.filter((c) => c.staleness === "very_stale").length,
|
|
705
|
-
};
|
|
706
|
-
|
|
707
|
-
const topCandidates: MemoryRecallCandiateDebug[] = [...diversified]
|
|
708
|
-
.sort((a, b) => b.finalScore - a.finalScore)
|
|
709
|
-
.slice(0, 10)
|
|
710
|
-
.map((c) => ({
|
|
711
|
-
key: c.key,
|
|
712
|
-
type: c.type,
|
|
713
|
-
kind: c.kind,
|
|
714
|
-
finalScore: c.finalScore,
|
|
715
|
-
semantic: c.semantic,
|
|
716
|
-
recency: c.recency,
|
|
717
|
-
...(c.sourceLabel ? { sourceLabel: c.sourceLabel } : {}),
|
|
718
|
-
}));
|
|
719
|
-
|
|
720
|
-
const latencyMs = Date.now() - start;
|
|
721
|
-
|
|
722
|
-
// Propagate degradation from semantic search failure or breaker-open skip
|
|
723
|
-
if (
|
|
724
|
-
semanticSearchFailed ||
|
|
725
|
-
qdrantBreakerOpen ||
|
|
726
|
-
(!queryVector && config.memory.embeddings.required)
|
|
727
|
-
) {
|
|
728
|
-
embeddingResult.degraded = true;
|
|
729
|
-
embeddingResult.reason =
|
|
730
|
-
embeddingResult.reason ??
|
|
731
|
-
(qdrantBreakerOpen
|
|
732
|
-
? "memory.qdrant_breaker_open"
|
|
733
|
-
: "memory.hybrid_search_failure");
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
log.debug(
|
|
737
|
-
{
|
|
738
|
-
query: truncate(query, 120),
|
|
739
|
-
hybridHits: hybridCandidates.length,
|
|
740
|
-
mergedCount: allCandidates.length,
|
|
741
|
-
stalenessStats,
|
|
742
|
-
selectedCount,
|
|
743
|
-
maxInjectTokens,
|
|
744
|
-
injectedTokens: estimateTextTokens(injectedText),
|
|
745
|
-
latencyMs,
|
|
746
|
-
...(hydeExpanded ? { hydeExpanded, hydeDocCount } : {}),
|
|
747
|
-
},
|
|
748
|
-
"Memory recall completed",
|
|
749
|
-
);
|
|
750
|
-
|
|
751
|
-
const result: MemoryRecallResult = {
|
|
752
|
-
enabled: true,
|
|
753
|
-
degraded: embeddingResult.degraded,
|
|
754
|
-
degradation: embeddingResult.degradation,
|
|
755
|
-
reason: embeddingResult.reason,
|
|
756
|
-
provider: embeddingResult.provider,
|
|
757
|
-
model: embeddingResult.model,
|
|
758
|
-
semanticHits: hybridCandidates.length,
|
|
759
|
-
mergedCount: allCandidates.length,
|
|
760
|
-
selectedCount,
|
|
761
|
-
injectedTokens: estimateTextTokens(injectedText),
|
|
762
|
-
injectedText,
|
|
763
|
-
latencyMs,
|
|
764
|
-
topCandidates,
|
|
765
|
-
tier1Count: 0,
|
|
766
|
-
tier2Count: 0,
|
|
767
|
-
hybridSearchMs,
|
|
768
|
-
sparseVectorUsed,
|
|
769
|
-
hydeExpanded,
|
|
770
|
-
hydeDocCount,
|
|
771
|
-
mmrApplied: true,
|
|
772
|
-
};
|
|
773
|
-
|
|
774
|
-
return result;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
/**
|
|
778
|
-
* Get the set of message IDs that are effectively in the conversation's
|
|
779
|
-
* context window. This includes:
|
|
780
|
-
* 1. Messages still visible (not compacted) in the conversation history.
|
|
781
|
-
* 2. Fork-source message IDs — when a conversation is forked, messages are
|
|
782
|
-
* copied with new IDs but their metadata stores the original parent
|
|
783
|
-
* message ID as `forkSourceMessageId`. Segments sourced from those parent
|
|
784
|
-
* messages are redundant because the fork already contains their content.
|
|
785
|
-
*
|
|
786
|
-
* Uses `contextCompactedMessageCount` to determine the compaction offset:
|
|
787
|
-
* messages ordered by createdAt after that count are still visible to the model.
|
|
788
|
-
*
|
|
789
|
-
* Returns `null` if the conversation is not found (deleted, or no DB row).
|
|
790
|
-
*/
|
|
791
|
-
function getEffectiveInContextMessageIds(
|
|
792
|
-
conversationId: string,
|
|
793
|
-
): Set<string> | null {
|
|
794
|
-
try {
|
|
795
|
-
const db = getDb();
|
|
796
|
-
|
|
797
|
-
// Look up the conversation's compacted message count
|
|
798
|
-
const conv = db
|
|
799
|
-
.select({
|
|
800
|
-
contextCompactedMessageCount:
|
|
801
|
-
conversations.contextCompactedMessageCount,
|
|
802
|
-
})
|
|
803
|
-
.from(conversations)
|
|
804
|
-
.where(eq(conversations.id, conversationId))
|
|
805
|
-
.get();
|
|
806
|
-
|
|
807
|
-
if (!conv) return null;
|
|
808
|
-
|
|
809
|
-
const offset = conv.contextCompactedMessageCount;
|
|
810
|
-
|
|
811
|
-
// Fetch message IDs and metadata ordered by creation time
|
|
812
|
-
const rows = db
|
|
813
|
-
.select({ id: messages.id, metadata: messages.metadata })
|
|
814
|
-
.from(messages)
|
|
815
|
-
.where(eq(messages.conversationId, conversationId))
|
|
816
|
-
.orderBy(asc(messages.createdAt))
|
|
817
|
-
.all();
|
|
818
|
-
|
|
819
|
-
// Messages up to `offset` have been compacted out of context
|
|
820
|
-
const inContextRows = rows.slice(offset);
|
|
821
|
-
const idSet = new Set(inContextRows.map((r) => r.id));
|
|
822
|
-
|
|
823
|
-
// Also include fork-source message IDs from in-context messages.
|
|
824
|
-
// When a conversation is forked, each copied message's metadata contains
|
|
825
|
-
// `forkSourceMessageId` pointing to the original (parent or grandparent)
|
|
826
|
-
// message ID. Segments sourced from those original messages are redundant.
|
|
827
|
-
for (const row of inContextRows) {
|
|
828
|
-
if (!row.metadata) continue;
|
|
829
|
-
try {
|
|
830
|
-
const parsed = JSON.parse(row.metadata);
|
|
831
|
-
if (
|
|
832
|
-
parsed &&
|
|
833
|
-
typeof parsed === "object" &&
|
|
834
|
-
!Array.isArray(parsed) &&
|
|
835
|
-
typeof parsed.forkSourceMessageId === "string"
|
|
836
|
-
) {
|
|
837
|
-
idSet.add(parsed.forkSourceMessageId);
|
|
838
|
-
}
|
|
839
|
-
} catch {
|
|
840
|
-
// Invalid metadata JSON — skip, don't break filtering.
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
return idSet;
|
|
845
|
-
} catch (err) {
|
|
846
|
-
log.warn(
|
|
847
|
-
{ err },
|
|
848
|
-
"Failed to fetch in-context message IDs; skipping segment filter",
|
|
849
|
-
);
|
|
850
|
-
return null;
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
/**
|
|
855
|
-
* Enrich item candidates with metadata needed for staleness computation:
|
|
856
|
-
* - firstSeenAt: when the item was first extracted
|
|
857
|
-
* - sourceConversationCount: number of distinct conversations that sourced this item
|
|
858
|
-
*/
|
|
859
|
-
function enrichItemMetadata(
|
|
860
|
-
itemIds: string[],
|
|
861
|
-
): Map<
|
|
862
|
-
string,
|
|
863
|
-
{ firstSeenAt: number; sourceConversationCount: number; kind: string }
|
|
864
|
-
> {
|
|
865
|
-
const result = new Map<
|
|
866
|
-
string,
|
|
867
|
-
{ firstSeenAt: number; sourceConversationCount: number; kind: string }
|
|
868
|
-
>();
|
|
869
|
-
if (itemIds.length === 0) return result;
|
|
870
|
-
|
|
871
|
-
try {
|
|
872
|
-
const db = getDb();
|
|
873
|
-
|
|
874
|
-
// Fetch firstSeenAt and kind from memory_items
|
|
875
|
-
const items = db
|
|
876
|
-
.select({
|
|
877
|
-
id: memoryItems.id,
|
|
878
|
-
firstSeenAt: memoryItems.firstSeenAt,
|
|
879
|
-
kind: memoryItems.kind,
|
|
880
|
-
})
|
|
881
|
-
.from(memoryItems)
|
|
882
|
-
.where(inArray(memoryItems.id, itemIds))
|
|
883
|
-
.all();
|
|
884
|
-
|
|
885
|
-
for (const item of items) {
|
|
886
|
-
result.set(item.id, {
|
|
887
|
-
firstSeenAt: item.firstSeenAt,
|
|
888
|
-
kind: item.kind,
|
|
889
|
-
sourceConversationCount: 1, // default, updated below
|
|
890
|
-
});
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
// Compute sourceConversationCount: count distinct conversation IDs
|
|
894
|
-
// across the memory_item_sources → messages join.
|
|
895
|
-
const sourceCountRows = db
|
|
896
|
-
.select({
|
|
897
|
-
memoryItemId: memoryItemSources.memoryItemId,
|
|
898
|
-
conversationCount:
|
|
899
|
-
sql<number>`COUNT(DISTINCT ${messages.conversationId})`.as(
|
|
900
|
-
"conversation_count",
|
|
901
|
-
),
|
|
902
|
-
})
|
|
903
|
-
.from(memoryItemSources)
|
|
904
|
-
.innerJoin(messages, sql`${memoryItemSources.messageId} = ${messages.id}`)
|
|
905
|
-
.where(inArray(memoryItemSources.memoryItemId, itemIds))
|
|
906
|
-
.groupBy(memoryItemSources.memoryItemId)
|
|
907
|
-
.all();
|
|
908
|
-
|
|
909
|
-
for (const row of sourceCountRows) {
|
|
910
|
-
const existing = result.get(row.memoryItemId);
|
|
911
|
-
if (existing) {
|
|
912
|
-
existing.sourceConversationCount = row.conversationCount;
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
} catch (err) {
|
|
916
|
-
log.warn(
|
|
917
|
-
{ err },
|
|
918
|
-
"Failed to enrich item metadata for staleness computation",
|
|
919
|
-
);
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
return result;
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
/**
|
|
926
|
-
* Enrich tiered candidates with source labels (conversation titles).
|
|
927
|
-
*
|
|
928
|
-
* For "item" candidates: joins through memoryItemSources → messages → conversations
|
|
929
|
-
* to find the most recent conversation title associated with the item.
|
|
930
|
-
* For "segment" / "summary" candidates: looks up the conversation title directly
|
|
931
|
-
* via the candidate's key (which contains the conversationId for segments).
|
|
932
|
-
*
|
|
933
|
-
* Mutates the candidates in-place for efficiency.
|
|
934
|
-
*/
|
|
935
|
-
function enrichSourceLabels(candidates: TieredCandidate[]): void {
|
|
936
|
-
if (candidates.length === 0) return;
|
|
937
|
-
|
|
938
|
-
try {
|
|
939
|
-
const db = getDb();
|
|
940
|
-
|
|
941
|
-
// ── Items: find conversation via memoryItemSources → messages → conversations ──
|
|
942
|
-
const itemCandidates = candidates.filter((c) => c.type === "item");
|
|
943
|
-
const itemIds = itemCandidates.map((c) => c.id);
|
|
944
|
-
|
|
945
|
-
if (itemIds.length > 0) {
|
|
946
|
-
const rows = db
|
|
947
|
-
.select({
|
|
948
|
-
memoryItemId: memoryItemSources.memoryItemId,
|
|
949
|
-
conversationId: conversations.id,
|
|
950
|
-
title: conversations.title,
|
|
951
|
-
conversationCreatedAt: conversations.createdAt,
|
|
952
|
-
conversationUpdatedAt: conversations.updatedAt,
|
|
953
|
-
})
|
|
954
|
-
.from(memoryItemSources)
|
|
955
|
-
.innerJoin(
|
|
956
|
-
messages,
|
|
957
|
-
sql`${memoryItemSources.messageId} = ${messages.id}`,
|
|
958
|
-
)
|
|
959
|
-
.innerJoin(
|
|
960
|
-
conversations,
|
|
961
|
-
sql`${messages.conversationId} = ${conversations.id}`,
|
|
962
|
-
)
|
|
963
|
-
.where(inArray(memoryItemSources.memoryItemId, itemIds))
|
|
964
|
-
.all();
|
|
965
|
-
|
|
966
|
-
// Group by item ID and pick the most recently updated conversation
|
|
967
|
-
const bestConvMap = new Map<
|
|
968
|
-
string,
|
|
969
|
-
{
|
|
970
|
-
title: string | null;
|
|
971
|
-
conversationId: string;
|
|
972
|
-
createdAt: number;
|
|
973
|
-
updatedAt: number;
|
|
974
|
-
}
|
|
975
|
-
>();
|
|
976
|
-
for (const row of rows) {
|
|
977
|
-
const existing = bestConvMap.get(row.memoryItemId);
|
|
978
|
-
if (
|
|
979
|
-
existing === undefined ||
|
|
980
|
-
row.conversationUpdatedAt > existing.updatedAt
|
|
981
|
-
) {
|
|
982
|
-
bestConvMap.set(row.memoryItemId, {
|
|
983
|
-
title: row.title,
|
|
984
|
-
conversationId: row.conversationId,
|
|
985
|
-
createdAt: row.conversationCreatedAt,
|
|
986
|
-
updatedAt: row.conversationUpdatedAt,
|
|
987
|
-
});
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
for (const c of itemCandidates) {
|
|
992
|
-
const conv = bestConvMap.get(c.id);
|
|
993
|
-
if (conv) {
|
|
994
|
-
if (conv.title) c.sourceLabel = conv.title;
|
|
995
|
-
const dirName = getConversationDirName(
|
|
996
|
-
conv.conversationId,
|
|
997
|
-
conv.createdAt,
|
|
998
|
-
);
|
|
999
|
-
c.sourcePath = `conversations/${dirName}/messages.jsonl`;
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
// ── Segments: look up conversation via conversationId on the candidate ──
|
|
1005
|
-
const segmentCandidates = candidates.filter(
|
|
1006
|
-
(c) => (c.type === "segment" || c.type === "summary") && c.conversationId,
|
|
1007
|
-
);
|
|
1008
|
-
|
|
1009
|
-
if (segmentCandidates.length > 0) {
|
|
1010
|
-
const convIds = [
|
|
1011
|
-
...new Set(segmentCandidates.map((c) => c.conversationId!)),
|
|
1012
|
-
];
|
|
1013
|
-
const convRows = db
|
|
1014
|
-
.select({
|
|
1015
|
-
id: conversations.id,
|
|
1016
|
-
title: conversations.title,
|
|
1017
|
-
createdAt: conversations.createdAt,
|
|
1018
|
-
})
|
|
1019
|
-
.from(conversations)
|
|
1020
|
-
.where(inArray(conversations.id, convIds))
|
|
1021
|
-
.all();
|
|
1022
|
-
|
|
1023
|
-
const convMap = new Map(convRows.map((r) => [r.id, r]));
|
|
1024
|
-
|
|
1025
|
-
for (const c of segmentCandidates) {
|
|
1026
|
-
const conv = convMap.get(c.conversationId!);
|
|
1027
|
-
if (conv) {
|
|
1028
|
-
if (conv.title) c.sourceLabel = conv.title;
|
|
1029
|
-
const dirName = getConversationDirName(conv.id, conv.createdAt);
|
|
1030
|
-
c.sourcePath = `conversations/${dirName}/messages.jsonl`;
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
} catch (err) {
|
|
1035
|
-
log.warn({ err }, "Failed to enrich candidates with source labels");
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
/**
|
|
1040
|
-
* Remove items from the array (in-place) whose ALL source messages are
|
|
1041
|
-
* in the given in-context set. This prevents current-turn content from
|
|
1042
|
-
* leaking into the injection via serendipity or other DB-sourced paths.
|
|
1043
|
-
*/
|
|
1044
|
-
function filterInContextItems(
|
|
1045
|
-
candidates: TieredCandidate[],
|
|
1046
|
-
inContextMessageIds: Set<string>,
|
|
1047
|
-
): void {
|
|
1048
|
-
const itemIds = candidates.filter((c) => c.type === "item").map((c) => c.id);
|
|
1049
|
-
if (itemIds.length === 0) return;
|
|
1050
|
-
|
|
1051
|
-
try {
|
|
1052
|
-
const db = getDb();
|
|
1053
|
-
const allSources = db
|
|
1054
|
-
.select({
|
|
1055
|
-
memoryItemId: memoryItemSources.memoryItemId,
|
|
1056
|
-
messageId: memoryItemSources.messageId,
|
|
1057
|
-
})
|
|
1058
|
-
.from(memoryItemSources)
|
|
1059
|
-
.where(inArray(memoryItemSources.memoryItemId, itemIds))
|
|
1060
|
-
.all();
|
|
1061
|
-
|
|
1062
|
-
const itemSourceMap = new Map<string, string[]>();
|
|
1063
|
-
for (const s of allSources) {
|
|
1064
|
-
const existing = itemSourceMap.get(s.memoryItemId);
|
|
1065
|
-
if (existing) existing.push(s.messageId);
|
|
1066
|
-
else itemSourceMap.set(s.memoryItemId, [s.messageId]);
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
1070
|
-
const c = candidates[i];
|
|
1071
|
-
if (c.type !== "item") continue;
|
|
1072
|
-
const sourceMessageIds = itemSourceMap.get(c.id);
|
|
1073
|
-
if (!sourceMessageIds || sourceMessageIds.length === 0) continue;
|
|
1074
|
-
if (sourceMessageIds.every((mid) => inContextMessageIds.has(mid))) {
|
|
1075
|
-
candidates.splice(i, 1);
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
} catch (err) {
|
|
1079
|
-
log.warn(
|
|
1080
|
-
{ err },
|
|
1081
|
-
"Failed to filter in-context serendipity items; skipping",
|
|
1082
|
-
);
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
/**
|
|
1087
|
-
* Sample random active memory items for serendipitous recall — items
|
|
1088
|
-
* the user didn't ask about but might spark unexpected connections.
|
|
1089
|
-
*
|
|
1090
|
-
* Queries SQLite for random active items not already in the candidate pool,
|
|
1091
|
-
* then selects up to `count` items with probability proportional to their
|
|
1092
|
-
* importance value (importance-weighted sampling).
|
|
1093
|
-
*
|
|
1094
|
-
* Items with importance >= MIN_SERENDIPITY_IMPORTANCE are eligible, as are
|
|
1095
|
-
* legacy items with NULL importance (not yet backfilled). This ensures
|
|
1096
|
-
* genuinely significant memories and pre-importance-era items can both
|
|
1097
|
-
* surface as echoes.
|
|
1098
|
-
*/
|
|
1099
|
-
const MIN_SERENDIPITY_IMPORTANCE = 0.7;
|
|
1100
|
-
|
|
1101
|
-
function sampleSerendipityItems(
|
|
1102
|
-
existingCandidates: TieredCandidate[],
|
|
1103
|
-
count: number,
|
|
1104
|
-
scopeIds?: string[],
|
|
1105
|
-
): TieredCandidate[] {
|
|
1106
|
-
if (count <= 0) return [];
|
|
1107
|
-
|
|
1108
|
-
try {
|
|
1109
|
-
const db = getDb();
|
|
1110
|
-
|
|
1111
|
-
// Collect IDs of item candidates already in the filtered set to exclude them
|
|
1112
|
-
const existingItemIds = existingCandidates
|
|
1113
|
-
.filter((c) => c.type === "item")
|
|
1114
|
-
.map((c) => c.id);
|
|
1115
|
-
|
|
1116
|
-
const RANDOM_POOL_SIZE = 10;
|
|
1117
|
-
|
|
1118
|
-
// Build scope condition: match allowed scopes, or default to 'default'
|
|
1119
|
-
// when no scope filter is set (prevents leaking private-scope items)
|
|
1120
|
-
const scopeCondition = scopeIds
|
|
1121
|
-
? inArray(memoryItems.scopeId, scopeIds)
|
|
1122
|
-
: eq(memoryItems.scopeId, "default");
|
|
1123
|
-
|
|
1124
|
-
const importanceFloor = sql`(${memoryItems.importance} >= ${MIN_SERENDIPITY_IMPORTANCE} OR ${memoryItems.importance} IS NULL)`;
|
|
1125
|
-
|
|
1126
|
-
const baseConditions =
|
|
1127
|
-
existingItemIds.length > 0
|
|
1128
|
-
? and(
|
|
1129
|
-
eq(memoryItems.status, "active"),
|
|
1130
|
-
scopeCondition,
|
|
1131
|
-
importanceFloor,
|
|
1132
|
-
notInArray(memoryItems.id, existingItemIds),
|
|
1133
|
-
)
|
|
1134
|
-
: and(
|
|
1135
|
-
eq(memoryItems.status, "active"),
|
|
1136
|
-
scopeCondition,
|
|
1137
|
-
importanceFloor,
|
|
1138
|
-
);
|
|
1139
|
-
|
|
1140
|
-
// Use rowid-probe sampling instead of ORDER BY RANDOM() to avoid a
|
|
1141
|
-
// full-table sort whose cost grows linearly with memory_items size.
|
|
1142
|
-
// Strategy: get the rowid range, generate random rowids, and probe for
|
|
1143
|
-
// the nearest eligible row with `rowid >= ?`. Each probe is O(log n)
|
|
1144
|
-
// via B-tree lookup, so total cost is O(k·log n) instead of O(n·log n).
|
|
1145
|
-
const range = db
|
|
1146
|
-
.select({
|
|
1147
|
-
minRowid: sql<number>`MIN(rowid)`,
|
|
1148
|
-
maxRowid: sql<number>`MAX(rowid)`,
|
|
1149
|
-
total: sql<number>`COUNT(*)`,
|
|
1150
|
-
})
|
|
1151
|
-
.from(memoryItems)
|
|
1152
|
-
.where(baseConditions)
|
|
1153
|
-
.get();
|
|
1154
|
-
|
|
1155
|
-
if (!range || range.total === 0) return [];
|
|
1156
|
-
|
|
1157
|
-
const columns = {
|
|
1158
|
-
id: memoryItems.id,
|
|
1159
|
-
kind: memoryItems.kind,
|
|
1160
|
-
subject: memoryItems.subject,
|
|
1161
|
-
statement: memoryItems.statement,
|
|
1162
|
-
importance: memoryItems.importance,
|
|
1163
|
-
firstSeenAt: memoryItems.firstSeenAt,
|
|
1164
|
-
};
|
|
1165
|
-
|
|
1166
|
-
let rows;
|
|
1167
|
-
if (range.total <= RANDOM_POOL_SIZE) {
|
|
1168
|
-
// Few enough eligible rows — fetch all, no randomness needed at DB level
|
|
1169
|
-
rows = db
|
|
1170
|
-
.select(columns)
|
|
1171
|
-
.from(memoryItems)
|
|
1172
|
-
.where(baseConditions)
|
|
1173
|
-
.all();
|
|
1174
|
-
} else {
|
|
1175
|
-
// Probe random rowids in the eligible range
|
|
1176
|
-
const seen = new Set<string>();
|
|
1177
|
-
rows = [];
|
|
1178
|
-
const rowidSpan = range.maxRowid - range.minRowid + 1;
|
|
1179
|
-
const maxAttempts = RANDOM_POOL_SIZE * 5;
|
|
1180
|
-
for (let i = 0; i < maxAttempts && rows.length < RANDOM_POOL_SIZE; i++) {
|
|
1181
|
-
const randomRowid =
|
|
1182
|
-
range.minRowid + Math.floor(Math.random() * rowidSpan);
|
|
1183
|
-
const row = db
|
|
1184
|
-
.select(columns)
|
|
1185
|
-
.from(memoryItems)
|
|
1186
|
-
.where(and(baseConditions, sql`rowid >= ${randomRowid}`))
|
|
1187
|
-
.orderBy(sql`rowid`)
|
|
1188
|
-
.limit(1)
|
|
1189
|
-
.get();
|
|
1190
|
-
if (row && !seen.has(row.id)) {
|
|
1191
|
-
seen.add(row.id);
|
|
1192
|
-
rows.push(row);
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
if (rows.length === 0) return [];
|
|
1198
|
-
|
|
1199
|
-
// Importance-weighted sampling: sort by importance * random() descending
|
|
1200
|
-
// and take the top `count` items
|
|
1201
|
-
const weighted = rows
|
|
1202
|
-
.map((row) => ({
|
|
1203
|
-
row,
|
|
1204
|
-
score: (row.importance ?? 0.5) * Math.random(),
|
|
1205
|
-
}))
|
|
1206
|
-
.sort((a, b) => b.score - a.score)
|
|
1207
|
-
.slice(0, count);
|
|
1208
|
-
|
|
1209
|
-
// Convert to Candidate-compatible objects
|
|
1210
|
-
return weighted.map(
|
|
1211
|
-
({ row }): TieredCandidate => ({
|
|
1212
|
-
type: "item",
|
|
1213
|
-
id: row.id,
|
|
1214
|
-
key: `item:${row.id}`,
|
|
1215
|
-
kind: row.kind,
|
|
1216
|
-
text: row.statement,
|
|
1217
|
-
source: "semantic",
|
|
1218
|
-
importance: row.importance ?? 0.5,
|
|
1219
|
-
confidence: 1,
|
|
1220
|
-
semantic: 0,
|
|
1221
|
-
recency: 0,
|
|
1222
|
-
finalScore: 0,
|
|
1223
|
-
createdAt: row.firstSeenAt,
|
|
1224
|
-
}),
|
|
1225
|
-
);
|
|
1226
|
-
} catch (err) {
|
|
1227
|
-
log.warn({ err }, "Failed to sample serendipity items");
|
|
1228
|
-
return [];
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
/**
|
|
1233
|
-
* Inject memory recall as a text content block prepended to the last user
|
|
1234
|
-
* message. This follows the same pattern as workspace, temporal, and other
|
|
1235
|
-
* runtime injections — the memory context is a text block in the user
|
|
1236
|
-
* message rather than a separate synthetic message pair.
|
|
1237
|
-
*
|
|
1238
|
-
* Stripping is handled by `stripUserTextBlocksByPrefix` matching the
|
|
1239
|
-
* `<memory_context __injected>` prefix in `RUNTIME_INJECTION_PREFIXES`, so no
|
|
1240
|
-
* dedicated strip function is needed.
|
|
1241
|
-
*/
|
|
1242
|
-
export function injectMemoryRecallAsUserBlock(
|
|
1243
|
-
messages: Message[],
|
|
1244
|
-
memoryRecallText: string,
|
|
1245
|
-
): Message[] {
|
|
1246
|
-
if (memoryRecallText.trim().length === 0) return messages;
|
|
1247
|
-
if (messages.length === 0) return messages;
|
|
1248
|
-
const userTail = messages[messages.length - 1];
|
|
1249
|
-
if (!userTail || userTail.role !== "user") return messages;
|
|
1250
|
-
return [
|
|
1251
|
-
...messages.slice(0, -1),
|
|
1252
|
-
{
|
|
1253
|
-
...userTail,
|
|
1254
|
-
content: [
|
|
1255
|
-
{ type: "text" as const, text: memoryRecallText },
|
|
1256
|
-
...userTail.content,
|
|
1257
|
-
],
|
|
1258
|
-
},
|
|
1259
|
-
];
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
export function queryMemoryForCli(
|
|
1263
|
-
query: string,
|
|
1264
|
-
conversationId: string,
|
|
1265
|
-
config: AssistantConfig,
|
|
1266
|
-
): Promise<MemoryRecallResult> {
|
|
1267
|
-
return buildMemoryRecall(query, conversationId, config);
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
function emptyResult(
|
|
1271
|
-
init: Partial<MemoryRecallResult> &
|
|
1272
|
-
Pick<MemoryRecallResult, "enabled" | "degraded" | "latencyMs">,
|
|
1273
|
-
): MemoryRecallResult {
|
|
1274
|
-
return {
|
|
1275
|
-
enabled: init.enabled,
|
|
1276
|
-
degraded: init.degraded,
|
|
1277
|
-
degradation: init.degradation,
|
|
1278
|
-
reason: init.reason,
|
|
1279
|
-
provider: init.provider,
|
|
1280
|
-
model: init.model,
|
|
1281
|
-
semanticHits: 0,
|
|
1282
|
-
mergedCount: 0,
|
|
1283
|
-
selectedCount: 0,
|
|
1284
|
-
injectedTokens: 0,
|
|
1285
|
-
injectedText: "",
|
|
1286
|
-
latencyMs: init.latencyMs,
|
|
1287
|
-
topCandidates: [],
|
|
1288
|
-
};
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
function truncate(text: string, max: number): string {
|
|
1292
|
-
if (text.length <= max) return text;
|
|
1293
|
-
return `${text.slice(0, max - 3)}...`;
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
function isAbortError(err: unknown): boolean {
|
|
1297
|
-
if (!(err instanceof Error)) return false;
|
|
1298
|
-
return err.name === "AbortError" || err.name === "APIUserAbortError";
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
/**
|
|
1302
|
-
* Check if an error represents a retryable HTTP status (429 or 5xx).
|
|
1303
|
-
* Checks the error's `status` or `statusCode` property first (set by most
|
|
1304
|
-
* HTTP/API clients), then falls back to looking for "status <code>" patterns
|
|
1305
|
-
* in the message. This avoids false positives from dimension numbers like 512.
|
|
1306
|
-
*/
|
|
1307
|
-
function getErrorStatusCode(err: Error): unknown {
|
|
1308
|
-
if ("status" in err) {
|
|
1309
|
-
const status = (err as { status: unknown }).status;
|
|
1310
|
-
if (status != null) return status;
|
|
1311
|
-
}
|
|
1312
|
-
if ("statusCode" in err) return (err as { statusCode: unknown }).statusCode;
|
|
1313
|
-
return undefined;
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
function isHttpStatusError(err: unknown): boolean {
|
|
1317
|
-
if (!(err instanceof Error)) return false;
|
|
1318
|
-
const status = getErrorStatusCode(err);
|
|
1319
|
-
if (typeof status === "number") {
|
|
1320
|
-
return status === 429 || (status >= 500 && status < 600);
|
|
1321
|
-
}
|
|
1322
|
-
// Fall back to message matching, but only for patterns that clearly
|
|
1323
|
-
// indicate an HTTP status code rather than arbitrary numbers.
|
|
1324
|
-
// Matches: "status 503", "HTTP 500", "status code: 502", parenthesized
|
|
1325
|
-
// codes like "failed (503)" from Gemini/Ollama (requires "failed" or
|
|
1326
|
-
// "error" context to avoid false positives from dimension numbers like
|
|
1327
|
-
// 512), and bare "429" (rate-limit).
|
|
1328
|
-
return /\b429\b|(?:failed|error)\s*\((?:429|5\d{2})\)|(?:status|http)\s*(?:code\s*)?:?\s*5\d{2}\b/i.test(
|
|
1329
|
-
err.message,
|
|
1330
|
-
);
|
|
1331
|
-
}
|