@vellumai/assistant 0.3.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.
Files changed (1068) hide show
  1. package/.dockerignore +27 -0
  2. package/.env.example +22 -0
  3. package/Dockerfile +99 -0
  4. package/Dockerfile.sandbox +5 -0
  5. package/README.md +248 -0
  6. package/bun.lock +1723 -0
  7. package/bunfig.toml +2 -0
  8. package/docs/skills.md +158 -0
  9. package/drizzle/0000_dizzy_maggott.sql +301 -0
  10. package/drizzle/meta/0000_snapshot.json +1999 -0
  11. package/drizzle/meta/_journal.json +13 -0
  12. package/drizzle.config.ts +7 -0
  13. package/eslint.config.mjs +17 -0
  14. package/hook-templates/debug-prompt-logger/hook.json +7 -0
  15. package/hook-templates/debug-prompt-logger/run.sh +68 -0
  16. package/knip.json +9 -0
  17. package/package.json +70 -0
  18. package/scripts/capture-x-graphql.ts +545 -0
  19. package/scripts/ipc/check-contract-inventory.ts +104 -0
  20. package/scripts/ipc/check-swift-decoder-drift.ts +166 -0
  21. package/scripts/ipc/generate-swift.ts +492 -0
  22. package/scripts/test-filesystem-tools.sh +48 -0
  23. package/scripts/test.sh +127 -0
  24. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +2485 -0
  25. package/src/__tests__/account-registry.test.ts +245 -0
  26. package/src/__tests__/active-skill-tools.test.ts +378 -0
  27. package/src/__tests__/agent-heartbeat-service.test.ts +250 -0
  28. package/src/__tests__/agent-loop-thinking.test.ts +81 -0
  29. package/src/__tests__/agent-loop.test.ts +1135 -0
  30. package/src/__tests__/anthropic-provider.test.ts +778 -0
  31. package/src/__tests__/app-builder-tool-scripts.test.ts +290 -0
  32. package/src/__tests__/app-bundler.test.ts +292 -0
  33. package/src/__tests__/app-executors.test.ts +613 -0
  34. package/src/__tests__/app-git-history.test.ts +176 -0
  35. package/src/__tests__/app-git-service.test.ts +169 -0
  36. package/src/__tests__/app-open-proxy.test.ts +62 -0
  37. package/src/__tests__/asset-materialize-tool.test.ts +452 -0
  38. package/src/__tests__/asset-search-tool.test.ts +477 -0
  39. package/src/__tests__/assistant-attachment-directive.test.ts +401 -0
  40. package/src/__tests__/assistant-attachments.test.ts +437 -0
  41. package/src/__tests__/assistant-event-hub.test.ts +226 -0
  42. package/src/__tests__/assistant-event.test.ts +123 -0
  43. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  44. package/src/__tests__/attachments-store.test.ts +476 -0
  45. package/src/__tests__/attachments.test.ts +134 -0
  46. package/src/__tests__/audit-log-rotation.test.ts +154 -0
  47. package/src/__tests__/browser-fill-credential.test.ts +309 -0
  48. package/src/__tests__/browser-manager.test.ts +203 -0
  49. package/src/__tests__/browser-runtime-check.test.ts +55 -0
  50. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +68 -0
  51. package/src/__tests__/browser-skill-endstate.test.ts +195 -0
  52. package/src/__tests__/bundle-scanner.test.ts +313 -0
  53. package/src/__tests__/call-bridge.test.ts +517 -0
  54. package/src/__tests__/call-constants.test.ts +40 -0
  55. package/src/__tests__/call-domain.test.ts +163 -0
  56. package/src/__tests__/call-orchestrator.test.ts +625 -0
  57. package/src/__tests__/call-recovery.test.ts +518 -0
  58. package/src/__tests__/call-routes-http.test.ts +699 -0
  59. package/src/__tests__/call-state-machine.test.ts +143 -0
  60. package/src/__tests__/call-state.test.ts +174 -0
  61. package/src/__tests__/call-store.test.ts +691 -0
  62. package/src/__tests__/channel-approval-routes.test.ts +2356 -0
  63. package/src/__tests__/channel-approval.test.ts +299 -0
  64. package/src/__tests__/channel-approvals.test.ts +521 -0
  65. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  66. package/src/__tests__/channel-guardian.test.ts +1005 -0
  67. package/src/__tests__/checker.test.ts +3519 -0
  68. package/src/__tests__/clarification-resolver.test.ts +159 -0
  69. package/src/__tests__/classifier.test.ts +67 -0
  70. package/src/__tests__/claude-code-skill-regression.test.ts +127 -0
  71. package/src/__tests__/claude-code-tool-profiles.test.ts +88 -0
  72. package/src/__tests__/cli-discover.test.ts +85 -0
  73. package/src/__tests__/cli.test.ts +26 -0
  74. package/src/__tests__/clipboard.test.ts +80 -0
  75. package/src/__tests__/commit-guarantee.test.ts +335 -0
  76. package/src/__tests__/commit-message-enrichment-service.test.ts +550 -0
  77. package/src/__tests__/compaction.benchmark.test.ts +176 -0
  78. package/src/__tests__/computer-use-session-compaction.test.ts +132 -0
  79. package/src/__tests__/computer-use-session-lifecycle.test.ts +293 -0
  80. package/src/__tests__/computer-use-session-working-dir.test.ts +117 -0
  81. package/src/__tests__/computer-use-skill-baseline.test.ts +74 -0
  82. package/src/__tests__/computer-use-skill-endstate.test.ts +89 -0
  83. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +217 -0
  84. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +107 -0
  85. package/src/__tests__/computer-use-skill-proxy-bridge.test.ts +54 -0
  86. package/src/__tests__/computer-use-tools.test.ts +250 -0
  87. package/src/__tests__/config-schema.test.ts +1462 -0
  88. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  89. package/src/__tests__/conflict-policy.test.ts +121 -0
  90. package/src/__tests__/conflict-store.test.ts +332 -0
  91. package/src/__tests__/connection-policy.test.ts +102 -0
  92. package/src/__tests__/contacts-tools.test.ts +331 -0
  93. package/src/__tests__/context-memory-e2e.test.ts +434 -0
  94. package/src/__tests__/context-token-estimator.test.ts +135 -0
  95. package/src/__tests__/context-window-manager.test.ts +376 -0
  96. package/src/__tests__/contradiction-checker.test.ts +314 -0
  97. package/src/__tests__/conversation-store.test.ts +612 -0
  98. package/src/__tests__/credential-broker-browser-fill.test.ts +517 -0
  99. package/src/__tests__/credential-broker-server-use.test.ts +554 -0
  100. package/src/__tests__/credential-broker.test.ts +167 -0
  101. package/src/__tests__/credential-host-pattern-match.test.ts +104 -0
  102. package/src/__tests__/credential-metadata-store.test.ts +779 -0
  103. package/src/__tests__/credential-policy-validate.test.ts +121 -0
  104. package/src/__tests__/credential-resolve.test.ts +328 -0
  105. package/src/__tests__/credential-security-e2e.test.ts +352 -0
  106. package/src/__tests__/credential-security-invariants.test.ts +583 -0
  107. package/src/__tests__/credential-selection.test.ts +354 -0
  108. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  109. package/src/__tests__/credential-vault.test.ts +852 -0
  110. package/src/__tests__/daemon-assistant-events.test.ts +164 -0
  111. package/src/__tests__/daemon-server-session-init.test.ts +522 -0
  112. package/src/__tests__/date-context.test.ts +373 -0
  113. package/src/__tests__/db-schedule-syntax-migration.test.ts +129 -0
  114. package/src/__tests__/delete-managed-skill-tool.test.ts +97 -0
  115. package/src/__tests__/diff.test.ts +121 -0
  116. package/src/__tests__/domain-normalize.test.ts +112 -0
  117. package/src/__tests__/domain-policy.test.ts +124 -0
  118. package/src/__tests__/doordash-client.test.ts +186 -0
  119. package/src/__tests__/doordash-session.test.ts +152 -0
  120. package/src/__tests__/dynamic-page-surface.test.ts +91 -0
  121. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +132 -0
  122. package/src/__tests__/edit-engine.test.ts +180 -0
  123. package/src/__tests__/elevenlabs-client.test.ts +271 -0
  124. package/src/__tests__/email-cli.test.ts +283 -0
  125. package/src/__tests__/encrypted-store.test.ts +332 -0
  126. package/src/__tests__/entity-extractor.test.ts +190 -0
  127. package/src/__tests__/ephemeral-permissions.test.ts +362 -0
  128. package/src/__tests__/evaluate-typescript-tool.test.ts +286 -0
  129. package/src/__tests__/event-bus.test.ts +222 -0
  130. package/src/__tests__/file-edit-tool.test.ts +122 -0
  131. package/src/__tests__/file-ops-service.test.ts +330 -0
  132. package/src/__tests__/file-read-tool.test.ts +75 -0
  133. package/src/__tests__/file-write-tool.test.ts +113 -0
  134. package/src/__tests__/filesystem-tools.test.ts +579 -0
  135. package/src/__tests__/fixtures/credential-security-fixtures.ts +181 -0
  136. package/src/__tests__/fixtures/media-reuse-fixtures.ts +126 -0
  137. package/src/__tests__/fixtures/mock-signup-server.ts +387 -0
  138. package/src/__tests__/fixtures/proxy-fixtures.ts +147 -0
  139. package/src/__tests__/followup-tools.test.ts +303 -0
  140. package/src/__tests__/forbidden-legacy-symbols.test.ts +71 -0
  141. package/src/__tests__/fuzzy-match-property.test.ts +216 -0
  142. package/src/__tests__/fuzzy-match.test.ts +138 -0
  143. package/src/__tests__/gateway-only-enforcement.test.ts +631 -0
  144. package/src/__tests__/gemini-image-service.test.ts +261 -0
  145. package/src/__tests__/gemini-provider.test.ts +651 -0
  146. package/src/__tests__/get-weather.test.ts +318 -0
  147. package/src/__tests__/gmail-integration.test.ts +73 -0
  148. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  149. package/src/__tests__/handlers-cu-observation-blob.test.ts +352 -0
  150. package/src/__tests__/handlers-ipc-blob-probe.test.ts +191 -0
  151. package/src/__tests__/handlers-slack-config.test.ts +200 -0
  152. package/src/__tests__/handlers-task-submit-slash.test.ts +38 -0
  153. package/src/__tests__/handlers-telegram-config.test.ts +968 -0
  154. package/src/__tests__/handlers-twilio-config.test.ts +659 -0
  155. package/src/__tests__/handlers-twitter-config.test.ts +858 -0
  156. package/src/__tests__/headless-browser-interactions.test.ts +536 -0
  157. package/src/__tests__/headless-browser-navigate.test.ts +211 -0
  158. package/src/__tests__/headless-browser-read-tools.test.ts +261 -0
  159. package/src/__tests__/headless-browser-snapshot.test.ts +185 -0
  160. package/src/__tests__/history-repair-observability.test.ts +56 -0
  161. package/src/__tests__/history-repair.test.ts +510 -0
  162. package/src/__tests__/home-base-bootstrap.test.ts +82 -0
  163. package/src/__tests__/hooks-blocking.test.ts +128 -0
  164. package/src/__tests__/hooks-cli.test.ts +144 -0
  165. package/src/__tests__/hooks-config.test.ts +93 -0
  166. package/src/__tests__/hooks-discovery.test.ts +199 -0
  167. package/src/__tests__/hooks-integration.test.ts +189 -0
  168. package/src/__tests__/hooks-manager.test.ts +187 -0
  169. package/src/__tests__/hooks-runner.test.ts +182 -0
  170. package/src/__tests__/hooks-settings.test.ts +154 -0
  171. package/src/__tests__/hooks-templates.test.ts +137 -0
  172. package/src/__tests__/hooks-ts-runner.test.ts +125 -0
  173. package/src/__tests__/hooks-watch.test.ts +100 -0
  174. package/src/__tests__/host-file-edit-tool.test.ts +228 -0
  175. package/src/__tests__/host-file-read-tool.test.ts +123 -0
  176. package/src/__tests__/host-file-write-tool.test.ts +136 -0
  177. package/src/__tests__/host-shell-tool.test.ts +562 -0
  178. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  179. package/src/__tests__/ingress-url-consistency.test.ts +214 -0
  180. package/src/__tests__/intent-routing.test.ts +259 -0
  181. package/src/__tests__/ipc-blob-store.test.ts +315 -0
  182. package/src/__tests__/ipc-contract-inventory.test.ts +54 -0
  183. package/src/__tests__/ipc-contract.test.ts +74 -0
  184. package/src/__tests__/ipc-protocol.test.ts +113 -0
  185. package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
  186. package/src/__tests__/ipc-snapshot.test.ts +1769 -0
  187. package/src/__tests__/ipc-validate.test.ts +407 -0
  188. package/src/__tests__/key-migration.test.ts +206 -0
  189. package/src/__tests__/keychain.test.ts +258 -0
  190. package/src/__tests__/llm-usage-store.test.ts +221 -0
  191. package/src/__tests__/managed-skill-lifecycle.test.ts +257 -0
  192. package/src/__tests__/managed-store.test.ts +608 -0
  193. package/src/__tests__/media-generate-image.test.ts +238 -0
  194. package/src/__tests__/media-reuse-story.e2e.test.ts +676 -0
  195. package/src/__tests__/media-visibility-policy.test.ts +141 -0
  196. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +235 -0
  197. package/src/__tests__/memory-lifecycle-e2e.test.ts +481 -0
  198. package/src/__tests__/memory-query-builder.test.ts +59 -0
  199. package/src/__tests__/memory-recall-quality.test.ts +846 -0
  200. package/src/__tests__/memory-regressions.experimental.test.ts +538 -0
  201. package/src/__tests__/memory-regressions.test.ts +4435 -0
  202. package/src/__tests__/memory-retrieval-budget.test.ts +49 -0
  203. package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
  204. package/src/__tests__/migration-cli-flows.test.ts +169 -0
  205. package/src/__tests__/migration-ordering.test.ts +249 -0
  206. package/src/__tests__/mock-signup-server.test.ts +528 -0
  207. package/src/__tests__/oauth-callback-registry.test.ts +92 -0
  208. package/src/__tests__/oauth2-gateway-transport.test.ts +285 -0
  209. package/src/__tests__/onboarding-starter-tasks.test.ts +176 -0
  210. package/src/__tests__/onboarding-template-contract.test.ts +58 -0
  211. package/src/__tests__/openai-provider.test.ts +753 -0
  212. package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
  213. package/src/__tests__/parser.test.ts +472 -0
  214. package/src/__tests__/path-classifier.test.ts +73 -0
  215. package/src/__tests__/path-policy.test.ts +435 -0
  216. package/src/__tests__/platform-move-helper.test.ts +99 -0
  217. package/src/__tests__/platform-socket-path.test.ts +52 -0
  218. package/src/__tests__/platform-workspace-migration.test.ts +1000 -0
  219. package/src/__tests__/platform.test.ts +131 -0
  220. package/src/__tests__/playbook-execution.test.ts +502 -0
  221. package/src/__tests__/playbook-tools.test.ts +340 -0
  222. package/src/__tests__/prebuilt-home-base-seed.test.ts +75 -0
  223. package/src/__tests__/pricing.test.ts +256 -0
  224. package/src/__tests__/profile-compiler.test.ts +374 -0
  225. package/src/__tests__/provider-commit-message-generator.test.ts +342 -0
  226. package/src/__tests__/provider-registry-ollama.test.ts +16 -0
  227. package/src/__tests__/provider-streaming.benchmark.test.ts +773 -0
  228. package/src/__tests__/proxy-approval-callback.test.ts +601 -0
  229. package/src/__tests__/public-ingress-urls.test.ts +256 -0
  230. package/src/__tests__/qdrant-manager.test.ts +267 -0
  231. package/src/__tests__/ratelimit.test.ts +297 -0
  232. package/src/__tests__/recurrence-engine-rruleset.test.ts +175 -0
  233. package/src/__tests__/recurrence-engine.test.ts +78 -0
  234. package/src/__tests__/recurrence-types.test.ts +79 -0
  235. package/src/__tests__/registry.test.ts +494 -0
  236. package/src/__tests__/relay-server.test.ts +688 -0
  237. package/src/__tests__/reminder-store.test.ts +223 -0
  238. package/src/__tests__/reminder.test.ts +229 -0
  239. package/src/__tests__/request-file-tool.test.ts +158 -0
  240. package/src/__tests__/run-orchestrator-assistant-events.test.ts +227 -0
  241. package/src/__tests__/run-orchestrator.test.ts +425 -0
  242. package/src/__tests__/runtime-attachment-metadata.test.ts +189 -0
  243. package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
  244. package/src/__tests__/runtime-events-sse.test.ts +162 -0
  245. package/src/__tests__/runtime-runs-http.test.ts +438 -0
  246. package/src/__tests__/runtime-runs.test.ts +260 -0
  247. package/src/__tests__/sandbox-diagnostics.test.ts +408 -0
  248. package/src/__tests__/sandbox-host-parity.test.ts +950 -0
  249. package/src/__tests__/scaffold-managed-skill-tool.test.ts +253 -0
  250. package/src/__tests__/schedule-store.test.ts +484 -0
  251. package/src/__tests__/schedule-tools.test.ts +783 -0
  252. package/src/__tests__/scheduler-recurrence.test.ts +430 -0
  253. package/src/__tests__/script-proxy-certs.test.ts +90 -0
  254. package/src/__tests__/script-proxy-connect-tunnel.test.ts +177 -0
  255. package/src/__tests__/script-proxy-decision-trace.test.ts +156 -0
  256. package/src/__tests__/script-proxy-http-forwarder.test.ts +281 -0
  257. package/src/__tests__/script-proxy-injection-runtime.test.ts +401 -0
  258. package/src/__tests__/script-proxy-mitm-handler.test.ts +407 -0
  259. package/src/__tests__/script-proxy-policy-runtime.test.ts +287 -0
  260. package/src/__tests__/script-proxy-policy.test.ts +310 -0
  261. package/src/__tests__/script-proxy-rewrite-specificity.test.ts +135 -0
  262. package/src/__tests__/script-proxy-router.test.ts +180 -0
  263. package/src/__tests__/script-proxy-session-manager.test.ts +382 -0
  264. package/src/__tests__/script-proxy-session-runtime.test.ts +113 -0
  265. package/src/__tests__/secret-allowlist.test.ts +230 -0
  266. package/src/__tests__/secret-ingress-handler.test.ts +110 -0
  267. package/src/__tests__/secret-onetime-send.test.ts +130 -0
  268. package/src/__tests__/secret-prompt-log-hygiene.test.ts +106 -0
  269. package/src/__tests__/secret-response-routing.test.ts +93 -0
  270. package/src/__tests__/secret-scanner-executor.test.ts +348 -0
  271. package/src/__tests__/secret-scanner.test.ts +900 -0
  272. package/src/__tests__/secure-keys.test.ts +323 -0
  273. package/src/__tests__/server-history-render.test.ts +431 -0
  274. package/src/__tests__/session-abort-tool-results.test.ts +240 -0
  275. package/src/__tests__/session-conflict-gate.test.ts +1136 -0
  276. package/src/__tests__/session-error.test.ts +369 -0
  277. package/src/__tests__/session-evictor.test.ts +188 -0
  278. package/src/__tests__/session-init.benchmark.test.ts +465 -0
  279. package/src/__tests__/session-load-history-repair.test.ts +222 -0
  280. package/src/__tests__/session-pre-run-repair.test.ts +213 -0
  281. package/src/__tests__/session-process-bridge.test.ts +242 -0
  282. package/src/__tests__/session-profile-injection.test.ts +444 -0
  283. package/src/__tests__/session-provider-retry-repair.test.ts +306 -0
  284. package/src/__tests__/session-queue.test.ts +1535 -0
  285. package/src/__tests__/session-runtime-assembly.test.ts +476 -0
  286. package/src/__tests__/session-runtime-workspace.test.ts +183 -0
  287. package/src/__tests__/session-skill-tools.test.ts +2431 -0
  288. package/src/__tests__/session-slash-known.test.ts +368 -0
  289. package/src/__tests__/session-slash-queue.test.ts +288 -0
  290. package/src/__tests__/session-slash-unknown.test.ts +271 -0
  291. package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
  292. package/src/__tests__/session-tool-setup-app-refresh.test.ts +473 -0
  293. package/src/__tests__/session-tool-setup-memory-scope.test.ts +140 -0
  294. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +140 -0
  295. package/src/__tests__/session-undo.test.ts +75 -0
  296. package/src/__tests__/session-workspace-cache-state.test.ts +246 -0
  297. package/src/__tests__/session-workspace-injection.test.ts +327 -0
  298. package/src/__tests__/session-workspace-tool-tracking.test.ts +240 -0
  299. package/src/__tests__/shared-filesystem-errors.test.ts +78 -0
  300. package/src/__tests__/shell-credential-ref.test.ts +187 -0
  301. package/src/__tests__/shell-identity.test.ts +256 -0
  302. package/src/__tests__/shell-parser-fuzz.test.ts +544 -0
  303. package/src/__tests__/shell-parser-property.test.ts +433 -0
  304. package/src/__tests__/shell-tool-proxy-mode.test.ts +272 -0
  305. package/src/__tests__/signup-e2e.test.ts +353 -0
  306. package/src/__tests__/size-guard.test.ts +117 -0
  307. package/src/__tests__/skill-include-graph.test.ts +303 -0
  308. package/src/__tests__/skill-load-tool.test.ts +409 -0
  309. package/src/__tests__/skill-projection.benchmark.test.ts +338 -0
  310. package/src/__tests__/skill-script-runner-host.test.ts +489 -0
  311. package/src/__tests__/skill-script-runner-sandbox.test.ts +349 -0
  312. package/src/__tests__/skill-script-runner.test.ts +159 -0
  313. package/src/__tests__/skill-tool-factory.test.ts +252 -0
  314. package/src/__tests__/skill-tool-manifest.test.ts +658 -0
  315. package/src/__tests__/skill-version-hash.test.ts +182 -0
  316. package/src/__tests__/skills.test.ts +680 -0
  317. package/src/__tests__/slash-commands-catalog.test.ts +86 -0
  318. package/src/__tests__/slash-commands-parser.test.ts +119 -0
  319. package/src/__tests__/slash-commands-resolver.test.ts +193 -0
  320. package/src/__tests__/slash-commands-rewrite.test.ts +39 -0
  321. package/src/__tests__/speaker-identification.test.ts +52 -0
  322. package/src/__tests__/starter-bundle.test.ts +136 -0
  323. package/src/__tests__/starter-task-flow.test.ts +143 -0
  324. package/src/__tests__/subagent-manager-notify.test.ts +404 -0
  325. package/src/__tests__/subagent-tools.test.ts +801 -0
  326. package/src/__tests__/subagent-types.test.ts +78 -0
  327. package/src/__tests__/swarm-orchestrator.test.ts +428 -0
  328. package/src/__tests__/swarm-plan-validator.test.ts +330 -0
  329. package/src/__tests__/swarm-recursion.test.ts +165 -0
  330. package/src/__tests__/swarm-router-planner.test.ts +208 -0
  331. package/src/__tests__/swarm-session-integration.test.ts +274 -0
  332. package/src/__tests__/swarm-tool.test.ts +145 -0
  333. package/src/__tests__/swarm-worker-backend.test.ts +129 -0
  334. package/src/__tests__/swarm-worker-runner.test.ts +272 -0
  335. package/src/__tests__/system-prompt.test.ts +439 -0
  336. package/src/__tests__/task-compiler.test.ts +284 -0
  337. package/src/__tests__/task-management-tools.test.ts +936 -0
  338. package/src/__tests__/task-runner.test.ts +216 -0
  339. package/src/__tests__/task-scheduler.test.ts +217 -0
  340. package/src/__tests__/task-tools.test.ts +595 -0
  341. package/src/__tests__/terminal-sandbox-docker.test.ts +1064 -0
  342. package/src/__tests__/terminal-sandbox.integration.test.ts +178 -0
  343. package/src/__tests__/terminal-sandbox.test.ts +202 -0
  344. package/src/__tests__/terminal-tools.test.ts +840 -0
  345. package/src/__tests__/test-support/browser-skill-harness.ts +90 -0
  346. package/src/__tests__/test-support/computer-use-skill-harness.ts +45 -0
  347. package/src/__tests__/tool-audit-listener.test.ts +113 -0
  348. package/src/__tests__/tool-domain-event-publisher.test.ts +253 -0
  349. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
  350. package/src/__tests__/tool-executor-lifecycle-events.test.ts +516 -0
  351. package/src/__tests__/tool-executor-redaction.test.ts +289 -0
  352. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  353. package/src/__tests__/tool-executor.test.ts +1989 -0
  354. package/src/__tests__/tool-metrics-listener.test.ts +225 -0
  355. package/src/__tests__/tool-notification-listener.test.ts +49 -0
  356. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  357. package/src/__tests__/tool-policy.test.ts +54 -0
  358. package/src/__tests__/tool-profiling-listener.test.ts +268 -0
  359. package/src/__tests__/tool-result-truncation.test.ts +217 -0
  360. package/src/__tests__/tool-trace-listener.test.ts +226 -0
  361. package/src/__tests__/top-level-renderer.test.ts +121 -0
  362. package/src/__tests__/top-level-scanner.test.ts +141 -0
  363. package/src/__tests__/trace-emitter.test.ts +173 -0
  364. package/src/__tests__/trust-store.test.ts +1605 -0
  365. package/src/__tests__/turn-commit.test.ts +554 -0
  366. package/src/__tests__/twilio-provider.test.ts +329 -0
  367. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  368. package/src/__tests__/twilio-routes-twiml.test.ts +127 -0
  369. package/src/__tests__/twilio-routes.test.ts +577 -0
  370. package/src/__tests__/twitter-auth-handler.test.ts +667 -0
  371. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  372. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  373. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  374. package/src/__tests__/url-safety.test.ts +418 -0
  375. package/src/__tests__/view-image-tool.test.ts +217 -0
  376. package/src/__tests__/weather-skill-regression.test.ts +225 -0
  377. package/src/__tests__/web-fetch.test.ts +869 -0
  378. package/src/__tests__/web-search.test.ts +584 -0
  379. package/src/__tests__/workspace-git-service.test.ts +1153 -0
  380. package/src/__tests__/workspace-heartbeat-service.test.ts +486 -0
  381. package/src/__tests__/workspace-lifecycle.test.ts +292 -0
  382. package/src/__tests__/workspace-policy.test.ts +213 -0
  383. package/src/agent/attachments.ts +35 -0
  384. package/src/agent/loop.ts +500 -0
  385. package/src/agent/message-types.ts +17 -0
  386. package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
  387. package/src/autonomy/autonomy-resolver.ts +60 -0
  388. package/src/autonomy/autonomy-store.ts +122 -0
  389. package/src/autonomy/disposition-mapper.ts +31 -0
  390. package/src/autonomy/index.ts +11 -0
  391. package/src/autonomy/types.ts +39 -0
  392. package/src/bundler/app-bundler.ts +295 -0
  393. package/src/bundler/bundle-scanner.ts +535 -0
  394. package/src/bundler/bundle-signer.ts +124 -0
  395. package/src/bundler/manifest.ts +21 -0
  396. package/src/bundler/signature-verifier.ts +184 -0
  397. package/src/calls/call-bridge.ts +168 -0
  398. package/src/calls/call-constants.ts +48 -0
  399. package/src/calls/call-domain.ts +430 -0
  400. package/src/calls/call-orchestrator.ts +498 -0
  401. package/src/calls/call-recovery.ts +207 -0
  402. package/src/calls/call-state-machine.ts +68 -0
  403. package/src/calls/call-state.ts +87 -0
  404. package/src/calls/call-store.ts +422 -0
  405. package/src/calls/elevenlabs-client.ts +97 -0
  406. package/src/calls/elevenlabs-config.ts +31 -0
  407. package/src/calls/relay-server.ts +390 -0
  408. package/src/calls/speaker-identification.ts +213 -0
  409. package/src/calls/twilio-config.ts +45 -0
  410. package/src/calls/twilio-provider.ts +263 -0
  411. package/src/calls/twilio-rest.ts +156 -0
  412. package/src/calls/twilio-routes.ts +311 -0
  413. package/src/calls/types.ts +39 -0
  414. package/src/calls/voice-provider.ts +14 -0
  415. package/src/calls/voice-quality.ts +114 -0
  416. package/src/cli/autonomy.ts +188 -0
  417. package/src/cli/config-commands.ts +334 -0
  418. package/src/cli/contacts.ts +149 -0
  419. package/src/cli/core-commands.ts +784 -0
  420. package/src/cli/doordash.ts +1055 -0
  421. package/src/cli/email-guardrails.ts +200 -0
  422. package/src/cli/email.ts +405 -0
  423. package/src/cli/ipc-client.ts +82 -0
  424. package/src/cli/main-screen.tsx +53 -0
  425. package/src/cli/map.ts +270 -0
  426. package/src/cli/twitter.ts +754 -0
  427. package/src/cli.ts +918 -0
  428. package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
  429. package/src/commands/cc-command-registry.ts +209 -0
  430. package/src/config/bundled-skills/.gitkeep +0 -0
  431. package/src/config/bundled-skills/agentmail/SKILL.md +128 -0
  432. package/src/config/bundled-skills/agentmail/icon.svg +21 -0
  433. package/src/config/bundled-skills/app-builder/SKILL.md +1404 -0
  434. package/src/config/bundled-skills/app-builder/TOOLS.json +279 -0
  435. package/src/config/bundled-skills/app-builder/icon.svg +9 -0
  436. package/src/config/bundled-skills/app-builder/tools/app-create.ts +15 -0
  437. package/src/config/bundled-skills/app-builder/tools/app-delete.ts +10 -0
  438. package/src/config/bundled-skills/app-builder/tools/app-file-edit.ts +11 -0
  439. package/src/config/bundled-skills/app-builder/tools/app-file-list.ts +10 -0
  440. package/src/config/bundled-skills/app-builder/tools/app-file-read.ts +18 -0
  441. package/src/config/bundled-skills/app-builder/tools/app-file-write.ts +11 -0
  442. package/src/config/bundled-skills/app-builder/tools/app-list.ts +10 -0
  443. package/src/config/bundled-skills/app-builder/tools/app-query.ts +10 -0
  444. package/src/config/bundled-skills/app-builder/tools/app-update.ts +20 -0
  445. package/src/config/bundled-skills/browser/SKILL.md +28 -0
  446. package/src/config/bundled-skills/browser/TOOLS.json +234 -0
  447. package/src/config/bundled-skills/browser/tools/browser-click.ts +9 -0
  448. package/src/config/bundled-skills/browser/tools/browser-close.ts +9 -0
  449. package/src/config/bundled-skills/browser/tools/browser-extract.ts +9 -0
  450. package/src/config/bundled-skills/browser/tools/browser-fill-credential.ts +9 -0
  451. package/src/config/bundled-skills/browser/tools/browser-navigate.ts +9 -0
  452. package/src/config/bundled-skills/browser/tools/browser-press-key.ts +9 -0
  453. package/src/config/bundled-skills/browser/tools/browser-screenshot.ts +9 -0
  454. package/src/config/bundled-skills/browser/tools/browser-snapshot.ts +9 -0
  455. package/src/config/bundled-skills/browser/tools/browser-type.ts +9 -0
  456. package/src/config/bundled-skills/browser/tools/browser-wait-for.ts +9 -0
  457. package/src/config/bundled-skills/claude-code/SKILL.md +50 -0
  458. package/src/config/bundled-skills/claude-code/TOOLS.json +40 -0
  459. package/src/config/bundled-skills/claude-code/tools/claude-code.ts +9 -0
  460. package/src/config/bundled-skills/computer-use/SKILL.md +17 -0
  461. package/src/config/bundled-skills/computer-use/TOOLS.json +326 -0
  462. package/src/config/bundled-skills/computer-use/tools/computer-use-click.ts +9 -0
  463. package/src/config/bundled-skills/computer-use/tools/computer-use-done.ts +9 -0
  464. package/src/config/bundled-skills/computer-use/tools/computer-use-double-click.ts +9 -0
  465. package/src/config/bundled-skills/computer-use/tools/computer-use-drag.ts +9 -0
  466. package/src/config/bundled-skills/computer-use/tools/computer-use-key.ts +9 -0
  467. package/src/config/bundled-skills/computer-use/tools/computer-use-open-app.ts +9 -0
  468. package/src/config/bundled-skills/computer-use/tools/computer-use-request-control.ts +9 -0
  469. package/src/config/bundled-skills/computer-use/tools/computer-use-respond.ts +9 -0
  470. package/src/config/bundled-skills/computer-use/tools/computer-use-right-click.ts +9 -0
  471. package/src/config/bundled-skills/computer-use/tools/computer-use-run-applescript.ts +9 -0
  472. package/src/config/bundled-skills/computer-use/tools/computer-use-scroll.ts +9 -0
  473. package/src/config/bundled-skills/computer-use/tools/computer-use-type-text.ts +9 -0
  474. package/src/config/bundled-skills/computer-use/tools/computer-use-wait.ts +9 -0
  475. package/src/config/bundled-skills/contacts/SKILL.md +39 -0
  476. package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
  477. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +57 -0
  478. package/src/config/bundled-skills/contacts/tools/contact-search.ts +60 -0
  479. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +66 -0
  480. package/src/config/bundled-skills/document/SKILL.md +26 -0
  481. package/src/config/bundled-skills/document/TOOLS.json +53 -0
  482. package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
  483. package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
  484. package/src/config/bundled-skills/doordash/SKILL.md +163 -0
  485. package/src/config/bundled-skills/followups/SKILL.md +32 -0
  486. package/src/config/bundled-skills/followups/TOOLS.json +100 -0
  487. package/src/config/bundled-skills/followups/icon.svg +24 -0
  488. package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
  489. package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
  490. package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
  491. package/src/config/bundled-skills/google-calendar/SKILL.md +51 -0
  492. package/src/config/bundled-skills/google-calendar/TOOLS.json +108 -0
  493. package/src/config/bundled-skills/google-calendar/calendar-client.ts +165 -0
  494. package/src/config/bundled-skills/google-calendar/tools/calendar-check-availability.ts +21 -0
  495. package/src/config/bundled-skills/google-calendar/tools/calendar-create-event.ts +42 -0
  496. package/src/config/bundled-skills/google-calendar/tools/calendar-get-event.ts +13 -0
  497. package/src/config/bundled-skills/google-calendar/tools/calendar-list-events.ts +30 -0
  498. package/src/config/bundled-skills/google-calendar/tools/calendar-rsvp.ts +41 -0
  499. package/src/config/bundled-skills/google-calendar/tools/shared.ts +18 -0
  500. package/src/config/bundled-skills/google-calendar/types.ts +97 -0
  501. package/src/config/bundled-skills/image-studio/SKILL.md +32 -0
  502. package/src/config/bundled-skills/image-studio/TOOLS.json +42 -0
  503. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +115 -0
  504. package/src/config/bundled-skills/macos-automation/SKILL.md +66 -0
  505. package/src/config/bundled-skills/messaging/SKILL.md +153 -0
  506. package/src/config/bundled-skills/messaging/TOOLS.json +357 -0
  507. package/src/config/bundled-skills/messaging/tools/gmail-archive.ts +23 -0
  508. package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +23 -0
  509. package/src/config/bundled-skills/messaging/tools/gmail-batch-label.ts +25 -0
  510. package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +26 -0
  511. package/src/config/bundled-skills/messaging/tools/gmail-label.ts +25 -0
  512. package/src/config/bundled-skills/messaging/tools/gmail-trash.ts +23 -0
  513. package/src/config/bundled-skills/messaging/tools/gmail-unsubscribe.ts +84 -0
  514. package/src/config/bundled-skills/messaging/tools/messaging-analyze-activity.ts +18 -0
  515. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +125 -0
  516. package/src/config/bundled-skills/messaging/tools/messaging-auth-test.ts +16 -0
  517. package/src/config/bundled-skills/messaging/tools/messaging-draft.ts +49 -0
  518. package/src/config/bundled-skills/messaging/tools/messaging-list-conversations.ts +21 -0
  519. package/src/config/bundled-skills/messaging/tools/messaging-mark-read.ts +25 -0
  520. package/src/config/bundled-skills/messaging/tools/messaging-read.ts +28 -0
  521. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +32 -0
  522. package/src/config/bundled-skills/messaging/tools/messaging-search.ts +22 -0
  523. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +31 -0
  524. package/src/config/bundled-skills/messaging/tools/shared.ts +76 -0
  525. package/src/config/bundled-skills/messaging/tools/slack-add-reaction.ts +25 -0
  526. package/src/config/bundled-skills/messaging/tools/slack-leave-channel.ts +23 -0
  527. package/src/config/bundled-skills/phone-calls/SKILL.md +533 -0
  528. package/src/config/bundled-skills/playbooks/SKILL.md +31 -0
  529. package/src/config/bundled-skills/playbooks/TOOLS.json +126 -0
  530. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +98 -0
  531. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +54 -0
  532. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +76 -0
  533. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +113 -0
  534. package/src/config/bundled-skills/public-ingress/SKILL.md +200 -0
  535. package/src/config/bundled-skills/reminder/SKILL.md +20 -0
  536. package/src/config/bundled-skills/reminder/TOOLS.json +67 -0
  537. package/src/config/bundled-skills/reminder/tools/reminder-cancel.ts +9 -0
  538. package/src/config/bundled-skills/reminder/tools/reminder-create.ts +9 -0
  539. package/src/config/bundled-skills/reminder/tools/reminder-list.ts +9 -0
  540. package/src/config/bundled-skills/schedule/SKILL.md +74 -0
  541. package/src/config/bundled-skills/schedule/TOOLS.json +135 -0
  542. package/src/config/bundled-skills/schedule/tools/schedule-create.ts +9 -0
  543. package/src/config/bundled-skills/schedule/tools/schedule-delete.ts +9 -0
  544. package/src/config/bundled-skills/schedule/tools/schedule-list.ts +9 -0
  545. package/src/config/bundled-skills/schedule/tools/schedule-update.ts +9 -0
  546. package/src/config/bundled-skills/self-upgrade/SKILL.md +68 -0
  547. package/src/config/bundled-skills/start-the-day/SKILL.md +70 -0
  548. package/src/config/bundled-skills/start-the-day/icon.svg +13 -0
  549. package/src/config/bundled-skills/subagent/SKILL.md +25 -0
  550. package/src/config/bundled-skills/subagent/TOOLS.json +107 -0
  551. package/src/config/bundled-skills/subagent/tools/subagent-abort.ts +9 -0
  552. package/src/config/bundled-skills/subagent/tools/subagent-message.ts +9 -0
  553. package/src/config/bundled-skills/subagent/tools/subagent-read.ts +9 -0
  554. package/src/config/bundled-skills/subagent/tools/subagent-spawn.ts +9 -0
  555. package/src/config/bundled-skills/subagent/tools/subagent-status.ts +9 -0
  556. package/src/config/bundled-skills/tasks/SKILL.md +28 -0
  557. package/src/config/bundled-skills/tasks/TOOLS.json +281 -0
  558. package/src/config/bundled-skills/tasks/tools/task-delete.ts +9 -0
  559. package/src/config/bundled-skills/tasks/tools/task-list-add.ts +9 -0
  560. package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +9 -0
  561. package/src/config/bundled-skills/tasks/tools/task-list-show.ts +9 -0
  562. package/src/config/bundled-skills/tasks/tools/task-list-update.ts +9 -0
  563. package/src/config/bundled-skills/tasks/tools/task-list.ts +9 -0
  564. package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
  565. package/src/config/bundled-skills/tasks/tools/task-run.ts +9 -0
  566. package/src/config/bundled-skills/tasks/tools/task-save.ts +9 -0
  567. package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
  568. package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
  569. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
  570. package/src/config/bundled-skills/twitter/SKILL.md +220 -0
  571. package/src/config/bundled-skills/watcher/SKILL.md +27 -0
  572. package/src/config/bundled-skills/watcher/TOOLS.json +147 -0
  573. package/src/config/bundled-skills/watcher/tools/watcher-create.ts +9 -0
  574. package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +9 -0
  575. package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +9 -0
  576. package/src/config/bundled-skills/watcher/tools/watcher-list.ts +9 -0
  577. package/src/config/bundled-skills/watcher/tools/watcher-update.ts +9 -0
  578. package/src/config/bundled-skills/weather/SKILL.md +37 -0
  579. package/src/config/bundled-skills/weather/TOOLS.json +32 -0
  580. package/src/config/bundled-skills/weather/icon.svg +24 -0
  581. package/src/config/bundled-skills/weather/tools/get-weather.ts +9 -0
  582. package/src/config/computer-use-prompt.ts +97 -0
  583. package/src/config/defaults.ts +263 -0
  584. package/src/config/loader.ts +339 -0
  585. package/src/config/schema.ts +1436 -0
  586. package/src/config/skill-state.ts +95 -0
  587. package/src/config/skills.ts +972 -0
  588. package/src/config/system-prompt.ts +675 -0
  589. package/src/config/templates/BOOTSTRAP.md +70 -0
  590. package/src/config/templates/IDENTITY.md +25 -0
  591. package/src/config/templates/LOOKS.md +25 -0
  592. package/src/config/templates/SOUL.md +37 -0
  593. package/src/config/templates/USER.md +19 -0
  594. package/src/config/types.ts +42 -0
  595. package/src/config/vellum-skills/chatgpt-import/SKILL.md +24 -0
  596. package/src/config/vellum-skills/chatgpt-import/TOOLS.json +23 -0
  597. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +284 -0
  598. package/src/config/vellum-skills/deploy-fullstack-vercel/SKILL.md +179 -0
  599. package/src/config/vellum-skills/document-writer/SKILL.md +195 -0
  600. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +199 -0
  601. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +153 -0
  602. package/src/config/vellum-skills/telegram-setup/SKILL.md +143 -0
  603. package/src/config/vellum-skills/twilio-setup/SKILL.md +213 -0
  604. package/src/contacts/contact-store.ts +410 -0
  605. package/src/contacts/index.ts +11 -0
  606. package/src/contacts/types.ts +28 -0
  607. package/src/context/token-estimator.ts +108 -0
  608. package/src/context/tool-result-truncation.ts +128 -0
  609. package/src/context/window-manager.ts +531 -0
  610. package/src/daemon/assistant-attachments.ts +691 -0
  611. package/src/daemon/classifier.ts +110 -0
  612. package/src/daemon/computer-use-session.ts +903 -0
  613. package/src/daemon/connection-policy.ts +41 -0
  614. package/src/daemon/date-context.ts +136 -0
  615. package/src/daemon/handlers/apps.ts +530 -0
  616. package/src/daemon/handlers/browser.ts +54 -0
  617. package/src/daemon/handlers/computer-use.ts +187 -0
  618. package/src/daemon/handlers/config.ts +1517 -0
  619. package/src/daemon/handlers/diagnostics.ts +338 -0
  620. package/src/daemon/handlers/documents.ts +173 -0
  621. package/src/daemon/handlers/home-base.ts +78 -0
  622. package/src/daemon/handlers/identity.ts +127 -0
  623. package/src/daemon/handlers/index.ts +129 -0
  624. package/src/daemon/handlers/misc.ts +331 -0
  625. package/src/daemon/handlers/open-bundle-handler.ts +80 -0
  626. package/src/daemon/handlers/publish.ts +187 -0
  627. package/src/daemon/handlers/sessions.ts +555 -0
  628. package/src/daemon/handlers/shared.ts +570 -0
  629. package/src/daemon/handlers/signing.ts +37 -0
  630. package/src/daemon/handlers/skills.ts +486 -0
  631. package/src/daemon/handlers/subagents.ts +210 -0
  632. package/src/daemon/handlers/twitter-auth.ts +198 -0
  633. package/src/daemon/handlers/work-items.ts +632 -0
  634. package/src/daemon/handlers/workspace-files.ts +75 -0
  635. package/src/daemon/handlers.ts +17 -0
  636. package/src/daemon/history-repair.ts +214 -0
  637. package/src/daemon/ipc-blob-store.ts +231 -0
  638. package/src/daemon/ipc-contract-inventory.json +495 -0
  639. package/src/daemon/ipc-contract-inventory.ts +126 -0
  640. package/src/daemon/ipc-contract.ts +2551 -0
  641. package/src/daemon/ipc-protocol.ts +75 -0
  642. package/src/daemon/ipc-validate.ts +188 -0
  643. package/src/daemon/lifecycle.ts +582 -0
  644. package/src/daemon/main.ts +21 -0
  645. package/src/daemon/media-visibility-policy.ts +57 -0
  646. package/src/daemon/ride-shotgun-handler.ts +309 -0
  647. package/src/daemon/server.ts +1215 -0
  648. package/src/daemon/session-agent-loop.ts +922 -0
  649. package/src/daemon/session-attachments.ts +196 -0
  650. package/src/daemon/session-conflict-gate.ts +184 -0
  651. package/src/daemon/session-dynamic-profile.ts +63 -0
  652. package/src/daemon/session-error.ts +290 -0
  653. package/src/daemon/session-evictor.ts +196 -0
  654. package/src/daemon/session-history.ts +437 -0
  655. package/src/daemon/session-lifecycle.ts +147 -0
  656. package/src/daemon/session-media-retry.ts +147 -0
  657. package/src/daemon/session-memory.ts +212 -0
  658. package/src/daemon/session-messaging.ts +145 -0
  659. package/src/daemon/session-notifiers.ts +193 -0
  660. package/src/daemon/session-process.ts +323 -0
  661. package/src/daemon/session-queue-manager.ts +82 -0
  662. package/src/daemon/session-runtime-assembly.ts +447 -0
  663. package/src/daemon/session-skill-tools.ts +356 -0
  664. package/src/daemon/session-slash.ts +305 -0
  665. package/src/daemon/session-surfaces.ts +702 -0
  666. package/src/daemon/session-tool-setup.ts +523 -0
  667. package/src/daemon/session-usage.ts +72 -0
  668. package/src/daemon/session-workspace.ts +19 -0
  669. package/src/daemon/session.ts +400 -0
  670. package/src/daemon/tls-certs.ts +189 -0
  671. package/src/daemon/trace-emitter.ts +82 -0
  672. package/src/daemon/video-thumbnail.ts +62 -0
  673. package/src/daemon/watch-handler.ts +274 -0
  674. package/src/doordash/client.ts +999 -0
  675. package/src/doordash/queries.ts +1311 -0
  676. package/src/doordash/query-extractor.ts +93 -0
  677. package/src/doordash/session.ts +82 -0
  678. package/src/email/provider.ts +117 -0
  679. package/src/email/providers/agentmail.ts +317 -0
  680. package/src/email/providers/index.ts +58 -0
  681. package/src/email/service.ts +303 -0
  682. package/src/email/types.ts +126 -0
  683. package/src/events/bus.ts +157 -0
  684. package/src/events/domain-events.ts +83 -0
  685. package/src/events/index.ts +18 -0
  686. package/src/events/tool-audit-listener.ts +80 -0
  687. package/src/events/tool-domain-event-publisher.ts +111 -0
  688. package/src/events/tool-metrics-listener.ts +159 -0
  689. package/src/events/tool-notification-listener.ts +17 -0
  690. package/src/events/tool-profiling-listener.ts +158 -0
  691. package/src/events/tool-trace-listener.ts +75 -0
  692. package/src/export/formatter.ts +98 -0
  693. package/src/followups/followup-store.ts +168 -0
  694. package/src/followups/index.ts +10 -0
  695. package/src/followups/types.ts +29 -0
  696. package/src/gallery/default-gallery.ts +795 -0
  697. package/src/gallery/gallery-manifest.ts +24 -0
  698. package/src/home-base/app-link-store.ts +82 -0
  699. package/src/home-base/bootstrap.ts +68 -0
  700. package/src/home-base/prebuilt/index.html +662 -0
  701. package/src/home-base/prebuilt/seed-metadata.json +21 -0
  702. package/src/home-base/prebuilt/seed.ts +112 -0
  703. package/src/home-base/prebuilt-home-base-updater.ts +30 -0
  704. package/src/hooks/cli.ts +163 -0
  705. package/src/hooks/config.ts +88 -0
  706. package/src/hooks/discovery.ts +110 -0
  707. package/src/hooks/manager.ts +124 -0
  708. package/src/hooks/runner.ts +123 -0
  709. package/src/hooks/templates.ts +52 -0
  710. package/src/hooks/types.ts +72 -0
  711. package/src/inbound/public-ingress-urls.ts +123 -0
  712. package/src/index.ts +81 -0
  713. package/src/instrument.ts +60 -0
  714. package/src/logfire.ts +99 -0
  715. package/src/media/gemini-image-service.ts +136 -0
  716. package/src/memory/account-store.ts +108 -0
  717. package/src/memory/admin.ts +211 -0
  718. package/src/memory/app-git-service.ts +295 -0
  719. package/src/memory/app-store.ts +577 -0
  720. package/src/memory/attachments-store.ts +397 -0
  721. package/src/memory/channel-delivery-store.ts +353 -0
  722. package/src/memory/channel-guardian-store.ts +669 -0
  723. package/src/memory/checkpoints.ts +52 -0
  724. package/src/memory/clarification-resolver.ts +298 -0
  725. package/src/memory/conflict-intent.ts +157 -0
  726. package/src/memory/conflict-policy.ts +73 -0
  727. package/src/memory/conflict-store.ts +350 -0
  728. package/src/memory/contradiction-checker.ts +358 -0
  729. package/src/memory/conversation-key-store.ts +122 -0
  730. package/src/memory/conversation-store.ts +470 -0
  731. package/src/memory/db.ts +1991 -0
  732. package/src/memory/embedding-backend.ts +229 -0
  733. package/src/memory/embedding-gemini.ts +52 -0
  734. package/src/memory/embedding-local.ts +65 -0
  735. package/src/memory/embedding-ollama.ts +55 -0
  736. package/src/memory/embedding-openai.ts +25 -0
  737. package/src/memory/entity-extractor.ts +474 -0
  738. package/src/memory/external-conversation-store.ts +234 -0
  739. package/src/memory/fingerprint.ts +20 -0
  740. package/src/memory/indexer.ts +156 -0
  741. package/src/memory/items-extractor.ts +461 -0
  742. package/src/memory/job-handlers/backfill.ts +139 -0
  743. package/src/memory/job-handlers/cleanup.ts +58 -0
  744. package/src/memory/job-handlers/conflict.ts +141 -0
  745. package/src/memory/job-handlers/embedding.ts +61 -0
  746. package/src/memory/job-handlers/extraction.ts +123 -0
  747. package/src/memory/job-handlers/index-maintenance.ts +54 -0
  748. package/src/memory/job-handlers/summarization.ts +286 -0
  749. package/src/memory/job-utils.ts +170 -0
  750. package/src/memory/jobs-store.ts +401 -0
  751. package/src/memory/jobs-worker.ts +313 -0
  752. package/src/memory/llm-request-log-store.ts +45 -0
  753. package/src/memory/llm-usage-store.ts +60 -0
  754. package/src/memory/message-content.ts +54 -0
  755. package/src/memory/profile-compiler.ts +160 -0
  756. package/src/memory/published-pages-store.ts +137 -0
  757. package/src/memory/qdrant-client.ts +366 -0
  758. package/src/memory/qdrant-manager.ts +242 -0
  759. package/src/memory/query-builder.ts +45 -0
  760. package/src/memory/retrieval-budget.ts +30 -0
  761. package/src/memory/retriever.ts +653 -0
  762. package/src/memory/runs-store.ts +305 -0
  763. package/src/memory/schema.ts +677 -0
  764. package/src/memory/search/entity.ts +298 -0
  765. package/src/memory/search/formatting.ts +207 -0
  766. package/src/memory/search/lexical.ts +227 -0
  767. package/src/memory/search/ranking.ts +401 -0
  768. package/src/memory/search/semantic.ts +121 -0
  769. package/src/memory/search/types.ts +137 -0
  770. package/src/memory/segmenter.ts +68 -0
  771. package/src/memory/shared-app-links-store.ts +138 -0
  772. package/src/memory/tool-usage-store.ts +62 -0
  773. package/src/messaging/activity-analyzer.ts +76 -0
  774. package/src/messaging/draft-store.ts +88 -0
  775. package/src/messaging/index.ts +3 -0
  776. package/src/messaging/provider-types.ts +80 -0
  777. package/src/messaging/provider.ts +52 -0
  778. package/src/messaging/providers/gmail/adapter.ts +193 -0
  779. package/src/messaging/providers/gmail/client.ts +204 -0
  780. package/src/messaging/providers/gmail/types.ts +90 -0
  781. package/src/messaging/providers/slack/adapter.ts +202 -0
  782. package/src/messaging/providers/slack/client.ts +198 -0
  783. package/src/messaging/providers/slack/types.ts +119 -0
  784. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  785. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  786. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  787. package/src/messaging/registry.ts +35 -0
  788. package/src/messaging/style-analyzer.ts +159 -0
  789. package/src/messaging/thread-summarizer.ts +306 -0
  790. package/src/messaging/triage-engine.ts +323 -0
  791. package/src/messaging/types.ts +55 -0
  792. package/src/permissions/checker.ts +640 -0
  793. package/src/permissions/defaults.ts +254 -0
  794. package/src/permissions/prompter.ts +98 -0
  795. package/src/permissions/secret-prompter.ts +114 -0
  796. package/src/permissions/shell-identity.ts +227 -0
  797. package/src/permissions/trust-store.ts +607 -0
  798. package/src/permissions/types.ts +43 -0
  799. package/src/permissions/workspace-policy.ts +114 -0
  800. package/src/playbooks/index.ts +2 -0
  801. package/src/playbooks/playbook-compiler.ts +90 -0
  802. package/src/playbooks/types.ts +55 -0
  803. package/src/providers/anthropic/client.ts +751 -0
  804. package/src/providers/failover.ts +129 -0
  805. package/src/providers/fireworks/client.ts +20 -0
  806. package/src/providers/gemini/client.ts +285 -0
  807. package/src/providers/ollama/client.ts +30 -0
  808. package/src/providers/openai/client.ts +337 -0
  809. package/src/providers/openrouter/client.ts +20 -0
  810. package/src/providers/ratelimit.ts +93 -0
  811. package/src/providers/registry.ts +146 -0
  812. package/src/providers/retry.ts +81 -0
  813. package/src/providers/stream-timeout.ts +38 -0
  814. package/src/providers/types.ts +109 -0
  815. package/src/runtime/assistant-event-hub.ts +157 -0
  816. package/src/runtime/assistant-event.ts +82 -0
  817. package/src/runtime/channel-approval-parser.ts +60 -0
  818. package/src/runtime/channel-approval-types.ts +73 -0
  819. package/src/runtime/channel-approvals.ts +206 -0
  820. package/src/runtime/channel-guardian-service.ts +212 -0
  821. package/src/runtime/gateway-client.ts +58 -0
  822. package/src/runtime/http-server.ts +1076 -0
  823. package/src/runtime/http-types.ts +66 -0
  824. package/src/runtime/routes/app-routes.ts +174 -0
  825. package/src/runtime/routes/attachment-routes.ts +133 -0
  826. package/src/runtime/routes/call-routes.ts +190 -0
  827. package/src/runtime/routes/channel-routes.ts +1404 -0
  828. package/src/runtime/routes/conversation-routes.ts +352 -0
  829. package/src/runtime/routes/events-routes.ts +148 -0
  830. package/src/runtime/routes/run-routes.ts +257 -0
  831. package/src/runtime/routes/secret-routes.ts +76 -0
  832. package/src/runtime/run-orchestrator.ts +330 -0
  833. package/src/schedule/recurrence-engine.ts +162 -0
  834. package/src/schedule/recurrence-types.ts +67 -0
  835. package/src/schedule/schedule-store.ts +506 -0
  836. package/src/schedule/scheduler.ts +171 -0
  837. package/src/security/encrypted-store.ts +238 -0
  838. package/src/security/keychain.ts +252 -0
  839. package/src/security/oauth-callback-registry.ts +66 -0
  840. package/src/security/oauth2.ts +274 -0
  841. package/src/security/redaction.ts +89 -0
  842. package/src/security/secret-allowlist.ts +164 -0
  843. package/src/security/secret-ingress.ts +57 -0
  844. package/src/security/secret-scanner.ts +550 -0
  845. package/src/security/secure-keys.ts +180 -0
  846. package/src/security/token-manager.ts +141 -0
  847. package/src/services/published-app-updater.ts +69 -0
  848. package/src/services/vercel-deploy.ts +73 -0
  849. package/src/skills/active-skill-tools.ts +81 -0
  850. package/src/skills/clawhub.ts +414 -0
  851. package/src/skills/include-graph.ts +146 -0
  852. package/src/skills/managed-store.ts +233 -0
  853. package/src/skills/path-classifier.ts +128 -0
  854. package/src/skills/slash-commands.ts +174 -0
  855. package/src/skills/tool-manifest.ts +165 -0
  856. package/src/skills/version-hash.ts +110 -0
  857. package/src/slack/slack-webhook.ts +61 -0
  858. package/src/subagent/index.ts +19 -0
  859. package/src/subagent/manager.ts +511 -0
  860. package/src/subagent/types.ts +69 -0
  861. package/src/swarm/backend-claude-code.ts +145 -0
  862. package/src/swarm/index.ts +44 -0
  863. package/src/swarm/limits.ts +37 -0
  864. package/src/swarm/orchestrator.ts +279 -0
  865. package/src/swarm/plan-validator.ts +151 -0
  866. package/src/swarm/router-planner.ts +100 -0
  867. package/src/swarm/router-prompts.ts +36 -0
  868. package/src/swarm/synthesizer.ts +62 -0
  869. package/src/swarm/types.ts +62 -0
  870. package/src/swarm/worker-backend.ts +121 -0
  871. package/src/swarm/worker-prompts.ts +79 -0
  872. package/src/swarm/worker-runner.ts +164 -0
  873. package/src/tasks/SPEC.md +139 -0
  874. package/src/tasks/candidate-store.ts +86 -0
  875. package/src/tasks/ephemeral-permissions.ts +48 -0
  876. package/src/tasks/task-compiler.ts +199 -0
  877. package/src/tasks/task-runner.ts +90 -0
  878. package/src/tasks/task-scheduler.ts +21 -0
  879. package/src/tasks/task-store.ts +127 -0
  880. package/src/tasks/tool-sanitizer.ts +36 -0
  881. package/src/tools/apps/definitions.ts +59 -0
  882. package/src/tools/apps/executors.ts +313 -0
  883. package/src/tools/apps/open-proxy.ts +43 -0
  884. package/src/tools/apps/registry.ts +16 -0
  885. package/src/tools/assets/materialize.ts +218 -0
  886. package/src/tools/assets/search.ts +361 -0
  887. package/src/tools/browser/__tests__/auth-cache.test.ts +219 -0
  888. package/src/tools/browser/__tests__/auth-detector.test.ts +362 -0
  889. package/src/tools/browser/__tests__/jit-auth.test.ts +189 -0
  890. package/src/tools/browser/api-map.ts +293 -0
  891. package/src/tools/browser/auth-cache.ts +149 -0
  892. package/src/tools/browser/auth-detector.ts +347 -0
  893. package/src/tools/browser/auto-navigate.ts +270 -0
  894. package/src/tools/browser/browser-execution.ts +980 -0
  895. package/src/tools/browser/browser-handoff.ts +79 -0
  896. package/src/tools/browser/browser-manager.ts +715 -0
  897. package/src/tools/browser/browser-screencast.ts +217 -0
  898. package/src/tools/browser/headless-browser.ts +450 -0
  899. package/src/tools/browser/jit-auth.ts +51 -0
  900. package/src/tools/browser/network-recorder.ts +349 -0
  901. package/src/tools/browser/network-recording-types.ts +49 -0
  902. package/src/tools/browser/recording-store.ts +49 -0
  903. package/src/tools/browser/runtime-check.ts +43 -0
  904. package/src/tools/browser/x-auto-navigate.ts +207 -0
  905. package/src/tools/calls/call-end.ts +67 -0
  906. package/src/tools/calls/call-start.ts +81 -0
  907. package/src/tools/calls/call-status.ts +81 -0
  908. package/src/tools/claude-code/claude-code.ts +428 -0
  909. package/src/tools/computer-use/definitions.ts +443 -0
  910. package/src/tools/computer-use/registry.ts +22 -0
  911. package/src/tools/computer-use/request-computer-control.ts +53 -0
  912. package/src/tools/computer-use/skill-proxy-bridge.ts +28 -0
  913. package/src/tools/credentials/account-registry.ts +127 -0
  914. package/src/tools/credentials/broker-types.ts +107 -0
  915. package/src/tools/credentials/broker.ts +372 -0
  916. package/src/tools/credentials/domain-policy.ts +51 -0
  917. package/src/tools/credentials/host-pattern-match.ts +60 -0
  918. package/src/tools/credentials/metadata-store.ts +335 -0
  919. package/src/tools/credentials/policy-types.ts +52 -0
  920. package/src/tools/credentials/policy-validate.ts +80 -0
  921. package/src/tools/credentials/resolve.ts +122 -0
  922. package/src/tools/credentials/selection.ts +159 -0
  923. package/src/tools/credentials/tool-policy.ts +25 -0
  924. package/src/tools/credentials/vault.ts +657 -0
  925. package/src/tools/document/document-tool.ts +92 -0
  926. package/src/tools/document/editor-template.ts +237 -0
  927. package/src/tools/execution-target.ts +21 -0
  928. package/src/tools/execution-timeout.ts +49 -0
  929. package/src/tools/executor.ts +815 -0
  930. package/src/tools/filesystem/edit.ts +127 -0
  931. package/src/tools/filesystem/fuzzy-match.ts +202 -0
  932. package/src/tools/filesystem/read.ts +71 -0
  933. package/src/tools/filesystem/view-image.ts +199 -0
  934. package/src/tools/filesystem/write.ts +79 -0
  935. package/src/tools/followups/followup_create.ts +76 -0
  936. package/src/tools/followups/followup_list.ts +60 -0
  937. package/src/tools/followups/followup_resolve.ts +56 -0
  938. package/src/tools/host-filesystem/edit.ts +125 -0
  939. package/src/tools/host-filesystem/read.ts +80 -0
  940. package/src/tools/host-filesystem/write.ts +76 -0
  941. package/src/tools/host-terminal/cli-discover.ts +180 -0
  942. package/src/tools/host-terminal/host-shell.ts +191 -0
  943. package/src/tools/memory/definitions.ts +69 -0
  944. package/src/tools/memory/handlers.ts +246 -0
  945. package/src/tools/memory/register.ts +66 -0
  946. package/src/tools/network/__tests__/web-search.test.ts +427 -0
  947. package/src/tools/network/domain-normalize.ts +85 -0
  948. package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
  949. package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
  950. package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
  951. package/src/tools/network/script-proxy/certs.ts +237 -0
  952. package/src/tools/network/script-proxy/connect-tunnel.ts +82 -0
  953. package/src/tools/network/script-proxy/http-forwarder.ts +151 -0
  954. package/src/tools/network/script-proxy/index.ts +28 -0
  955. package/src/tools/network/script-proxy/logging.ts +196 -0
  956. package/src/tools/network/script-proxy/mitm-handler.ts +269 -0
  957. package/src/tools/network/script-proxy/policy.ts +152 -0
  958. package/src/tools/network/script-proxy/router.ts +60 -0
  959. package/src/tools/network/script-proxy/server.ts +136 -0
  960. package/src/tools/network/script-proxy/session-manager.ts +534 -0
  961. package/src/tools/network/script-proxy/types.ts +125 -0
  962. package/src/tools/network/url-safety.ts +227 -0
  963. package/src/tools/network/web-fetch.ts +713 -0
  964. package/src/tools/network/web-search.ts +296 -0
  965. package/src/tools/policy-context.ts +29 -0
  966. package/src/tools/registry.ts +295 -0
  967. package/src/tools/reminder/reminder-store.ts +148 -0
  968. package/src/tools/reminder/reminder.ts +80 -0
  969. package/src/tools/schedule/create.ts +81 -0
  970. package/src/tools/schedule/delete.ts +28 -0
  971. package/src/tools/schedule/list.ts +69 -0
  972. package/src/tools/schedule/update.ts +97 -0
  973. package/src/tools/shared/filesystem/edit-engine.ts +56 -0
  974. package/src/tools/shared/filesystem/errors.ts +85 -0
  975. package/src/tools/shared/filesystem/file-ops-service.ts +215 -0
  976. package/src/tools/shared/filesystem/format-diff.ts +35 -0
  977. package/src/tools/shared/filesystem/path-policy.ts +125 -0
  978. package/src/tools/shared/filesystem/size-guard.ts +41 -0
  979. package/src/tools/shared/filesystem/types.ts +80 -0
  980. package/src/tools/shared/shell-output.ts +52 -0
  981. package/src/tools/skills/delete-managed.ts +60 -0
  982. package/src/tools/skills/load.ts +139 -0
  983. package/src/tools/skills/sandbox-runner.ts +279 -0
  984. package/src/tools/skills/scaffold-managed.ts +150 -0
  985. package/src/tools/skills/script-contract.ts +6 -0
  986. package/src/tools/skills/skill-script-runner.ts +86 -0
  987. package/src/tools/skills/skill-tool-factory.ts +64 -0
  988. package/src/tools/skills/vellum-catalog.ts +217 -0
  989. package/src/tools/subagent/abort.ts +33 -0
  990. package/src/tools/subagent/message.ts +39 -0
  991. package/src/tools/subagent/read.ts +67 -0
  992. package/src/tools/subagent/spawn.ts +46 -0
  993. package/src/tools/subagent/status.ts +45 -0
  994. package/src/tools/swarm/delegate.ts +183 -0
  995. package/src/tools/system/request-permission.ts +98 -0
  996. package/src/tools/system/version.ts +43 -0
  997. package/src/tools/tasks/index.ts +27 -0
  998. package/src/tools/tasks/task-delete.ts +82 -0
  999. package/src/tools/tasks/task-list.ts +44 -0
  1000. package/src/tools/tasks/task-run.ts +97 -0
  1001. package/src/tools/tasks/task-save.ts +47 -0
  1002. package/src/tools/tasks/work-item-enqueue.ts +234 -0
  1003. package/src/tools/tasks/work-item-list.ts +55 -0
  1004. package/src/tools/tasks/work-item-remove.ts +60 -0
  1005. package/src/tools/tasks/work-item-run.ts +78 -0
  1006. package/src/tools/tasks/work-item-update.ts +114 -0
  1007. package/src/tools/terminal/backends/docker.ts +372 -0
  1008. package/src/tools/terminal/backends/native.ts +190 -0
  1009. package/src/tools/terminal/backends/types.ts +26 -0
  1010. package/src/tools/terminal/evaluate-typescript.ts +275 -0
  1011. package/src/tools/terminal/parser.ts +413 -0
  1012. package/src/tools/terminal/safe-env.ts +37 -0
  1013. package/src/tools/terminal/sandbox-diagnostics.ts +149 -0
  1014. package/src/tools/terminal/sandbox.ts +44 -0
  1015. package/src/tools/terminal/shell.ts +257 -0
  1016. package/src/tools/tool-manifest.ts +198 -0
  1017. package/src/tools/types.ts +176 -0
  1018. package/src/tools/ui-surface/definitions.ts +244 -0
  1019. package/src/tools/ui-surface/registry.ts +14 -0
  1020. package/src/tools/watch/screen-watch.ts +130 -0
  1021. package/src/tools/watch/watch-state.ts +119 -0
  1022. package/src/tools/watcher/create.ts +64 -0
  1023. package/src/tools/watcher/delete.ts +27 -0
  1024. package/src/tools/watcher/digest.ts +50 -0
  1025. package/src/tools/watcher/list.ts +60 -0
  1026. package/src/tools/watcher/update.ts +56 -0
  1027. package/src/tools/weather/service.ts +551 -0
  1028. package/src/twitter/client.ts +690 -0
  1029. package/src/twitter/oauth-client.ts +102 -0
  1030. package/src/twitter/router.ts +101 -0
  1031. package/src/twitter/session.ts +91 -0
  1032. package/src/usage/actors.ts +24 -0
  1033. package/src/usage/types.ts +37 -0
  1034. package/src/util/clipboard.ts +33 -0
  1035. package/src/util/content-id.ts +16 -0
  1036. package/src/util/debounce.ts +88 -0
  1037. package/src/util/diff.ts +181 -0
  1038. package/src/util/errors.ts +129 -0
  1039. package/src/util/logger.ts +243 -0
  1040. package/src/util/network-info.ts +47 -0
  1041. package/src/util/platform.ts +632 -0
  1042. package/src/util/pricing.ts +150 -0
  1043. package/src/util/promise-guard.ts +37 -0
  1044. package/src/util/retry.ts +98 -0
  1045. package/src/util/spinner.ts +51 -0
  1046. package/src/util/time.ts +16 -0
  1047. package/src/util/truncate.ts +6 -0
  1048. package/src/util/xml.ts +4 -0
  1049. package/src/version.ts +3 -0
  1050. package/src/watcher/constants.ts +11 -0
  1051. package/src/watcher/engine.ts +199 -0
  1052. package/src/watcher/provider-registry.ts +15 -0
  1053. package/src/watcher/provider-types.ts +48 -0
  1054. package/src/watcher/providers/gmail.ts +198 -0
  1055. package/src/watcher/providers/google-calendar.ts +228 -0
  1056. package/src/watcher/providers/slack.ts +129 -0
  1057. package/src/watcher/watcher-store.ts +419 -0
  1058. package/src/work-items/work-item-runner.ts +171 -0
  1059. package/src/work-items/work-item-store.ts +325 -0
  1060. package/src/workspace/commit-message-enrichment-service.ts +284 -0
  1061. package/src/workspace/commit-message-provider.ts +95 -0
  1062. package/src/workspace/git-service.ts +857 -0
  1063. package/src/workspace/heartbeat-service.ts +345 -0
  1064. package/src/workspace/provider-commit-message-generator.ts +285 -0
  1065. package/src/workspace/top-level-renderer.ts +19 -0
  1066. package/src/workspace/top-level-scanner.ts +41 -0
  1067. package/src/workspace/turn-commit.ts +175 -0
  1068. package/tsconfig.json +21 -0
@@ -0,0 +1,4435 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ const testDir = mkdtempSync(join(tmpdir(), 'memory-regressions-'));
7
+
8
+ mock.module('../util/platform.js', () => ({
9
+ getDataDir: () => testDir,
10
+ isMacOS: () => process.platform === 'darwin',
11
+ isLinux: () => process.platform === 'linux',
12
+ isWindows: () => process.platform === 'win32',
13
+ getSocketPath: () => join(testDir, 'test.sock'),
14
+ getPidPath: () => join(testDir, 'test.pid'),
15
+ getDbPath: () => join(testDir, 'test.db'),
16
+ getLogPath: () => join(testDir, 'test.log'),
17
+ ensureDataDir: () => {},
18
+ }));
19
+
20
+ mock.module('../util/logger.js', () => ({
21
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
22
+ get: () => () => {},
23
+ }),
24
+ }));
25
+
26
+ // Stub the local embedding backend so the real ONNX model (2.5 GB RSS) never
27
+ // loads — avoids a Bun v1.3.9 panic on process exit.
28
+ mock.module('../memory/embedding-local.js', () => ({
29
+ LocalEmbeddingBackend: class {
30
+ readonly provider = 'local' as const;
31
+ readonly model: string;
32
+ constructor(model: string) { this.model = model; }
33
+ async embed(texts: string[]): Promise<number[][]> {
34
+ return texts.map(() => new Array(384).fill(0));
35
+ }
36
+ },
37
+ }));
38
+
39
+ // Mock Qdrant client so semantic search returns empty results instead of
40
+ // throwing "Qdrant client not initialized".
41
+ mock.module('../memory/qdrant-client.js', () => ({
42
+ getQdrantClient: () => ({
43
+ searchWithFilter: async () => [],
44
+ upsertPoints: async () => {},
45
+ deletePoints: async () => {},
46
+ }),
47
+ initQdrantClient: () => {},
48
+ }));
49
+
50
+ import { and, eq } from 'drizzle-orm';
51
+ import { DEFAULT_CONFIG } from '../config/defaults.js';
52
+ import { currentMonthWindow } from '../memory/job-utils.js';
53
+
54
+ // Disable LLM extraction in tests to avoid real API calls and ensure
55
+ // deterministic pattern-based extraction.
56
+ const TEST_CONFIG = {
57
+ ...DEFAULT_CONFIG,
58
+ memory: {
59
+ ...DEFAULT_CONFIG.memory,
60
+ extraction: {
61
+ ...DEFAULT_CONFIG.memory.extraction,
62
+ useLLM: false,
63
+ },
64
+ },
65
+ };
66
+
67
+ mock.module('../config/loader.js', () => ({
68
+ loadConfig: () => TEST_CONFIG,
69
+ getConfig: () => TEST_CONFIG,
70
+ invalidateConfigCache: () => {},
71
+ }));
72
+ import { estimateTextTokens } from '../context/token-estimator.js';
73
+ import { getMemorySystemStatus, requestMemoryBackfill, requestMemoryCleanup } from '../memory/admin.js';
74
+ import { getMemoryCheckpoint } from '../memory/checkpoints.js';
75
+ import { createOrUpdatePendingConflict, getConflictById, resolveConflict } from '../memory/conflict-store.js';
76
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
77
+ import { selectEmbeddingBackend } from '../memory/embedding-backend.js';
78
+ import { upsertEntity, upsertEntityRelation } from '../memory/entity-extractor.js';
79
+ import { getRecentSegmentsForConversation, indexMessageNow } from '../memory/indexer.js';
80
+ import { extractAndUpsertMemoryItemsForMessage } from '../memory/items-extractor.js';
81
+ import {
82
+ claimMemoryJobs,
83
+ enqueueBackfillEntityRelationsJob,
84
+ enqueueCleanupResolvedConflictsJob,
85
+ enqueueCleanupStaleSupersededItemsJob,
86
+ enqueueMemoryJob,
87
+ enqueueResolvePendingConflictsForMessageJob,
88
+ } from '../memory/jobs-store.js';
89
+ import {
90
+ currentWeekWindow,
91
+ maybeEnqueueScheduledCleanupJobs,
92
+ resetCleanupScheduleThrottle,
93
+ resetStaleSweepThrottle,
94
+ runMemoryJobsOnce,
95
+ sweepStaleItems,
96
+ } from '../memory/jobs-worker.js';
97
+ import {
98
+ buildMemoryRecall,
99
+ escapeXmlTags,
100
+ formatAbsoluteTime,
101
+ formatRelativeTime,
102
+ injectMemoryRecallIntoUserMessage,
103
+ injectMemoryRecallAsSeparateMessage,
104
+ searchMemoryItems,
105
+ stripMemoryRecallMessages,
106
+ } from '../memory/retriever.js';
107
+ import { addMessage, createConversation, getConversationMemoryScopeId } from '../memory/conversation-store.js';
108
+ import { backfillJob } from '../memory/job-handlers/backfill.js';
109
+ import { buildConversationSummaryJob, buildGlobalSummaryJob } from '../memory/job-handlers/summarization.js';
110
+ import {
111
+ conversations,
112
+ memoryEmbeddings,
113
+ memoryEntities,
114
+ memoryEntityRelations,
115
+ memoryItemEntities,
116
+ memoryItemConflicts,
117
+ memoryItems,
118
+ memoryItemSources,
119
+ memoryJobs,
120
+ memorySegments,
121
+ memorySummaries,
122
+ messages,
123
+ } from '../memory/schema.js';
124
+
125
+ describe('Memory regressions', () => {
126
+ beforeAll(() => {
127
+ initializeDb();
128
+ });
129
+
130
+ beforeEach(() => {
131
+ const db = getDb();
132
+ db.run('DELETE FROM memory_item_conflicts');
133
+ db.run('DELETE FROM memory_item_entities');
134
+ db.run('DELETE FROM memory_entity_relations');
135
+ db.run('DELETE FROM memory_entities');
136
+ db.run('DELETE FROM memory_item_sources');
137
+ db.run('DELETE FROM memory_embeddings');
138
+ db.run('DELETE FROM memory_summaries');
139
+ db.run('DELETE FROM memory_items');
140
+ db.run('DELETE FROM memory_segment_fts');
141
+ db.run('DELETE FROM memory_segments');
142
+ db.run('DELETE FROM messages');
143
+ db.run('DELETE FROM conversations');
144
+ db.run('DELETE FROM memory_jobs');
145
+ db.run('DELETE FROM memory_checkpoints');
146
+ resetCleanupScheduleThrottle();
147
+ resetStaleSweepThrottle();
148
+ });
149
+
150
+ afterAll(() => {
151
+ resetDb();
152
+ try {
153
+ rmSync(testDir, { recursive: true });
154
+ } catch {
155
+ // best effort cleanup
156
+ }
157
+ });
158
+
159
+ function semanticRecallConfig() {
160
+ return {
161
+ ...DEFAULT_CONFIG,
162
+ memory: {
163
+ ...DEFAULT_CONFIG.memory,
164
+ embeddings: {
165
+ ...DEFAULT_CONFIG.memory.embeddings,
166
+ provider: 'ollama' as const,
167
+ required: true,
168
+ },
169
+ retrieval: {
170
+ ...DEFAULT_CONFIG.memory.retrieval,
171
+ lexicalTopK: 0,
172
+ semanticTopK: 10,
173
+ maxInjectTokens: 2000,
174
+ },
175
+ },
176
+ };
177
+ }
178
+
179
+ // Baseline: indexMessageNow without explicit scopeId defaults to 'default'
180
+ test('baseline: memory segments default to scope "default" when no scopeId given', () => {
181
+ const db = getDb();
182
+ const now = Date.now();
183
+ db.insert(conversations).values({
184
+ id: 'conv-baseline-scope',
185
+ title: null,
186
+ createdAt: now,
187
+ updatedAt: now,
188
+ totalInputTokens: 0,
189
+ totalOutputTokens: 0,
190
+ totalEstimatedCost: 0,
191
+ contextSummary: null,
192
+ contextCompactedMessageCount: 0,
193
+ contextCompactedAt: null,
194
+ }).run();
195
+ db.insert(messages).values({
196
+ id: 'msg-baseline-scope',
197
+ conversationId: 'conv-baseline-scope',
198
+ role: 'user',
199
+ content: JSON.stringify([{ type: 'text', text: 'The user likes dark mode.' }]),
200
+ createdAt: now,
201
+ }).run();
202
+
203
+ // Index without explicit scopeId — should use 'default'
204
+ indexMessageNow({
205
+ messageId: 'msg-baseline-scope',
206
+ conversationId: 'conv-baseline-scope',
207
+ role: 'user',
208
+ content: JSON.stringify([{ type: 'text', text: 'The user likes dark mode.' }]),
209
+ createdAt: now,
210
+ }, DEFAULT_CONFIG.memory);
211
+
212
+ const segs = db.select().from(memorySegments)
213
+ .where(eq(memorySegments.messageId, 'msg-baseline-scope'))
214
+ .all();
215
+
216
+ expect(segs.length).toBeGreaterThan(0);
217
+ for (const seg of segs) {
218
+ expect(seg.scopeId).toBe('default');
219
+ }
220
+ });
221
+
222
+ test('lexical recall accepts punctuation-heavy user queries without degrading', async () => {
223
+ const db = getDb();
224
+ const createdAt = 1_700_000_000_000;
225
+ db.insert(conversations).values({
226
+ id: 'conv-1',
227
+ title: null,
228
+ createdAt,
229
+ updatedAt: createdAt,
230
+ totalInputTokens: 0,
231
+ totalOutputTokens: 0,
232
+ totalEstimatedCost: 0,
233
+ contextSummary: null,
234
+ contextCompactedMessageCount: 0,
235
+ contextCompactedAt: null,
236
+ }).run();
237
+ db.insert(messages).values({
238
+ id: 'msg-1',
239
+ conversationId: 'conv-1',
240
+ role: 'user',
241
+ content: JSON.stringify([{ type: 'text', text: 'error timeout in src index ts' }]),
242
+ createdAt,
243
+ }).run();
244
+ db.run(`
245
+ INSERT INTO memory_segments (
246
+ id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
247
+ ) VALUES (
248
+ 'seg-1', 'msg-1', 'conv-1', 'user', 0, 'error timeout in src index ts', 8, ${createdAt}, ${createdAt}
249
+ )
250
+ `);
251
+
252
+ const config = {
253
+ ...DEFAULT_CONFIG,
254
+ memory: {
255
+ ...DEFAULT_CONFIG.memory,
256
+ embeddings: {
257
+ ...DEFAULT_CONFIG.memory.embeddings,
258
+ required: false,
259
+ },
260
+ },
261
+ };
262
+
263
+ const recall = await buildMemoryRecall('error: timeout src/index.ts foo-bar', 'conv-1', config);
264
+ expect(recall.degraded).toBe(false);
265
+ expect(recall.lexicalHits).toBeGreaterThan(0);
266
+ });
267
+
268
+ test('recall excludes current-turn message ids from injected candidates', async () => {
269
+ const db = getDb();
270
+ const now = 1_700_000_100_000;
271
+ db.insert(conversations).values({
272
+ id: 'conv-exclude',
273
+ title: null,
274
+ createdAt: now,
275
+ updatedAt: now,
276
+ totalInputTokens: 0,
277
+ totalOutputTokens: 0,
278
+ totalEstimatedCost: 0,
279
+ contextSummary: null,
280
+ contextCompactedMessageCount: 0,
281
+ contextCompactedAt: null,
282
+ }).run();
283
+ db.insert(messages).values({
284
+ id: 'msg-old',
285
+ conversationId: 'conv-exclude',
286
+ role: 'user',
287
+ content: JSON.stringify([{ type: 'text', text: 'Remember my timezone is PST.' }]),
288
+ createdAt: now - 10_000,
289
+ }).run();
290
+ db.insert(messages).values({
291
+ id: 'msg-current',
292
+ conversationId: 'conv-exclude',
293
+ role: 'user',
294
+ content: JSON.stringify([{ type: 'text', text: 'What is my timezone again?' }]),
295
+ createdAt: now,
296
+ }).run();
297
+ db.run(`
298
+ INSERT INTO memory_segments (
299
+ id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
300
+ ) VALUES
301
+ ('seg-old', 'msg-old', 'conv-exclude', 'user', 0, 'Remember my timezone is PST.', 7, ${now - 10_000}, ${now - 10_000}),
302
+ ('seg-current', 'msg-current', 'conv-exclude', 'user', 0, 'What is my timezone again?', 7, ${now}, ${now})
303
+ `);
304
+
305
+ const config = {
306
+ ...DEFAULT_CONFIG,
307
+ memory: {
308
+ ...DEFAULT_CONFIG.memory,
309
+ embeddings: {
310
+ ...DEFAULT_CONFIG.memory.embeddings,
311
+ required: false,
312
+ },
313
+ },
314
+ };
315
+
316
+ const recall = await buildMemoryRecall(
317
+ 'timezone',
318
+ 'conv-exclude',
319
+ config,
320
+ { excludeMessageIds: ['msg-current'] },
321
+ );
322
+ expect(recall.injectedText).toContain('Remember my timezone is PST.');
323
+ expect(recall.injectedText).not.toContain('What is my timezone again?');
324
+ });
325
+
326
+ test('memory recall injection remains user-role and is stripped from runtime history', () => {
327
+ const memoryRecallText = '[Memory Recall v1]\n- [item:abc] user prefers concise answers';
328
+ const originalUserMessage = {
329
+ role: 'user' as const,
330
+ content: [{ type: 'text', text: 'Actual user request' }],
331
+ };
332
+ const injected = injectMemoryRecallIntoUserMessage(
333
+ originalUserMessage,
334
+ memoryRecallText,
335
+ );
336
+
337
+ expect(injected.role).toBe('user');
338
+ expect(injected.content[0]).toEqual({
339
+ type: 'text',
340
+ text: memoryRecallText,
341
+ });
342
+
343
+ const cleaned = stripMemoryRecallMessages([injected], memoryRecallText);
344
+ expect(cleaned).toHaveLength(1);
345
+ expect(cleaned[0]).toEqual(originalUserMessage);
346
+ });
347
+
348
+ test('memory recall stripping preserves literal marker text outside the injected block', () => {
349
+ const memoryRecallText = '[Memory Recall v1]\n- [item:abc] user prefers concise answers';
350
+ const literalUserMessage = {
351
+ role: 'user' as const,
352
+ content: [{ type: 'text', text: '[Memory Recall v1] this is user-authored content' }],
353
+ };
354
+ const literalAssistantMessage = {
355
+ role: 'assistant' as const,
356
+ content: [{ type: 'text', text: memoryRecallText }],
357
+ };
358
+ const originalUserTail = {
359
+ role: 'user' as const,
360
+ content: [{ type: 'text', text: 'Actual user request' }],
361
+ };
362
+ const injectedTail = injectMemoryRecallIntoUserMessage(originalUserTail, memoryRecallText);
363
+
364
+ const cleaned = stripMemoryRecallMessages(
365
+ [literalUserMessage, literalAssistantMessage, injectedTail],
366
+ memoryRecallText,
367
+ );
368
+
369
+ expect(cleaned).toHaveLength(3);
370
+ expect(cleaned[0]).toEqual(literalUserMessage);
371
+ expect(cleaned[1]).toEqual(literalAssistantMessage);
372
+ expect(cleaned[2]).toEqual(originalUserTail);
373
+ });
374
+
375
+ test('recall stripping removes last matching block in merged content after deep-repair', () => {
376
+ const memoryRecallText = '[Memory Recall v1]\n- [item:abc] user prefers concise answers';
377
+ // Simulate deep-repair merging two consecutive user messages where both
378
+ // contain the recall text. The injected (active) recall block is the last one.
379
+ const mergedUserMessage = {
380
+ role: 'user' as const,
381
+ content: [
382
+ { type: 'text', text: memoryRecallText },
383
+ { type: 'text', text: 'Earlier user request' },
384
+ { type: 'text', text: memoryRecallText },
385
+ { type: 'text', text: 'Latest user request' },
386
+ ],
387
+ };
388
+
389
+ const cleaned = stripMemoryRecallMessages([mergedUserMessage], memoryRecallText);
390
+ expect(cleaned).toHaveLength(1);
391
+ // The last (active) recall block should be stripped, the first (leaked) one preserved
392
+ expect(cleaned[0].content).toEqual([
393
+ { type: 'text', text: memoryRecallText },
394
+ { type: 'text', text: 'Earlier user request' },
395
+ { type: 'text', text: 'Latest user request' },
396
+ ]);
397
+ });
398
+
399
+ test('separate_context_message injects memory as user+assistant pair before last user message', () => {
400
+ const history = [
401
+ { role: 'user' as const, content: [{ type: 'text', text: 'Hello' }] },
402
+ { role: 'assistant' as const, content: [{ type: 'text', text: 'Hi!' }] },
403
+ { role: 'user' as const, content: [{ type: 'text', text: 'Tell me about X' }] },
404
+ ];
405
+ const recallText = '<memory>Some recalled fact</memory>';
406
+ const result = injectMemoryRecallAsSeparateMessage(history, recallText);
407
+ // Should have 5 messages: original 2 + injected user + injected assistant ack + original last user
408
+ expect(result).toHaveLength(5);
409
+ expect(result[0]).toBe(history[0]);
410
+ expect(result[1]).toBe(history[1]);
411
+ // Injected context message
412
+ expect(result[2].role).toBe('user');
413
+ expect(result[2].content).toEqual([{ type: 'text', text: recallText }]);
414
+ // Assistant acknowledgment
415
+ expect(result[3].role).toBe('assistant');
416
+ expect(result[3].content).toEqual([{ type: 'text', text: '[Memory context loaded.]' }]);
417
+ // Original user message preserved unchanged
418
+ expect(result[4]).toBe(history[2]);
419
+ });
420
+
421
+ test('separate_context_message with empty text is a no-op', () => {
422
+ const history = [
423
+ { role: 'user' as const, content: [{ type: 'text', text: 'Hello' }] },
424
+ ];
425
+ const result = injectMemoryRecallAsSeparateMessage(history, ' ');
426
+ expect(result).toBe(history);
427
+ });
428
+
429
+ test('stripMemoryRecallMessages removes separate_context_message pair', () => {
430
+ const recallText = '<memory>Some recalled fact</memory>';
431
+ const messages = [
432
+ { role: 'user' as const, content: [{ type: 'text', text: 'Hello' }] },
433
+ { role: 'assistant' as const, content: [{ type: 'text', text: 'Hi!' }] },
434
+ // Injected context message pair
435
+ { role: 'user' as const, content: [{ type: 'text', text: recallText }] },
436
+ { role: 'assistant' as const, content: [{ type: 'text', text: '[Memory context loaded.]' }] },
437
+ // Real user message
438
+ { role: 'user' as const, content: [{ type: 'text', text: 'Tell me about X' }] },
439
+ ];
440
+ const cleaned = stripMemoryRecallMessages(messages, recallText);
441
+ expect(cleaned).toHaveLength(3);
442
+ expect(cleaned[0].content[0].text).toBe('Hello');
443
+ expect(cleaned[1].content[0].text).toBe('Hi!');
444
+ expect(cleaned[2].content[0].text).toBe('Tell me about X');
445
+ });
446
+
447
+ test('stripMemoryRecallMessages falls back to prepend_user_block when no separate pair found', () => {
448
+ const recallText = '<memory>Fact</memory>';
449
+ const messages = [
450
+ {
451
+ role: 'user' as const,
452
+ content: [
453
+ { type: 'text', text: recallText },
454
+ { type: 'text', text: 'User query' },
455
+ ],
456
+ },
457
+ ];
458
+ const cleaned = stripMemoryRecallMessages(messages, recallText);
459
+ expect(cleaned).toHaveLength(1);
460
+ expect(cleaned[0].content).toEqual([{ type: 'text', text: 'User query' }]);
461
+ });
462
+
463
+ test('aborting memory recall embedding returns a non-degraded aborted recall result', async () => {
464
+ const originalFetch = globalThis.fetch;
465
+ const controller = new AbortController();
466
+ let seenSignal: AbortSignal | undefined;
467
+
468
+ globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => {
469
+ seenSignal = init?.signal as AbortSignal | undefined;
470
+ return new Promise<Response>((_resolve, reject) => {
471
+ const signal = init?.signal as AbortSignal | undefined;
472
+ if (!signal) {
473
+ reject(new Error('Expected abort signal'));
474
+ return;
475
+ }
476
+ const abortError = new Error('Aborted');
477
+ abortError.name = 'AbortError';
478
+ if (signal.aborted) {
479
+ reject(abortError);
480
+ return;
481
+ }
482
+ signal.addEventListener('abort', () => reject(abortError), { once: true });
483
+ });
484
+ }) as typeof globalThis.fetch;
485
+
486
+ try {
487
+ const recallPromise = buildMemoryRecall(
488
+ 'timezone',
489
+ 'conv-abort',
490
+ semanticRecallConfig(),
491
+ { signal: controller.signal },
492
+ );
493
+ controller.abort();
494
+ const recall = await recallPromise;
495
+ expect(seenSignal).toBe(controller.signal);
496
+ expect(recall.degraded).toBe(false);
497
+ expect(recall.reason).toBe('memory.aborted');
498
+ expect(recall.injectedText).toBe('');
499
+ expect(recall.injectedTokens).toBe(0);
500
+ } finally {
501
+ globalThis.fetch = originalFetch;
502
+ }
503
+ });
504
+
505
+ test('memory item lastSeenAt follows message.createdAt and does not move backwards', async () => {
506
+ const db = getDb();
507
+ db.insert(conversations).values({
508
+ id: 'conv-2',
509
+ title: null,
510
+ createdAt: 1_000,
511
+ updatedAt: 1_000,
512
+ totalInputTokens: 0,
513
+ totalOutputTokens: 0,
514
+ totalEstimatedCost: 0,
515
+ contextSummary: null,
516
+ contextCompactedMessageCount: 0,
517
+ contextCompactedAt: null,
518
+ }).run();
519
+
520
+ db.insert(messages).values({
521
+ id: 'msg-newer',
522
+ conversationId: 'conv-2',
523
+ role: 'user',
524
+ content: JSON.stringify([{ type: 'text', text: 'We decided to use sqlite for local persistence because reliability matters.' }]),
525
+ createdAt: 1_000,
526
+ }).run();
527
+ db.insert(messages).values({
528
+ id: 'msg-older',
529
+ conversationId: 'conv-2',
530
+ role: 'user',
531
+ content: JSON.stringify([{ type: 'text', text: 'We decided to use sqlite for local persistence because reliability matters.' }]),
532
+ createdAt: 500,
533
+ }).run();
534
+
535
+ await extractAndUpsertMemoryItemsForMessage('msg-newer');
536
+ await extractAndUpsertMemoryItemsForMessage('msg-older');
537
+
538
+ const row = db
539
+ .select()
540
+ .from(memoryItems)
541
+ .where(and(eq(memoryItems.kind, 'decision'), eq(memoryItems.status, 'active')))
542
+ .get();
543
+
544
+ expect(row).not.toBeNull();
545
+ expect(row?.lastSeenAt).toBe(1_000);
546
+ });
547
+
548
+ test('memory_save sets verificationState to user_confirmed', async () => {
549
+ const { handleMemorySave } = await import('../tools/memory/handlers.js');
550
+
551
+ const result = await handleMemorySave(
552
+ { statement: 'User explicitly saved this preference', kind: 'preference' },
553
+ DEFAULT_CONFIG,
554
+ 'conv-verify-save',
555
+ 'msg-verify-save',
556
+ );
557
+ expect(result.isError).toBe(false);
558
+
559
+ const db = getDb();
560
+ const items = db.select().from(memoryItems).all();
561
+ const saved = items.find((i) => i.statement === 'User explicitly saved this preference');
562
+ expect(saved).toBeDefined();
563
+ expect(saved!.verificationState).toBe('user_confirmed');
564
+ });
565
+
566
+ test('memory_save in different scopes creates separate items', async () => {
567
+ const { handleMemorySave } = await import('../tools/memory/handlers.js');
568
+
569
+ const sharedArgs = { statement: 'I prefer dark mode', kind: 'preference' };
570
+
571
+ // Save in the default scope
572
+ const r1 = await handleMemorySave(sharedArgs, DEFAULT_CONFIG, 'conv-scope-1', 'msg-scope-1', 'default');
573
+ expect(r1.isError).toBe(false);
574
+ expect(r1.content).toContain('Saved to memory');
575
+
576
+ // Save the identical statement in a private scope
577
+ const r2 = await handleMemorySave(sharedArgs, DEFAULT_CONFIG, 'conv-scope-2', 'msg-scope-2', 'private-abc');
578
+ expect(r2.isError).toBe(false);
579
+ expect(r2.content).toContain('Saved to memory');
580
+
581
+ // Both items should exist with distinct IDs
582
+ const db = getDb();
583
+ const items = db.select().from(memoryItems)
584
+ .where(eq(memoryItems.statement, 'I prefer dark mode'))
585
+ .all();
586
+ expect(items.length).toBe(2);
587
+
588
+ const scopes = new Set(items.map((i) => i.scopeId));
589
+ expect(scopes.has('default')).toBe(true);
590
+ expect(scopes.has('private-abc')).toBe(true);
591
+
592
+ // Saving the same statement again in default scope should dedup (not create a third)
593
+ const r3 = await handleMemorySave(sharedArgs, DEFAULT_CONFIG, 'conv-scope-3', 'msg-scope-3', 'default');
594
+ expect(r3.isError).toBe(false);
595
+ expect(r3.content).toContain('already exists');
596
+
597
+ const afterDedup = db.select().from(memoryItems)
598
+ .where(eq(memoryItems.statement, 'I prefer dark mode'))
599
+ .all();
600
+ expect(afterDedup.length).toBe(2);
601
+ });
602
+
603
+ test('memory_update promotes verificationState to user_confirmed', async () => {
604
+ const db = getDb();
605
+ const now = Date.now();
606
+ const { handleMemoryUpdate } = await import('../tools/memory/handlers.js');
607
+
608
+ // Pre-seed an assistant-inferred item
609
+ db.insert(memoryItems).values({
610
+ id: 'item-update-verify',
611
+ kind: 'fact',
612
+ subject: 'update test',
613
+ statement: 'Original assistant inferred statement',
614
+ status: 'active',
615
+ confidence: 0.6,
616
+ importance: 0.4,
617
+ fingerprint: 'fp-update-verify-original',
618
+ verificationState: 'assistant_inferred',
619
+ firstSeenAt: now,
620
+ lastSeenAt: now,
621
+ lastUsedAt: null,
622
+ }).run();
623
+
624
+ const result = await handleMemoryUpdate(
625
+ { memory_id: 'item-update-verify', statement: 'User corrected statement' },
626
+ DEFAULT_CONFIG,
627
+ );
628
+ expect(result.isError).toBe(false);
629
+
630
+ const updated = db.select().from(memoryItems).where(eq(memoryItems.id, 'item-update-verify')).get();
631
+ expect(updated).toBeDefined();
632
+ expect(updated!.statement).toBe('User corrected statement');
633
+ expect(updated!.verificationState).toBe('user_confirmed');
634
+ });
635
+
636
+ test('private thread cannot update default-scope item by ID', async () => {
637
+ const db = getDb();
638
+ const now = Date.now();
639
+ const { handleMemoryUpdate } = await import('../tools/memory/handlers.js');
640
+
641
+ // Pre-seed an item in the default scope
642
+ db.insert(memoryItems).values({
643
+ id: 'item-default-no-cross',
644
+ kind: 'fact',
645
+ subject: 'cross-scope update',
646
+ statement: 'Original default-scope statement',
647
+ status: 'active',
648
+ confidence: 0.8,
649
+ importance: 0.6,
650
+ fingerprint: 'fp-default-no-cross',
651
+ verificationState: 'assistant_inferred',
652
+ scopeId: 'default',
653
+ firstSeenAt: now,
654
+ lastSeenAt: now,
655
+ lastUsedAt: null,
656
+ }).run();
657
+
658
+ // Attempt to update from a private scope — should fail with "not found"
659
+ const result = await handleMemoryUpdate(
660
+ { memory_id: 'item-default-no-cross', statement: 'Hijacked statement' },
661
+ DEFAULT_CONFIG,
662
+ 'private-thread-xyz',
663
+ );
664
+ expect(result.isError).toBe(true);
665
+ expect(result.content).toContain('not found');
666
+
667
+ // Verify the original item is unchanged
668
+ const item = db.select().from(memoryItems).where(eq(memoryItems.id, 'item-default-no-cross')).get();
669
+ expect(item).toBeDefined();
670
+ expect(item!.statement).toBe('Original default-scope statement');
671
+ });
672
+
673
+ test('standard thread cannot update private-scope item by ID', async () => {
674
+ const db = getDb();
675
+ const now = Date.now();
676
+ const { handleMemoryUpdate } = await import('../tools/memory/handlers.js');
677
+
678
+ // Pre-seed an item in a private scope
679
+ db.insert(memoryItems).values({
680
+ id: 'item-private-no-cross',
681
+ kind: 'preference',
682
+ subject: 'cross-scope update reverse',
683
+ statement: 'Private scope secret preference',
684
+ status: 'active',
685
+ confidence: 0.9,
686
+ importance: 0.7,
687
+ fingerprint: 'fp-private-no-cross',
688
+ verificationState: 'user_confirmed',
689
+ scopeId: 'private-thread-abc',
690
+ firstSeenAt: now,
691
+ lastSeenAt: now,
692
+ lastUsedAt: null,
693
+ }).run();
694
+
695
+ // Attempt to update from the default scope — should fail with "not found"
696
+ const result = await handleMemoryUpdate(
697
+ { memory_id: 'item-private-no-cross', statement: 'Overwritten from default' },
698
+ DEFAULT_CONFIG,
699
+ 'default',
700
+ );
701
+ expect(result.isError).toBe(true);
702
+ expect(result.content).toContain('not found');
703
+
704
+ // Verify the original item is unchanged
705
+ const item = db.select().from(memoryItems).where(eq(memoryItems.id, 'item-private-no-cross')).get();
706
+ expect(item).toBeDefined();
707
+ expect(item!.statement).toBe('Private scope secret preference');
708
+ });
709
+
710
+ test('extracted items from user messages get user_reported verification state', async () => {
711
+ const db = getDb();
712
+ const now = Date.now();
713
+ db.insert(conversations).values({
714
+ id: 'conv-verify-extract',
715
+ title: null,
716
+ createdAt: now,
717
+ updatedAt: now,
718
+ totalInputTokens: 0,
719
+ totalOutputTokens: 0,
720
+ totalEstimatedCost: 0,
721
+ contextSummary: null,
722
+ contextCompactedMessageCount: 0,
723
+ contextCompactedAt: null,
724
+ }).run();
725
+ db.insert(messages).values({
726
+ id: 'msg-verify-user',
727
+ conversationId: 'conv-verify-extract',
728
+ role: 'user',
729
+ content: JSON.stringify([{ type: 'text', text: 'I prefer dark mode for all my editors and terminals.' }]),
730
+ createdAt: now,
731
+ }).run();
732
+
733
+ const upserted = await extractAndUpsertMemoryItemsForMessage('msg-verify-user');
734
+ expect(upserted).toBeGreaterThan(0);
735
+
736
+ const items = db.select().from(memoryItems).all();
737
+ const userItems = items.filter(i => i.verificationState === 'user_reported');
738
+ expect(userItems.length).toBeGreaterThan(0);
739
+ });
740
+
741
+ test('extracted items from assistant messages get assistant_inferred verification state', async () => {
742
+ const db = getDb();
743
+ const now = Date.now();
744
+ db.insert(conversations).values({
745
+ id: 'conv-verify-assistant',
746
+ title: null,
747
+ createdAt: now,
748
+ updatedAt: now,
749
+ totalInputTokens: 0,
750
+ totalOutputTokens: 0,
751
+ totalEstimatedCost: 0,
752
+ contextSummary: null,
753
+ contextCompactedMessageCount: 0,
754
+ contextCompactedAt: null,
755
+ }).run();
756
+ db.insert(messages).values({
757
+ id: 'msg-verify-assistant',
758
+ conversationId: 'conv-verify-assistant',
759
+ role: 'assistant',
760
+ content: JSON.stringify([{ type: 'text', text: 'I noted that you prefer using TypeScript for all your projects.' }]),
761
+ createdAt: now,
762
+ }).run();
763
+
764
+ const upserted = await extractAndUpsertMemoryItemsForMessage('msg-verify-assistant');
765
+ expect(upserted).toBeGreaterThan(0);
766
+
767
+ const items = db.select().from(memoryItems).all();
768
+ const assistantItems = items.filter(i => i.verificationState === 'assistant_inferred');
769
+ expect(assistantItems.length).toBeGreaterThan(0);
770
+ });
771
+
772
+ test('verification state defaults to assistant_inferred for legacy rows', () => {
773
+ const db = getDb();
774
+ const raw = (db as unknown as { $client: { query: (q: string) => { get: (...params: unknown[]) => unknown } } }).$client;
775
+ // Simulate a legacy row without explicit verification_state
776
+ raw.query(`
777
+ INSERT INTO memory_items (id, kind, subject, statement, status, confidence, fingerprint, first_seen_at, last_seen_at)
778
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
779
+ `).get(
780
+ 'item-legacy-verify', 'fact', 'Legacy item', 'This is a legacy item', 'active', 0.5, 'fp-legacy-verify', Date.now(), Date.now(),
781
+ );
782
+
783
+ const item = db.select().from(memoryItems).where(eq(memoryItems.id, 'item-legacy-verify')).get();
784
+ expect(item).toBeDefined();
785
+ expect(item!.verificationState).toBe('assistant_inferred');
786
+ });
787
+
788
+ test('recent segment helper returns newest segments first', () => {
789
+ const db = getDb();
790
+ db.insert(conversations).values({
791
+ id: 'conv-recent',
792
+ title: null,
793
+ createdAt: 2_200,
794
+ updatedAt: 2_200,
795
+ totalInputTokens: 0,
796
+ totalOutputTokens: 0,
797
+ totalEstimatedCost: 0,
798
+ contextSummary: null,
799
+ contextCompactedMessageCount: 0,
800
+ contextCompactedAt: null,
801
+ }).run();
802
+ db.insert(messages).values([
803
+ {
804
+ id: 'msg-recent-1',
805
+ conversationId: 'conv-recent',
806
+ role: 'user',
807
+ content: JSON.stringify([{ type: 'text', text: 'old' }]),
808
+ createdAt: 2_201,
809
+ },
810
+ {
811
+ id: 'msg-recent-2',
812
+ conversationId: 'conv-recent',
813
+ role: 'user',
814
+ content: JSON.stringify([{ type: 'text', text: 'newer' }]),
815
+ createdAt: 2_202,
816
+ },
817
+ {
818
+ id: 'msg-recent-3',
819
+ conversationId: 'conv-recent',
820
+ role: 'user',
821
+ content: JSON.stringify([{ type: 'text', text: 'newest' }]),
822
+ createdAt: 2_203,
823
+ },
824
+ ]).run();
825
+ db.run(`
826
+ INSERT INTO memory_segments (
827
+ id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
828
+ ) VALUES
829
+ ('seg-recent-1', 'msg-recent-1', 'conv-recent', 'user', 0, 'old', 1, 2201, 2201),
830
+ ('seg-recent-2', 'msg-recent-2', 'conv-recent', 'user', 0, 'newer', 1, 2202, 2202),
831
+ ('seg-recent-3', 'msg-recent-3', 'conv-recent', 'user', 0, 'newest', 1, 2203, 2203)
832
+ `);
833
+
834
+ const recent = getRecentSegmentsForConversation('conv-recent', 2);
835
+ expect(recent).toHaveLength(2);
836
+ expect(recent[0]?.id).toBe('seg-recent-3');
837
+ expect(recent[1]?.id).toBe('seg-recent-2');
838
+ });
839
+
840
+ test('weekly window uses UTC boundaries for stable scope keys', () => {
841
+ const window = currentWeekWindow(new Date('2025-01-06T00:30:00.000Z'));
842
+ expect(window.scopeKey).toBe('2025-W02');
843
+ expect(window.startMs).toBe(Date.parse('2025-01-06T00:00:00.000Z'));
844
+ expect(window.endMs).toBe(Date.parse('2025-01-13T00:00:00.000Z'));
845
+ });
846
+
847
+ test('explicit ollama memory embedding provider is honored without extra ollama config', () => {
848
+ const config = {
849
+ ...DEFAULT_CONFIG,
850
+ provider: 'anthropic' as const,
851
+ apiKeys: {},
852
+ memory: {
853
+ ...DEFAULT_CONFIG.memory,
854
+ embeddings: {
855
+ ...DEFAULT_CONFIG.memory.embeddings,
856
+ provider: 'ollama' as const,
857
+ },
858
+ },
859
+ };
860
+
861
+ const selection = selectEmbeddingBackend(config);
862
+ expect(selection.backend?.provider).toBe('ollama');
863
+ expect(selection.reason).toBeNull();
864
+ });
865
+
866
+ test('memory backfill request resumes by default and only restarts when forced', () => {
867
+ const db = getDb();
868
+ const resumeJobId = requestMemoryBackfill();
869
+ const forceJobId = requestMemoryBackfill(true);
870
+
871
+ const resumeRow = db
872
+ .select()
873
+ .from(memoryJobs)
874
+ .where(eq(memoryJobs.id, resumeJobId))
875
+ .get();
876
+ const forceRow = db
877
+ .select()
878
+ .from(memoryJobs)
879
+ .where(eq(memoryJobs.id, forceJobId))
880
+ .get();
881
+
882
+ expect(resumeRow).not.toBeNull();
883
+ expect(forceRow).not.toBeNull();
884
+ expect(JSON.parse(resumeRow?.payload ?? '{}')).toMatchObject({ force: false });
885
+ expect(JSON.parse(forceRow?.payload ?? '{}')).toMatchObject({ force: true });
886
+ });
887
+
888
+ test('relation backfill enqueue is deduped and force upgrades payload', () => {
889
+ const db = getDb();
890
+
891
+ const firstId = enqueueBackfillEntityRelationsJob();
892
+ const secondId = enqueueBackfillEntityRelationsJob();
893
+ expect(secondId).toBe(firstId);
894
+
895
+ const upgradedId = enqueueBackfillEntityRelationsJob(true);
896
+ expect(upgradedId).toBe(firstId);
897
+
898
+ const row = db
899
+ .select()
900
+ .from(memoryJobs)
901
+ .where(eq(memoryJobs.id, firstId))
902
+ .get();
903
+ expect(row).not.toBeUndefined();
904
+ expect(JSON.parse(row?.payload ?? '{}')).toMatchObject({ force: true });
905
+ });
906
+
907
+ test('pending conflict resolver enqueue is deduped by message and scope', () => {
908
+ const db = getDb();
909
+
910
+ const firstId = enqueueResolvePendingConflictsForMessageJob('msg-conflict-1', 'scope-a');
911
+ const secondId = enqueueResolvePendingConflictsForMessageJob('msg-conflict-1', 'scope-a');
912
+ const thirdId = enqueueResolvePendingConflictsForMessageJob('msg-conflict-1', 'scope-b');
913
+
914
+ expect(secondId).toBe(firstId);
915
+ expect(thirdId).not.toBe(firstId);
916
+
917
+ const queued = db
918
+ .select()
919
+ .from(memoryJobs)
920
+ .where(and(
921
+ eq(memoryJobs.type, 'resolve_pending_conflicts_for_message'),
922
+ eq(memoryJobs.status, 'pending'),
923
+ ))
924
+ .all();
925
+ expect(queued).toHaveLength(2);
926
+ });
927
+
928
+ test('background conflict resolver job applies user clarification to pending conflicts', async () => {
929
+ const db = getDb();
930
+ const now = 1_700_001_200_000;
931
+ const originalConflictsEnabled = TEST_CONFIG.memory.conflicts.enabled;
932
+ TEST_CONFIG.memory.conflicts.enabled = true;
933
+
934
+ try {
935
+ db.insert(conversations).values({
936
+ id: 'conv-conflicts-bg',
937
+ title: null,
938
+ createdAt: now,
939
+ updatedAt: now,
940
+ totalInputTokens: 0,
941
+ totalOutputTokens: 0,
942
+ totalEstimatedCost: 0,
943
+ contextSummary: null,
944
+ contextCompactedMessageCount: 0,
945
+ contextCompactedAt: null,
946
+ }).run();
947
+
948
+ db.insert(messages).values({
949
+ id: 'msg-conflicts-bg',
950
+ conversationId: 'conv-conflicts-bg',
951
+ role: 'user',
952
+ content: JSON.stringify([{ type: 'text', text: 'Keep the new MySQL default instead.' }]),
953
+ createdAt: now + 1,
954
+ }).run();
955
+
956
+ db.insert(memoryItems).values([
957
+ {
958
+ id: 'item-conflict-existing',
959
+ kind: 'preference',
960
+ subject: 'database',
961
+ statement: 'Use Postgres by default.',
962
+ status: 'active',
963
+ confidence: 0.8,
964
+ fingerprint: 'fp-conflict-existing',
965
+ verificationState: 'assistant_inferred',
966
+ scopeId: 'scope-conflicts',
967
+ firstSeenAt: now - 10_000,
968
+ lastSeenAt: now - 5_000,
969
+ validFrom: now - 10_000,
970
+ invalidAt: null,
971
+ },
972
+ {
973
+ id: 'item-conflict-candidate',
974
+ kind: 'preference',
975
+ subject: 'database',
976
+ statement: 'Use MySQL by default.',
977
+ status: 'pending_clarification',
978
+ confidence: 0.8,
979
+ fingerprint: 'fp-conflict-candidate',
980
+ verificationState: 'assistant_inferred',
981
+ scopeId: 'scope-conflicts',
982
+ firstSeenAt: now - 9_000,
983
+ lastSeenAt: now - 4_000,
984
+ validFrom: now - 9_000,
985
+ invalidAt: null,
986
+ },
987
+ ]).run();
988
+
989
+ const conflict = createOrUpdatePendingConflict({
990
+ scopeId: 'scope-conflicts',
991
+ existingItemId: 'item-conflict-existing',
992
+ candidateItemId: 'item-conflict-candidate',
993
+ relationship: 'ambiguous_contradiction',
994
+ });
995
+ db.update(memoryItemConflicts)
996
+ .set({ createdAt: now, updatedAt: now })
997
+ .where(eq(memoryItemConflicts.id, conflict.id))
998
+ .run();
999
+
1000
+ enqueueResolvePendingConflictsForMessageJob('msg-conflicts-bg', 'scope-conflicts');
1001
+ const processed = await runMemoryJobsOnce();
1002
+ expect(processed).toBe(1);
1003
+
1004
+ const existing = db
1005
+ .select()
1006
+ .from(memoryItems)
1007
+ .where(eq(memoryItems.id, 'item-conflict-existing'))
1008
+ .get();
1009
+ const candidate = db
1010
+ .select()
1011
+ .from(memoryItems)
1012
+ .where(eq(memoryItems.id, 'item-conflict-candidate'))
1013
+ .get();
1014
+ const updatedConflict = getConflictById(conflict.id);
1015
+
1016
+ expect(existing?.invalidAt).not.toBeNull();
1017
+ expect(existing?.status).toBe('superseded');
1018
+ expect(candidate?.status).toBe('active');
1019
+ expect(updatedConflict?.status).toBe('resolved_keep_candidate');
1020
+ expect(updatedConflict?.resolutionNote).toContain('Background message resolver');
1021
+ } finally {
1022
+ TEST_CONFIG.memory.conflicts.enabled = originalConflictsEnabled;
1023
+ }
1024
+ });
1025
+
1026
+ test('background conflict resolver ignores conflicts created after triggering message', async () => {
1027
+ const db = getDb();
1028
+ const now = 1_700_001_300_000;
1029
+ const originalConflictsEnabled = TEST_CONFIG.memory.conflicts.enabled;
1030
+ TEST_CONFIG.memory.conflicts.enabled = true;
1031
+
1032
+ try {
1033
+ db.insert(conversations).values({
1034
+ id: 'conv-conflicts-age',
1035
+ title: null,
1036
+ createdAt: now,
1037
+ updatedAt: now,
1038
+ totalInputTokens: 0,
1039
+ totalOutputTokens: 0,
1040
+ totalEstimatedCost: 0,
1041
+ contextSummary: null,
1042
+ contextCompactedMessageCount: 0,
1043
+ contextCompactedAt: null,
1044
+ }).run();
1045
+
1046
+ db.insert(messages).values({
1047
+ id: 'msg-conflicts-age',
1048
+ conversationId: 'conv-conflicts-age',
1049
+ role: 'user',
1050
+ content: JSON.stringify([{ type: 'text', text: 'Keep the new Bun runtime instead.' }]),
1051
+ createdAt: now + 1,
1052
+ }).run();
1053
+
1054
+ db.insert(memoryItems).values([
1055
+ {
1056
+ id: 'item-conflict-existing-age',
1057
+ kind: 'preference',
1058
+ subject: 'runtime',
1059
+ statement: 'Use Node.js 20 by default.',
1060
+ status: 'active',
1061
+ confidence: 0.8,
1062
+ fingerprint: 'fp-conflict-existing-age',
1063
+ verificationState: 'assistant_inferred',
1064
+ scopeId: 'scope-conflicts-age',
1065
+ firstSeenAt: now - 10_000,
1066
+ lastSeenAt: now - 5_000,
1067
+ validFrom: now - 10_000,
1068
+ invalidAt: null,
1069
+ },
1070
+ {
1071
+ id: 'item-conflict-candidate-age',
1072
+ kind: 'preference',
1073
+ subject: 'runtime',
1074
+ statement: 'Use Bun by default.',
1075
+ status: 'pending_clarification',
1076
+ confidence: 0.8,
1077
+ fingerprint: 'fp-conflict-candidate-age',
1078
+ verificationState: 'assistant_inferred',
1079
+ scopeId: 'scope-conflicts-age',
1080
+ firstSeenAt: now - 9_000,
1081
+ lastSeenAt: now - 4_000,
1082
+ validFrom: now - 9_000,
1083
+ invalidAt: null,
1084
+ },
1085
+ ]).run();
1086
+
1087
+ const conflict = createOrUpdatePendingConflict({
1088
+ scopeId: 'scope-conflicts-age',
1089
+ existingItemId: 'item-conflict-existing-age',
1090
+ candidateItemId: 'item-conflict-candidate-age',
1091
+ relationship: 'ambiguous_contradiction',
1092
+ });
1093
+ expect(conflict.createdAt).toBeGreaterThan(now + 1);
1094
+
1095
+ enqueueResolvePendingConflictsForMessageJob('msg-conflicts-age', 'scope-conflicts-age');
1096
+ const processed = await runMemoryJobsOnce();
1097
+ expect(processed).toBe(1);
1098
+
1099
+ const existing = db
1100
+ .select()
1101
+ .from(memoryItems)
1102
+ .where(eq(memoryItems.id, 'item-conflict-existing-age'))
1103
+ .get();
1104
+ const candidate = db
1105
+ .select()
1106
+ .from(memoryItems)
1107
+ .where(eq(memoryItems.id, 'item-conflict-candidate-age'))
1108
+ .get();
1109
+ const updatedConflict = getConflictById(conflict.id);
1110
+
1111
+ expect(existing?.status).toBe('active');
1112
+ expect(existing?.invalidAt).toBeNull();
1113
+ expect(candidate?.status).toBe('pending_clarification');
1114
+ expect(updatedConflict?.status).toBe('pending_clarification');
1115
+ expect(updatedConflict?.resolutionNote).toBeNull();
1116
+ } finally {
1117
+ TEST_CONFIG.memory.conflicts.enabled = originalConflictsEnabled;
1118
+ }
1119
+ });
1120
+
1121
+ test('background conflict resolver ignores clarification-like replies with no topical overlap when conflict was never asked', async () => {
1122
+ const db = getDb();
1123
+ const now = 1_700_001_400_000;
1124
+ const originalConflictsEnabled = TEST_CONFIG.memory.conflicts.enabled;
1125
+ TEST_CONFIG.memory.conflicts.enabled = true;
1126
+
1127
+ try {
1128
+ db.insert(conversations).values({
1129
+ id: 'conv-conflicts-unrelated',
1130
+ title: null,
1131
+ createdAt: now,
1132
+ updatedAt: now,
1133
+ totalInputTokens: 0,
1134
+ totalOutputTokens: 0,
1135
+ totalEstimatedCost: 0,
1136
+ contextSummary: null,
1137
+ contextCompactedMessageCount: 0,
1138
+ contextCompactedAt: null,
1139
+ }).run();
1140
+
1141
+ db.insert(messages).values({
1142
+ id: 'msg-conflicts-unrelated',
1143
+ conversationId: 'conv-conflicts-unrelated',
1144
+ role: 'user',
1145
+ content: JSON.stringify([{ type: 'text', text: 'Keep the new one instead.' }]),
1146
+ createdAt: now + 1,
1147
+ }).run();
1148
+
1149
+ db.insert(memoryItems).values([
1150
+ {
1151
+ id: 'item-conflict-existing-unrelated',
1152
+ kind: 'preference',
1153
+ subject: 'database',
1154
+ statement: 'Use Postgres by default.',
1155
+ status: 'active',
1156
+ confidence: 0.8,
1157
+ fingerprint: 'fp-conflict-existing-unrelated',
1158
+ verificationState: 'assistant_inferred',
1159
+ scopeId: 'scope-conflicts-unrelated',
1160
+ firstSeenAt: now - 10_000,
1161
+ lastSeenAt: now - 5_000,
1162
+ validFrom: now - 10_000,
1163
+ invalidAt: null,
1164
+ },
1165
+ {
1166
+ id: 'item-conflict-candidate-unrelated',
1167
+ kind: 'preference',
1168
+ subject: 'database',
1169
+ statement: 'Use MySQL by default.',
1170
+ status: 'pending_clarification',
1171
+ confidence: 0.8,
1172
+ fingerprint: 'fp-conflict-candidate-unrelated',
1173
+ verificationState: 'assistant_inferred',
1174
+ scopeId: 'scope-conflicts-unrelated',
1175
+ firstSeenAt: now - 9_000,
1176
+ lastSeenAt: now - 4_000,
1177
+ validFrom: now - 9_000,
1178
+ invalidAt: null,
1179
+ },
1180
+ ]).run();
1181
+
1182
+ const conflict = createOrUpdatePendingConflict({
1183
+ scopeId: 'scope-conflicts-unrelated',
1184
+ existingItemId: 'item-conflict-existing-unrelated',
1185
+ candidateItemId: 'item-conflict-candidate-unrelated',
1186
+ relationship: 'ambiguous_contradiction',
1187
+ });
1188
+ db.update(memoryItemConflicts)
1189
+ .set({ createdAt: now, updatedAt: now, lastAskedAt: null })
1190
+ .where(eq(memoryItemConflicts.id, conflict.id))
1191
+ .run();
1192
+
1193
+ enqueueResolvePendingConflictsForMessageJob('msg-conflicts-unrelated', 'scope-conflicts-unrelated');
1194
+ const processed = await runMemoryJobsOnce();
1195
+ expect(processed).toBe(1);
1196
+
1197
+ const existing = db
1198
+ .select()
1199
+ .from(memoryItems)
1200
+ .where(eq(memoryItems.id, 'item-conflict-existing-unrelated'))
1201
+ .get();
1202
+ const candidate = db
1203
+ .select()
1204
+ .from(memoryItems)
1205
+ .where(eq(memoryItems.id, 'item-conflict-candidate-unrelated'))
1206
+ .get();
1207
+ const updatedConflict = getConflictById(conflict.id);
1208
+
1209
+ expect(existing?.status).toBe('active');
1210
+ expect(existing?.invalidAt).toBeNull();
1211
+ expect(candidate?.status).toBe('pending_clarification');
1212
+ expect(updatedConflict?.status).toBe('pending_clarification');
1213
+ expect(updatedConflict?.resolutionNote).toBeNull();
1214
+ } finally {
1215
+ TEST_CONFIG.memory.conflicts.enabled = originalConflictsEnabled;
1216
+ }
1217
+ });
1218
+
1219
+ test('background conflict resolver dismisses transient/non-durable conflicts without LLM call', async () => {
1220
+ const db = getDb();
1221
+ const now = 1_700_001_500_000;
1222
+ const originalConflictsEnabled = TEST_CONFIG.memory.conflicts.enabled;
1223
+ TEST_CONFIG.memory.conflicts.enabled = true;
1224
+
1225
+ try {
1226
+ db.insert(conversations).values({
1227
+ id: 'conv-conflicts-transient',
1228
+ title: null,
1229
+ createdAt: now,
1230
+ updatedAt: now,
1231
+ totalInputTokens: 0,
1232
+ totalOutputTokens: 0,
1233
+ totalEstimatedCost: 0,
1234
+ contextSummary: null,
1235
+ contextCompactedMessageCount: 0,
1236
+ contextCompactedAt: null,
1237
+ }).run();
1238
+
1239
+ db.insert(messages).values({
1240
+ id: 'msg-conflicts-transient',
1241
+ conversationId: 'conv-conflicts-transient',
1242
+ role: 'user',
1243
+ content: JSON.stringify([{ type: 'text', text: 'Keep the new one instead.' }]),
1244
+ createdAt: now + 1,
1245
+ }).run();
1246
+
1247
+ // Create a transient conflict: PR tracking statements should be dismissed
1248
+ db.insert(memoryItems).values([
1249
+ {
1250
+ id: 'item-conflict-existing-transient',
1251
+ kind: 'preference',
1252
+ subject: 'pr-tracking',
1253
+ statement: 'Currently tracking PR #42 for review.',
1254
+ status: 'active',
1255
+ confidence: 0.8,
1256
+ fingerprint: 'fp-conflict-existing-transient',
1257
+ verificationState: 'assistant_inferred',
1258
+ scopeId: 'scope-conflicts-transient',
1259
+ firstSeenAt: now - 10_000,
1260
+ lastSeenAt: now - 5_000,
1261
+ validFrom: now - 10_000,
1262
+ invalidAt: null,
1263
+ },
1264
+ {
1265
+ id: 'item-conflict-candidate-transient',
1266
+ kind: 'preference',
1267
+ subject: 'pr-tracking',
1268
+ statement: 'Currently tracking PR #99 for review.',
1269
+ status: 'pending_clarification',
1270
+ confidence: 0.8,
1271
+ fingerprint: 'fp-conflict-candidate-transient',
1272
+ verificationState: 'assistant_inferred',
1273
+ scopeId: 'scope-conflicts-transient',
1274
+ firstSeenAt: now - 9_000,
1275
+ lastSeenAt: now - 4_000,
1276
+ validFrom: now - 9_000,
1277
+ invalidAt: null,
1278
+ },
1279
+ ]).run();
1280
+
1281
+ const conflict = createOrUpdatePendingConflict({
1282
+ scopeId: 'scope-conflicts-transient',
1283
+ existingItemId: 'item-conflict-existing-transient',
1284
+ candidateItemId: 'item-conflict-candidate-transient',
1285
+ relationship: 'ambiguous_contradiction',
1286
+ });
1287
+ db.update(memoryItemConflicts)
1288
+ .set({ createdAt: now, updatedAt: now })
1289
+ .where(eq(memoryItemConflicts.id, conflict.id))
1290
+ .run();
1291
+
1292
+ enqueueResolvePendingConflictsForMessageJob('msg-conflicts-transient', 'scope-conflicts-transient');
1293
+ const processed = await runMemoryJobsOnce();
1294
+ expect(processed).toBe(1);
1295
+
1296
+ const updatedConflict = getConflictById(conflict.id);
1297
+ expect(updatedConflict?.status).toBe('dismissed');
1298
+ expect(updatedConflict?.resolutionNote).toContain('conflict policy');
1299
+
1300
+ // Memory items should remain untouched (no LLM resolution was attempted)
1301
+ const existing = db
1302
+ .select()
1303
+ .from(memoryItems)
1304
+ .where(eq(memoryItems.id, 'item-conflict-existing-transient'))
1305
+ .get();
1306
+ const candidate = db
1307
+ .select()
1308
+ .from(memoryItems)
1309
+ .where(eq(memoryItems.id, 'item-conflict-candidate-transient'))
1310
+ .get();
1311
+ expect(existing?.status).toBe('active');
1312
+ expect(candidate?.status).toBe('pending_clarification');
1313
+ } finally {
1314
+ TEST_CONFIG.memory.conflicts.enabled = originalConflictsEnabled;
1315
+ }
1316
+ });
1317
+
1318
+ test('cleanup job enqueue is deduped and retention overrides upgrade payload', () => {
1319
+ const db = getDb();
1320
+
1321
+ const resolvedFirst = enqueueCleanupResolvedConflictsJob();
1322
+ const resolvedSecond = enqueueCleanupResolvedConflictsJob();
1323
+ expect(resolvedSecond).toBe(resolvedFirst);
1324
+ const resolvedUpgraded = enqueueCleanupResolvedConflictsJob(12_345);
1325
+ expect(resolvedUpgraded).toBe(resolvedFirst);
1326
+
1327
+ const supersededFirst = enqueueCleanupStaleSupersededItemsJob();
1328
+ const supersededSecond = enqueueCleanupStaleSupersededItemsJob();
1329
+ expect(supersededSecond).toBe(supersededFirst);
1330
+ const supersededUpgraded = enqueueCleanupStaleSupersededItemsJob(67_890);
1331
+ expect(supersededUpgraded).toBe(supersededFirst);
1332
+
1333
+ const resolvedRow = db.select().from(memoryJobs).where(eq(memoryJobs.id, resolvedFirst)).get();
1334
+ const supersededRow = db.select().from(memoryJobs).where(eq(memoryJobs.id, supersededFirst)).get();
1335
+ expect(JSON.parse(resolvedRow?.payload ?? '{}')).toMatchObject({ retentionMs: 12_345 });
1336
+ expect(JSON.parse(supersededRow?.payload ?? '{}')).toMatchObject({ retentionMs: 67_890 });
1337
+ });
1338
+
1339
+ test('cleanup job enqueue dedupes against running jobs without mutating payload', () => {
1340
+ const db = getDb();
1341
+
1342
+ const resolvedId = enqueueCleanupResolvedConflictsJob(10_000);
1343
+ const supersededId = enqueueCleanupStaleSupersededItemsJob(20_000);
1344
+
1345
+ db.update(memoryJobs)
1346
+ .set({ status: 'running' })
1347
+ .where(eq(memoryJobs.id, resolvedId))
1348
+ .run();
1349
+ db.update(memoryJobs)
1350
+ .set({ status: 'running' })
1351
+ .where(eq(memoryJobs.id, supersededId))
1352
+ .run();
1353
+
1354
+ const resolvedDedupedId = enqueueCleanupResolvedConflictsJob(11_111);
1355
+ const supersededDedupedId = enqueueCleanupStaleSupersededItemsJob(22_222);
1356
+ expect(resolvedDedupedId).toBe(resolvedId);
1357
+ expect(supersededDedupedId).toBe(supersededId);
1358
+
1359
+ const resolvedRow = db.select().from(memoryJobs).where(eq(memoryJobs.id, resolvedId)).get();
1360
+ const supersededRow = db.select().from(memoryJobs).where(eq(memoryJobs.id, supersededId)).get();
1361
+ expect(JSON.parse(resolvedRow?.payload ?? '{}')).toMatchObject({ retentionMs: 10_000 });
1362
+ expect(JSON.parse(supersededRow?.payload ?? '{}')).toMatchObject({ retentionMs: 20_000 });
1363
+ });
1364
+
1365
+ test('scheduled cleanup enqueue respects throttle and config retention values', () => {
1366
+ const db = getDb();
1367
+ const originalCleanup = { ...TEST_CONFIG.memory.cleanup };
1368
+ TEST_CONFIG.memory.cleanup.enabled = true;
1369
+ TEST_CONFIG.memory.cleanup.enqueueIntervalMs = 1_000;
1370
+ TEST_CONFIG.memory.cleanup.resolvedConflictRetentionMs = 12_345;
1371
+ TEST_CONFIG.memory.cleanup.supersededItemRetentionMs = 67_890;
1372
+
1373
+ try {
1374
+ const first = maybeEnqueueScheduledCleanupJobs(TEST_CONFIG, 5_000);
1375
+ expect(first).toBe(true);
1376
+
1377
+ const tooSoon = maybeEnqueueScheduledCleanupJobs(TEST_CONFIG, 5_500);
1378
+ expect(tooSoon).toBe(false);
1379
+
1380
+ const jobsAfterFirst = db.select().from(memoryJobs).all();
1381
+ const resolvedJob = jobsAfterFirst.find((row) => row.type === 'cleanup_resolved_conflicts');
1382
+ const supersededJob = jobsAfterFirst.find((row) => row.type === 'cleanup_stale_superseded_items');
1383
+ expect(resolvedJob).toBeDefined();
1384
+ expect(supersededJob).toBeDefined();
1385
+ expect(JSON.parse(resolvedJob?.payload ?? '{}')).toMatchObject({ retentionMs: 12_345 });
1386
+ expect(JSON.parse(supersededJob?.payload ?? '{}')).toMatchObject({ retentionMs: 67_890 });
1387
+
1388
+ const secondWindow = maybeEnqueueScheduledCleanupJobs(TEST_CONFIG, 6_500);
1389
+ expect(secondWindow).toBe(true);
1390
+ const jobsAfterSecond = db.select().from(memoryJobs).all();
1391
+ expect(jobsAfterSecond.filter((row) => row.type === 'cleanup_resolved_conflicts').length).toBe(1);
1392
+ expect(jobsAfterSecond.filter((row) => row.type === 'cleanup_stale_superseded_items').length).toBe(1);
1393
+ } finally {
1394
+ TEST_CONFIG.memory.cleanup = originalCleanup;
1395
+ }
1396
+ });
1397
+
1398
+ test('cleanup jobs use config retention defaults when payload retention is missing', async () => {
1399
+ const db = getDb();
1400
+ const now = Date.now();
1401
+ const originalCleanup = { ...TEST_CONFIG.memory.cleanup };
1402
+ TEST_CONFIG.memory.cleanup.resolvedConflictRetentionMs = 10_000;
1403
+ TEST_CONFIG.memory.cleanup.supersededItemRetentionMs = 10_000;
1404
+
1405
+ try {
1406
+ db.insert(memoryItems).values([
1407
+ {
1408
+ id: 'cleanup-config-existing',
1409
+ kind: 'fact',
1410
+ subject: 'stack',
1411
+ statement: 'Use Bun',
1412
+ status: 'active',
1413
+ confidence: 0.8,
1414
+ fingerprint: 'fp-cleanup-config-existing',
1415
+ verificationState: 'assistant_inferred',
1416
+ scopeId: 'default',
1417
+ firstSeenAt: now - 20_000,
1418
+ lastSeenAt: now - 20_000,
1419
+ },
1420
+ {
1421
+ id: 'cleanup-config-candidate',
1422
+ kind: 'fact',
1423
+ subject: 'stack',
1424
+ statement: 'Use Node',
1425
+ status: 'pending_clarification',
1426
+ confidence: 0.8,
1427
+ fingerprint: 'fp-cleanup-config-candidate',
1428
+ verificationState: 'assistant_inferred',
1429
+ scopeId: 'default',
1430
+ firstSeenAt: now - 20_000,
1431
+ lastSeenAt: now - 20_000,
1432
+ },
1433
+ {
1434
+ id: 'cleanup-config-stale-item',
1435
+ kind: 'decision',
1436
+ subject: 'deploy strategy',
1437
+ statement: 'Manual deploy Fridays.',
1438
+ status: 'superseded',
1439
+ confidence: 0.7,
1440
+ fingerprint: 'fp-cleanup-config-stale-item',
1441
+ verificationState: 'assistant_inferred',
1442
+ scopeId: 'default',
1443
+ firstSeenAt: now - 200_000,
1444
+ lastSeenAt: now - 200_000,
1445
+ invalidAt: now - 200_000,
1446
+ },
1447
+ ]).run();
1448
+
1449
+ const conflict = createOrUpdatePendingConflict({
1450
+ scopeId: 'default',
1451
+ existingItemId: 'cleanup-config-existing',
1452
+ candidateItemId: 'cleanup-config-candidate',
1453
+ relationship: 'ambiguous_contradiction',
1454
+ });
1455
+ resolveConflict(conflict.id, { status: 'resolved_keep_existing' });
1456
+ db.run(`
1457
+ UPDATE memory_item_conflicts
1458
+ SET resolved_at = ${now - 100_000}, updated_at = ${now - 100_000}
1459
+ WHERE id = '${conflict.id}'
1460
+ `);
1461
+
1462
+ enqueueMemoryJob('cleanup_resolved_conflicts', {});
1463
+ enqueueMemoryJob('cleanup_stale_superseded_items', {});
1464
+ const processed = await runMemoryJobsOnce();
1465
+ expect(processed).toBe(2);
1466
+
1467
+ const conflictRow = db.select().from(memoryItemConflicts).where(eq(memoryItemConflicts.id, conflict.id)).get();
1468
+ const staleItem = db.select().from(memoryItems).where(eq(memoryItems.id, 'cleanup-config-stale-item')).get();
1469
+ expect(conflictRow).toBeUndefined();
1470
+ expect(staleItem).toBeUndefined();
1471
+ } finally {
1472
+ TEST_CONFIG.memory.cleanup = originalCleanup;
1473
+ }
1474
+ });
1475
+
1476
+ test('cleanup_resolved_conflicts removes stale resolved rows but keeps recent/pending', async () => {
1477
+ const db = getDb();
1478
+ const now = Date.now();
1479
+
1480
+ db.insert(memoryItems).values([
1481
+ {
1482
+ id: 'cleanup-conflict-existing-a',
1483
+ kind: 'fact',
1484
+ subject: 'db',
1485
+ statement: 'Use Postgres.',
1486
+ status: 'active',
1487
+ confidence: 0.8,
1488
+ fingerprint: 'fp-cleanup-conflict-existing-a',
1489
+ verificationState: 'assistant_inferred',
1490
+ scopeId: 'default',
1491
+ firstSeenAt: now - 20_000,
1492
+ lastSeenAt: now - 20_000,
1493
+ },
1494
+ {
1495
+ id: 'cleanup-conflict-candidate-a',
1496
+ kind: 'fact',
1497
+ subject: 'db',
1498
+ statement: 'Use MySQL.',
1499
+ status: 'pending_clarification',
1500
+ confidence: 0.8,
1501
+ fingerprint: 'fp-cleanup-conflict-candidate-a',
1502
+ verificationState: 'assistant_inferred',
1503
+ scopeId: 'default',
1504
+ firstSeenAt: now - 20_000,
1505
+ lastSeenAt: now - 20_000,
1506
+ },
1507
+ {
1508
+ id: 'cleanup-conflict-existing-b',
1509
+ kind: 'fact',
1510
+ subject: 'frontend',
1511
+ statement: 'Use React.',
1512
+ status: 'active',
1513
+ confidence: 0.8,
1514
+ fingerprint: 'fp-cleanup-conflict-existing-b',
1515
+ verificationState: 'assistant_inferred',
1516
+ scopeId: 'default',
1517
+ firstSeenAt: now - 20_000,
1518
+ lastSeenAt: now - 20_000,
1519
+ },
1520
+ {
1521
+ id: 'cleanup-conflict-candidate-b',
1522
+ kind: 'fact',
1523
+ subject: 'frontend',
1524
+ statement: 'Use Vue.',
1525
+ status: 'pending_clarification',
1526
+ confidence: 0.8,
1527
+ fingerprint: 'fp-cleanup-conflict-candidate-b',
1528
+ verificationState: 'assistant_inferred',
1529
+ scopeId: 'default',
1530
+ firstSeenAt: now - 20_000,
1531
+ lastSeenAt: now - 20_000,
1532
+ },
1533
+ {
1534
+ id: 'cleanup-conflict-existing-c',
1535
+ kind: 'fact',
1536
+ subject: 'orm',
1537
+ statement: 'Use Drizzle.',
1538
+ status: 'active',
1539
+ confidence: 0.8,
1540
+ fingerprint: 'fp-cleanup-conflict-existing-c',
1541
+ verificationState: 'assistant_inferred',
1542
+ scopeId: 'default',
1543
+ firstSeenAt: now - 20_000,
1544
+ lastSeenAt: now - 20_000,
1545
+ },
1546
+ {
1547
+ id: 'cleanup-conflict-candidate-c',
1548
+ kind: 'fact',
1549
+ subject: 'orm',
1550
+ statement: 'Use Prisma.',
1551
+ status: 'pending_clarification',
1552
+ confidence: 0.8,
1553
+ fingerprint: 'fp-cleanup-conflict-candidate-c',
1554
+ verificationState: 'assistant_inferred',
1555
+ scopeId: 'default',
1556
+ firstSeenAt: now - 20_000,
1557
+ lastSeenAt: now - 20_000,
1558
+ },
1559
+ ]).run();
1560
+
1561
+ const staleResolved = createOrUpdatePendingConflict({
1562
+ scopeId: 'default',
1563
+ existingItemId: 'cleanup-conflict-existing-a',
1564
+ candidateItemId: 'cleanup-conflict-candidate-a',
1565
+ relationship: 'ambiguous_contradiction',
1566
+ });
1567
+ const pendingConflict = createOrUpdatePendingConflict({
1568
+ scopeId: 'default',
1569
+ existingItemId: 'cleanup-conflict-existing-b',
1570
+ candidateItemId: 'cleanup-conflict-candidate-b',
1571
+ relationship: 'ambiguous_contradiction',
1572
+ });
1573
+ const recentResolved = createOrUpdatePendingConflict({
1574
+ scopeId: 'default',
1575
+ existingItemId: 'cleanup-conflict-existing-c',
1576
+ candidateItemId: 'cleanup-conflict-candidate-c',
1577
+ relationship: 'ambiguous_contradiction',
1578
+ clarificationQuestion: 'Recent resolution row',
1579
+ });
1580
+
1581
+ resolveConflict(staleResolved.id, { status: 'resolved_keep_existing' });
1582
+ resolveConflict(recentResolved.id, { status: 'resolved_keep_candidate' });
1583
+
1584
+ db.run(`
1585
+ UPDATE memory_item_conflicts
1586
+ SET resolved_at = ${now - 100_000}, updated_at = ${now - 100_000}
1587
+ WHERE id = '${staleResolved.id}'
1588
+ `);
1589
+ db.run(`
1590
+ UPDATE memory_item_conflicts
1591
+ SET resolved_at = ${now - 100}, updated_at = ${now - 100}
1592
+ WHERE id = '${recentResolved.id}'
1593
+ `);
1594
+
1595
+ enqueueMemoryJob('cleanup_resolved_conflicts', { retentionMs: 10_000 });
1596
+ const processed = await runMemoryJobsOnce();
1597
+ expect(processed).toBe(1);
1598
+
1599
+ const staleRow = db.select().from(memoryItemConflicts).where(eq(memoryItemConflicts.id, staleResolved.id)).get();
1600
+ const pendingRow = db.select().from(memoryItemConflicts).where(eq(memoryItemConflicts.id, pendingConflict.id)).get();
1601
+ const recentRow = db.select().from(memoryItemConflicts).where(eq(memoryItemConflicts.id, recentResolved.id)).get();
1602
+ expect(staleRow).toBeUndefined();
1603
+ expect(pendingRow?.status).toBe('pending_clarification');
1604
+ expect(recentRow?.status).toBe('resolved_keep_candidate');
1605
+ });
1606
+
1607
+ test('cleanup_stale_superseded_items removes stale superseded rows, embeddings, and entity links', async () => {
1608
+ const db = getDb();
1609
+ const now = Date.now();
1610
+
1611
+ db.insert(memoryItems).values([
1612
+ {
1613
+ id: 'cleanup-stale-item',
1614
+ kind: 'decision',
1615
+ subject: 'deploy strategy',
1616
+ statement: 'Deploy manually every Friday.',
1617
+ status: 'superseded',
1618
+ confidence: 0.7,
1619
+ fingerprint: 'fp-cleanup-stale-item',
1620
+ verificationState: 'assistant_inferred',
1621
+ scopeId: 'default',
1622
+ firstSeenAt: now - 200_000,
1623
+ lastSeenAt: now - 200_000,
1624
+ invalidAt: now - 200_000,
1625
+ },
1626
+ {
1627
+ id: 'cleanup-recent-item',
1628
+ kind: 'decision',
1629
+ subject: 'deploy strategy',
1630
+ statement: 'Deploy continuously via CI.',
1631
+ status: 'superseded',
1632
+ confidence: 0.7,
1633
+ fingerprint: 'fp-cleanup-recent-item',
1634
+ verificationState: 'assistant_inferred',
1635
+ scopeId: 'default',
1636
+ firstSeenAt: now - 200_000,
1637
+ lastSeenAt: now - 200_000,
1638
+ invalidAt: now - 100,
1639
+ },
1640
+ ]).run();
1641
+
1642
+ db.insert(memoryEmbeddings).values([
1643
+ {
1644
+ id: 'cleanup-embed-stale',
1645
+ targetType: 'item',
1646
+ targetId: 'cleanup-stale-item',
1647
+ provider: 'openai',
1648
+ model: 'text-embedding-3-small',
1649
+ dimensions: 3,
1650
+ vectorJson: '[0,0,0]',
1651
+ createdAt: now - 1000,
1652
+ updatedAt: now - 1000,
1653
+ },
1654
+ {
1655
+ id: 'cleanup-embed-recent',
1656
+ targetType: 'item',
1657
+ targetId: 'cleanup-recent-item',
1658
+ provider: 'openai',
1659
+ model: 'text-embedding-3-small',
1660
+ dimensions: 3,
1661
+ vectorJson: '[0,0,0]',
1662
+ createdAt: now - 1000,
1663
+ updatedAt: now - 1000,
1664
+ },
1665
+ ]).run();
1666
+
1667
+ // Create entity links for both items (no FK cascade on this table)
1668
+ db.insert(memoryEntities).values({
1669
+ id: 'cleanup-entity',
1670
+ name: 'Deployment',
1671
+ type: 'concept',
1672
+ aliases: JSON.stringify([]),
1673
+ description: null,
1674
+ firstSeenAt: now - 200_000,
1675
+ lastSeenAt: now - 200_000,
1676
+ mentionCount: 2,
1677
+ }).run();
1678
+ db.insert(memoryItemEntities).values([
1679
+ { memoryItemId: 'cleanup-stale-item', entityId: 'cleanup-entity' },
1680
+ { memoryItemId: 'cleanup-recent-item', entityId: 'cleanup-entity' },
1681
+ ]).run();
1682
+
1683
+ enqueueMemoryJob('cleanup_stale_superseded_items', { retentionMs: 10_000 });
1684
+ const processed = await runMemoryJobsOnce();
1685
+ expect(processed).toBe(1);
1686
+
1687
+ const staleItem = db.select().from(memoryItems).where(eq(memoryItems.id, 'cleanup-stale-item')).get();
1688
+ const recentItem = db.select().from(memoryItems).where(eq(memoryItems.id, 'cleanup-recent-item')).get();
1689
+ const staleEmbedding = db.select().from(memoryEmbeddings).where(eq(memoryEmbeddings.id, 'cleanup-embed-stale')).get();
1690
+ const recentEmbedding = db.select().from(memoryEmbeddings).where(eq(memoryEmbeddings.id, 'cleanup-embed-recent')).get();
1691
+
1692
+ // Entity links for stale item should be removed; recent item's links should remain
1693
+ const staleEntityLinks = db.select().from(memoryItemEntities).where(eq(memoryItemEntities.memoryItemId, 'cleanup-stale-item')).all();
1694
+ const recentEntityLinks = db.select().from(memoryItemEntities).where(eq(memoryItemEntities.memoryItemId, 'cleanup-recent-item')).all();
1695
+
1696
+ expect(staleItem).toBeUndefined();
1697
+ expect(recentItem).toBeDefined();
1698
+ expect(staleEmbedding).toBeUndefined();
1699
+ expect(recentEmbedding).toBeDefined();
1700
+ expect(staleEntityLinks).toHaveLength(0);
1701
+ expect(recentEntityLinks).toHaveLength(1);
1702
+ });
1703
+
1704
+ test('memory admin status reports pending/resolved conflicts and oldest pending age', () => {
1705
+ const db = getDb();
1706
+ const now = Date.now();
1707
+
1708
+ db.insert(memoryItems).values([
1709
+ {
1710
+ id: 'status-conflict-existing',
1711
+ kind: 'fact',
1712
+ subject: 'editor',
1713
+ statement: 'Use Neovim.',
1714
+ status: 'active',
1715
+ confidence: 0.8,
1716
+ fingerprint: 'fp-status-existing',
1717
+ verificationState: 'assistant_inferred',
1718
+ scopeId: 'default',
1719
+ firstSeenAt: now - 10_000,
1720
+ lastSeenAt: now - 10_000,
1721
+ },
1722
+ {
1723
+ id: 'status-conflict-candidate',
1724
+ kind: 'fact',
1725
+ subject: 'editor',
1726
+ statement: 'Use VS Code.',
1727
+ status: 'pending_clarification',
1728
+ confidence: 0.8,
1729
+ fingerprint: 'fp-status-candidate',
1730
+ verificationState: 'assistant_inferred',
1731
+ scopeId: 'default',
1732
+ firstSeenAt: now - 10_000,
1733
+ lastSeenAt: now - 10_000,
1734
+ },
1735
+ {
1736
+ id: 'status-conflict-existing-2',
1737
+ kind: 'fact',
1738
+ subject: 'shell',
1739
+ statement: 'Use zsh.',
1740
+ status: 'active',
1741
+ confidence: 0.8,
1742
+ fingerprint: 'fp-status-existing-2',
1743
+ verificationState: 'assistant_inferred',
1744
+ scopeId: 'default',
1745
+ firstSeenAt: now - 10_000,
1746
+ lastSeenAt: now - 10_000,
1747
+ },
1748
+ {
1749
+ id: 'status-conflict-candidate-2',
1750
+ kind: 'fact',
1751
+ subject: 'shell',
1752
+ statement: 'Use fish.',
1753
+ status: 'pending_clarification',
1754
+ confidence: 0.8,
1755
+ fingerprint: 'fp-status-candidate-2',
1756
+ verificationState: 'assistant_inferred',
1757
+ scopeId: 'default',
1758
+ firstSeenAt: now - 10_000,
1759
+ lastSeenAt: now - 10_000,
1760
+ },
1761
+ ]).run();
1762
+
1763
+ const pending = createOrUpdatePendingConflict({
1764
+ scopeId: 'default',
1765
+ existingItemId: 'status-conflict-existing',
1766
+ candidateItemId: 'status-conflict-candidate',
1767
+ relationship: 'ambiguous_contradiction',
1768
+ });
1769
+ const resolved = createOrUpdatePendingConflict({
1770
+ scopeId: 'default',
1771
+ existingItemId: 'status-conflict-existing-2',
1772
+ candidateItemId: 'status-conflict-candidate-2',
1773
+ relationship: 'ambiguous_contradiction',
1774
+ clarificationQuestion: 'resolved-row',
1775
+ });
1776
+ resolveConflict(resolved.id, { status: 'resolved_merge' });
1777
+
1778
+ db.run(`UPDATE memory_item_conflicts SET created_at = ${now - 5_000} WHERE id = '${pending.id}'`);
1779
+
1780
+ const status = getMemorySystemStatus();
1781
+ expect(status.conflicts.pending).toBe(1);
1782
+ expect(status.conflicts.resolved).toBe(1);
1783
+ expect(status.conflicts.oldestPendingAgeMs).not.toBeNull();
1784
+ expect((status.conflicts.oldestPendingAgeMs ?? 0) >= 4_000).toBe(true);
1785
+ expect(status.cleanup.resolvedBacklog).toBe(0);
1786
+ expect(status.cleanup.supersededBacklog).toBe(0);
1787
+ expect(status.cleanup.resolvedCompleted24h).toBe(0);
1788
+ expect(status.cleanup.supersededCompleted24h).toBe(0);
1789
+ });
1790
+
1791
+ test('memory admin status reports cleanup backlog and 24h throughput metrics', () => {
1792
+ const db = getDb();
1793
+ const now = Date.now();
1794
+ const yesterday = now - 20 * 60 * 60 * 1000;
1795
+ const old = now - 40 * 60 * 60 * 1000;
1796
+
1797
+ db.insert(memoryJobs).values([
1798
+ {
1799
+ id: 'cleanup-status-pending-resolved',
1800
+ type: 'cleanup_resolved_conflicts',
1801
+ payload: '{}',
1802
+ status: 'pending',
1803
+ attempts: 0,
1804
+ deferrals: 0,
1805
+ runAfter: now,
1806
+ lastError: null,
1807
+ createdAt: now,
1808
+ updatedAt: now,
1809
+ },
1810
+ {
1811
+ id: 'cleanup-status-running-superseded',
1812
+ type: 'cleanup_stale_superseded_items',
1813
+ payload: '{}',
1814
+ status: 'running',
1815
+ attempts: 0,
1816
+ deferrals: 0,
1817
+ runAfter: now,
1818
+ lastError: null,
1819
+ createdAt: now,
1820
+ updatedAt: now,
1821
+ },
1822
+ {
1823
+ id: 'cleanup-status-completed-resolved-recent',
1824
+ type: 'cleanup_resolved_conflicts',
1825
+ payload: '{}',
1826
+ status: 'completed',
1827
+ attempts: 1,
1828
+ deferrals: 0,
1829
+ runAfter: yesterday,
1830
+ lastError: null,
1831
+ createdAt: yesterday,
1832
+ updatedAt: yesterday,
1833
+ },
1834
+ {
1835
+ id: 'cleanup-status-completed-superseded-recent',
1836
+ type: 'cleanup_stale_superseded_items',
1837
+ payload: '{}',
1838
+ status: 'completed',
1839
+ attempts: 1,
1840
+ deferrals: 0,
1841
+ runAfter: yesterday,
1842
+ lastError: null,
1843
+ createdAt: yesterday,
1844
+ updatedAt: yesterday,
1845
+ },
1846
+ {
1847
+ id: 'cleanup-status-completed-resolved-old',
1848
+ type: 'cleanup_resolved_conflicts',
1849
+ payload: '{}',
1850
+ status: 'completed',
1851
+ attempts: 1,
1852
+ deferrals: 0,
1853
+ runAfter: old,
1854
+ lastError: null,
1855
+ createdAt: old,
1856
+ updatedAt: old,
1857
+ },
1858
+ ]).run();
1859
+
1860
+ const status = getMemorySystemStatus();
1861
+ expect(status.cleanup.resolvedBacklog).toBe(1);
1862
+ expect(status.cleanup.supersededBacklog).toBe(1);
1863
+ expect(status.cleanup.resolvedCompleted24h).toBe(1);
1864
+ expect(status.cleanup.supersededCompleted24h).toBe(1);
1865
+ });
1866
+
1867
+ test('requestMemoryCleanup queues both cleanup job types', () => {
1868
+ const db = getDb();
1869
+ const queued = requestMemoryCleanup(9_999);
1870
+ expect(queued.resolvedConflictsJobId).toBeTruthy();
1871
+ expect(queued.staleSupersededItemsJobId).toBeTruthy();
1872
+
1873
+ const resolvedRow = db.select().from(memoryJobs).where(eq(memoryJobs.id, queued.resolvedConflictsJobId)).get();
1874
+ const supersededRow = db.select().from(memoryJobs).where(eq(memoryJobs.id, queued.staleSupersededItemsJobId)).get();
1875
+ expect(resolvedRow?.type).toBe('cleanup_resolved_conflicts');
1876
+ expect(supersededRow?.type).toBe('cleanup_stale_superseded_items');
1877
+ });
1878
+
1879
+ test('relation backfill advances checkpoints in deterministic batches', async () => {
1880
+ const db = getDb();
1881
+ const now = 1_700_001_000_000;
1882
+ const originalEnabled = TEST_CONFIG.memory.entity.extractRelations.enabled;
1883
+ const originalBatchSize = TEST_CONFIG.memory.entity.extractRelations.backfillBatchSize;
1884
+ TEST_CONFIG.memory.entity.extractRelations.enabled = true;
1885
+ TEST_CONFIG.memory.entity.extractRelations.backfillBatchSize = 2;
1886
+
1887
+ try {
1888
+ db.insert(conversations).values({
1889
+ id: 'conv-rel-backfill',
1890
+ title: null,
1891
+ createdAt: now,
1892
+ updatedAt: now,
1893
+ totalInputTokens: 0,
1894
+ totalOutputTokens: 0,
1895
+ totalEstimatedCost: 0,
1896
+ contextSummary: null,
1897
+ contextCompactedMessageCount: 0,
1898
+ contextCompactedAt: null,
1899
+ }).run();
1900
+
1901
+ db.insert(messages).values([
1902
+ {
1903
+ id: 'msg-rel-backfill-1',
1904
+ conversationId: 'conv-rel-backfill',
1905
+ role: 'user',
1906
+ content: JSON.stringify([{ type: 'text', text: 'Project Atlas uses Qdrant for memory search.' }]),
1907
+ createdAt: now + 1,
1908
+ },
1909
+ {
1910
+ id: 'msg-rel-backfill-2',
1911
+ conversationId: 'conv-rel-backfill',
1912
+ role: 'user',
1913
+ content: JSON.stringify([{ type: 'text', text: 'Atlas collaborates with Orion.' }]),
1914
+ createdAt: now + 2,
1915
+ },
1916
+ {
1917
+ id: 'msg-rel-backfill-3',
1918
+ conversationId: 'conv-rel-backfill',
1919
+ role: 'user',
1920
+ content: JSON.stringify([{ type: 'text', text: 'Orion depends on Redis caching.' }]),
1921
+ createdAt: now + 3,
1922
+ },
1923
+ ]).run();
1924
+
1925
+ enqueueBackfillEntityRelationsJob(true);
1926
+
1927
+ const firstProcessed = await runMemoryJobsOnce();
1928
+ expect(firstProcessed).toBe(1);
1929
+ expect(getMemoryCheckpoint('memory:relation_backfill:last_created_at')).toBe(String(now + 2));
1930
+ expect(getMemoryCheckpoint('memory:relation_backfill:last_message_id')).toBe('msg-rel-backfill-2');
1931
+
1932
+ db.run(`DELETE FROM memory_jobs WHERE type = 'extract_entities' AND status = 'pending'`);
1933
+
1934
+ const secondProcessed = await runMemoryJobsOnce();
1935
+ expect(secondProcessed).toBe(1);
1936
+ expect(getMemoryCheckpoint('memory:relation_backfill:last_created_at')).toBe(String(now + 3));
1937
+ expect(getMemoryCheckpoint('memory:relation_backfill:last_message_id')).toBe('msg-rel-backfill-3');
1938
+
1939
+ const pendingBackfill = db
1940
+ .select()
1941
+ .from(memoryJobs)
1942
+ .where(and(eq(memoryJobs.type, 'backfill_entity_relations'), eq(memoryJobs.status, 'pending')))
1943
+ .all();
1944
+ expect(pendingBackfill).toHaveLength(0);
1945
+ } finally {
1946
+ TEST_CONFIG.memory.entity.extractRelations.enabled = originalEnabled;
1947
+ TEST_CONFIG.memory.entity.extractRelations.backfillBatchSize = originalBatchSize;
1948
+ }
1949
+ });
1950
+
1951
+ test('memory recall token budgeting includes recall marker overhead', async () => {
1952
+ const db = getDb();
1953
+ const createdAt = 1_700_000_300_000;
1954
+ db.insert(conversations).values({
1955
+ id: 'conv-budget',
1956
+ title: null,
1957
+ createdAt,
1958
+ updatedAt: createdAt,
1959
+ totalInputTokens: 0,
1960
+ totalOutputTokens: 0,
1961
+ totalEstimatedCost: 0,
1962
+ contextSummary: null,
1963
+ contextCompactedMessageCount: 0,
1964
+ contextCompactedAt: null,
1965
+ }).run();
1966
+ db.insert(messages).values({
1967
+ id: 'msg-budget',
1968
+ conversationId: 'conv-budget',
1969
+ role: 'user',
1970
+ content: JSON.stringify([{ type: 'text', text: 'remember budget token sentinel' }]),
1971
+ createdAt,
1972
+ }).run();
1973
+ db.run(`
1974
+ INSERT INTO memory_segments (
1975
+ id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
1976
+ ) VALUES (
1977
+ 'seg-budget', 'msg-budget', 'conv-budget', 'user', 0, 'remember budget token sentinel', 6, ${createdAt}, ${createdAt}
1978
+ )
1979
+ `);
1980
+
1981
+ const candidateLine = '- <kind>segment:seg-budget</kind> remember budget token sentinel';
1982
+ const lineOnlyTokens = estimateTextTokens(candidateLine);
1983
+ const fullRecallTokens = estimateTextTokens(
1984
+ '<memory source="long_term_memory" confidence="approximate">\n' +
1985
+ `## Relevant Context\n${candidateLine}\n</memory>`,
1986
+ );
1987
+ expect(fullRecallTokens).toBeGreaterThan(lineOnlyTokens);
1988
+
1989
+ const config = {
1990
+ ...DEFAULT_CONFIG,
1991
+ memory: {
1992
+ ...DEFAULT_CONFIG.memory,
1993
+ embeddings: {
1994
+ ...DEFAULT_CONFIG.memory.embeddings,
1995
+ required: false,
1996
+ },
1997
+ retrieval: {
1998
+ ...DEFAULT_CONFIG.memory.retrieval,
1999
+ maxInjectTokens: lineOnlyTokens,
2000
+ },
2001
+ },
2002
+ };
2003
+
2004
+ const recall = await buildMemoryRecall('budget sentinel', 'conv-budget', config);
2005
+ expect(recall.injectedText).toBe('');
2006
+ expect(recall.injectedTokens).toBe(0);
2007
+ });
2008
+
2009
+ test('memory recall respects maxInjectTokensOverride when provided', async () => {
2010
+ const db = getDb();
2011
+ const createdAt = 1_700_000_301_000;
2012
+ db.insert(conversations).values({
2013
+ id: 'conv-budget-override',
2014
+ title: null,
2015
+ createdAt,
2016
+ updatedAt: createdAt,
2017
+ totalInputTokens: 0,
2018
+ totalOutputTokens: 0,
2019
+ totalEstimatedCost: 0,
2020
+ contextSummary: null,
2021
+ contextCompactedMessageCount: 0,
2022
+ contextCompactedAt: null,
2023
+ }).run();
2024
+
2025
+ for (let i = 0; i < 4; i++) {
2026
+ const msgId = `msg-budget-override-${i}`;
2027
+ const segId = `seg-budget-override-${i}`;
2028
+ const text = `budget override sentinel item ${i} with enough text to exceed tiny limits`;
2029
+ db.insert(messages).values({
2030
+ id: msgId,
2031
+ conversationId: 'conv-budget-override',
2032
+ role: 'user',
2033
+ content: JSON.stringify([{ type: 'text', text }]),
2034
+ createdAt: createdAt + i,
2035
+ }).run();
2036
+ db.run(`
2037
+ INSERT INTO memory_segments (
2038
+ id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
2039
+ ) VALUES (
2040
+ '${segId}', '${msgId}', 'conv-budget-override', 'user', 0, '${text}', 20, ${createdAt + i}, ${createdAt + i}
2041
+ )
2042
+ `);
2043
+ }
2044
+
2045
+ const config = {
2046
+ ...DEFAULT_CONFIG,
2047
+ memory: {
2048
+ ...DEFAULT_CONFIG.memory,
2049
+ embeddings: {
2050
+ ...DEFAULT_CONFIG.memory.embeddings,
2051
+ provider: 'openai' as const,
2052
+ required: false,
2053
+ },
2054
+ retrieval: {
2055
+ ...DEFAULT_CONFIG.memory.retrieval,
2056
+ maxInjectTokens: 5000,
2057
+ lexicalTopK: 10,
2058
+ },
2059
+ },
2060
+ };
2061
+
2062
+ const override = 120;
2063
+ const recall = await buildMemoryRecall(
2064
+ 'budget override sentinel',
2065
+ 'conv-budget-override',
2066
+ config,
2067
+ { maxInjectTokensOverride: override },
2068
+ );
2069
+ expect(recall.injectedTokens).toBeLessThanOrEqual(override);
2070
+ });
2071
+
2072
+ test('claimMemoryJobs only returns rows it actually claimed', () => {
2073
+ const db = getDb();
2074
+ const jobId = enqueueMemoryJob('build_conversation_summary', { conversationId: 'conv-lock' });
2075
+ db.run(`
2076
+ CREATE TEMP TRIGGER memory_jobs_claim_ignore
2077
+ BEFORE UPDATE ON memory_jobs
2078
+ WHEN NEW.status = 'running' AND OLD.id = '${jobId}'
2079
+ BEGIN
2080
+ SELECT RAISE(IGNORE);
2081
+ END;
2082
+ `);
2083
+
2084
+ try {
2085
+ const claimed = claimMemoryJobs(10);
2086
+ expect(claimed).toHaveLength(0);
2087
+ const row = db
2088
+ .select()
2089
+ .from(memoryJobs)
2090
+ .where(eq(memoryJobs.id, jobId))
2091
+ .get();
2092
+ expect(row?.status).toBe('pending');
2093
+ } finally {
2094
+ db.run('DROP TRIGGER IF EXISTS memory_jobs_claim_ignore');
2095
+ }
2096
+ });
2097
+
2098
+ test('formatAbsoluteTime returns YYYY-MM-DD HH:mm TZ format', () => {
2099
+ // Use a fixed epoch-ms value; the rendered string depends on the local timezone,
2100
+ // so we verify the structural format rather than exact values.
2101
+ const epochMs = 1_707_850_200_000; // 2024-02-13 in UTC
2102
+ const result = formatAbsoluteTime(epochMs);
2103
+
2104
+ // Should match pattern: YYYY-MM-DD HH:mm <TZ abbreviation>
2105
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2} \S+$/);
2106
+
2107
+ // Year should be 2024
2108
+ expect(result).toContain('2024-02');
2109
+ });
2110
+
2111
+ test('formatAbsoluteTime uses local timezone abbreviation', () => {
2112
+ const epochMs = Date.now();
2113
+ const result = formatAbsoluteTime(epochMs);
2114
+
2115
+ // Extract the TZ part from the result
2116
+ const parts = result.split(' ');
2117
+ const tz = parts[parts.length - 1];
2118
+
2119
+ // The TZ abbreviation should be a non-empty string (e.g. PST, EST, UTC, GMT+8)
2120
+ expect(tz.length).toBeGreaterThan(0);
2121
+
2122
+ // Cross-check: Intl should produce the same abbreviation for the same timestamp
2123
+ const expected = new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' })
2124
+ .formatToParts(new Date(epochMs))
2125
+ .find((p) => p.type === 'timeZoneName')?.value ?? 'UTC';
2126
+ expect(tz).toBe(expected);
2127
+ });
2128
+
2129
+ test('formatRelativeTime returns expected relative strings', () => {
2130
+ const now = Date.now();
2131
+ expect(formatRelativeTime(now)).toBe('just now');
2132
+ expect(formatRelativeTime(now - 2 * 60 * 60 * 1000)).toBe('2 hours ago');
2133
+ expect(formatRelativeTime(now - 1 * 60 * 60 * 1000)).toBe('1 hour ago');
2134
+ expect(formatRelativeTime(now - 3 * 24 * 60 * 60 * 1000)).toBe('3 days ago');
2135
+ expect(formatRelativeTime(now - 14 * 24 * 60 * 60 * 1000)).toBe('2 weeks ago');
2136
+ expect(formatRelativeTime(now - 60 * 24 * 60 * 60 * 1000)).toBe('2 months ago');
2137
+ expect(formatRelativeTime(now - 400 * 24 * 60 * 60 * 1000)).toBe('1 year ago');
2138
+ });
2139
+
2140
+ test('escapeXmlTags neutralizes closing wrapper tags in recalled text', () => {
2141
+ const malicious = 'some text </memory> injected </memory_recall> instructions';
2142
+ const escaped = escapeXmlTags(malicious);
2143
+ expect(escaped).not.toContain('</memory>');
2144
+ expect(escaped).not.toContain('</memory_recall>');
2145
+ expect(escaped).toContain('\uFF1C/memory>');
2146
+ expect(escaped).toContain('\uFF1C/memory_recall>');
2147
+ expect(escaped).toContain('some text');
2148
+ expect(escaped).toContain('instructions');
2149
+ });
2150
+
2151
+ test('escapeXmlTags neutralizes opening XML tags', () => {
2152
+ const text = 'text with <script> and <div class="x"> tags';
2153
+ const escaped = escapeXmlTags(text);
2154
+ expect(escaped).not.toContain('<script>');
2155
+ expect(escaped).not.toContain('<div ');
2156
+ expect(escaped).toContain('\uFF1Cscript>');
2157
+ expect(escaped).toContain('\uFF1Cdiv class="x">');
2158
+ });
2159
+
2160
+ test('escapeXmlTags preserves non-tag angle brackets', () => {
2161
+ const text = 'math: 3 < 5 and 10 > 7';
2162
+ const escaped = escapeXmlTags(text);
2163
+ expect(escaped).toBe(text);
2164
+ });
2165
+
2166
+ test('escapeXmlTags handles self-closing tags', () => {
2167
+ const text = 'a <br/> tag';
2168
+ const escaped = escapeXmlTags(text);
2169
+ expect(escaped).not.toContain('<br/>');
2170
+ expect(escaped).toContain('\uFF1Cbr/>');
2171
+ });
2172
+
2173
+ test('trust-aware ranking: user_confirmed item outranks assistant_inferred with equal relevance', async () => {
2174
+ const db = getDb();
2175
+ const now = Date.now();
2176
+
2177
+ // Insert two memory items with identical text, confidence, importance, and timestamps
2178
+ // but different verification states
2179
+ db.insert(memoryItems).values([
2180
+ {
2181
+ id: 'item-trust-confirmed',
2182
+ kind: 'fact',
2183
+ subject: 'trust ranking test',
2184
+ statement: 'The user prefers dark mode for all applications',
2185
+ status: 'active',
2186
+ confidence: 0.8,
2187
+ importance: 0.5,
2188
+ fingerprint: 'fp-trust-confirmed',
2189
+ firstSeenAt: now,
2190
+ lastSeenAt: now,
2191
+ accessCount: 0,
2192
+ verificationState: 'user_confirmed',
2193
+ },
2194
+ {
2195
+ id: 'item-trust-inferred',
2196
+ kind: 'fact',
2197
+ subject: 'trust ranking test',
2198
+ statement: 'The user prefers dark mode for all editors',
2199
+ status: 'active',
2200
+ confidence: 0.8,
2201
+ importance: 0.5,
2202
+ fingerprint: 'fp-trust-inferred',
2203
+ firstSeenAt: now,
2204
+ lastSeenAt: now,
2205
+ accessCount: 0,
2206
+ verificationState: 'assistant_inferred',
2207
+ },
2208
+ ]).run();
2209
+
2210
+ const config = {
2211
+ ...DEFAULT_CONFIG,
2212
+ memory: {
2213
+ ...DEFAULT_CONFIG.memory,
2214
+ embeddings: {
2215
+ ...DEFAULT_CONFIG.memory.embeddings,
2216
+ required: false,
2217
+ },
2218
+ },
2219
+ };
2220
+
2221
+ const recall = await buildMemoryRecall('dark mode', 'conv-trust-test', config);
2222
+
2223
+ // Both items should be found (directItemSearch matches on "dark" and "mode")
2224
+ const confirmed = recall.topCandidates.find((c) => c.key === 'item:item-trust-confirmed');
2225
+ const inferred = recall.topCandidates.find((c) => c.key === 'item:item-trust-inferred');
2226
+ expect(confirmed).toBeDefined();
2227
+ expect(inferred).toBeDefined();
2228
+
2229
+ // user_confirmed (weight 1.0) should have a higher finalScore than assistant_inferred (weight 0.7)
2230
+ expect(confirmed!.finalScore).toBeGreaterThan(inferred!.finalScore);
2231
+ });
2232
+
2233
+ test('trust-aware ranking: user_reported item outranks assistant_inferred', async () => {
2234
+ const db = getDb();
2235
+ const now = Date.now();
2236
+
2237
+ db.insert(memoryItems).values([
2238
+ {
2239
+ id: 'item-trust-reported',
2240
+ kind: 'fact',
2241
+ subject: 'trust ranking reported',
2242
+ statement: 'The user uses vim keybindings in their editor',
2243
+ status: 'active',
2244
+ confidence: 0.8,
2245
+ importance: 0.5,
2246
+ fingerprint: 'fp-trust-reported',
2247
+ firstSeenAt: now,
2248
+ lastSeenAt: now,
2249
+ accessCount: 0,
2250
+ verificationState: 'user_reported',
2251
+ },
2252
+ {
2253
+ id: 'item-trust-inferred2',
2254
+ kind: 'fact',
2255
+ subject: 'trust ranking inferred',
2256
+ statement: 'The user uses vim keybindings in their terminal',
2257
+ status: 'active',
2258
+ confidence: 0.8,
2259
+ importance: 0.5,
2260
+ fingerprint: 'fp-trust-inferred2',
2261
+ firstSeenAt: now,
2262
+ lastSeenAt: now,
2263
+ accessCount: 0,
2264
+ verificationState: 'assistant_inferred',
2265
+ },
2266
+ ]).run();
2267
+
2268
+ const config = {
2269
+ ...DEFAULT_CONFIG,
2270
+ memory: {
2271
+ ...DEFAULT_CONFIG.memory,
2272
+ embeddings: {
2273
+ ...DEFAULT_CONFIG.memory.embeddings,
2274
+ required: false,
2275
+ },
2276
+ },
2277
+ };
2278
+
2279
+ const recall = await buildMemoryRecall('vim keybindings', 'conv-trust-test2', config);
2280
+
2281
+ const reported = recall.topCandidates.find((c) => c.key === 'item:item-trust-reported');
2282
+ const inferred = recall.topCandidates.find((c) => c.key === 'item:item-trust-inferred2');
2283
+ expect(reported).toBeDefined();
2284
+ expect(inferred).toBeDefined();
2285
+
2286
+ // user_reported (weight 0.9) should outrank assistant_inferred (weight 0.7)
2287
+ expect(reported!.finalScore).toBeGreaterThan(inferred!.finalScore);
2288
+ });
2289
+
2290
+ test('trust-aware ranking: weight values are bounded and non-zero', async () => {
2291
+ const db = getDb();
2292
+ const now = Date.now();
2293
+
2294
+ // Insert an item with an unknown verification state to test the default weight
2295
+ const raw = (db as unknown as { $client: { query: (q: string) => { get: (...params: unknown[]) => unknown } } }).$client;
2296
+ raw.query(`
2297
+ INSERT INTO memory_items (id, kind, subject, statement, status, confidence, importance, fingerprint, first_seen_at, last_seen_at, access_count, verification_state)
2298
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2299
+ `).get(
2300
+ 'item-trust-unknown', 'fact', 'trust ranking unknown', 'The user has an unknown trust state preference',
2301
+ 'active', 0.8, 0.5, 'fp-trust-unknown', now, now, 0, 'some_future_state',
2302
+ );
2303
+
2304
+ const config = {
2305
+ ...DEFAULT_CONFIG,
2306
+ memory: {
2307
+ ...DEFAULT_CONFIG.memory,
2308
+ embeddings: {
2309
+ ...DEFAULT_CONFIG.memory.embeddings,
2310
+ required: false,
2311
+ },
2312
+ },
2313
+ };
2314
+
2315
+ const recall = await buildMemoryRecall('unknown trust state preference', 'conv-trust-test3', config);
2316
+
2317
+ const unknown = recall.topCandidates.find((c) => c.key === 'item:item-trust-unknown');
2318
+ expect(unknown).toBeDefined();
2319
+ // The finalScore should be > 0 (trust weight is bounded, not zero)
2320
+ expect(unknown!.finalScore).toBeGreaterThan(0);
2321
+ });
2322
+
2323
+ test('freshness decay: stale event item scores lower than fresh one', async () => {
2324
+ const db = getDb();
2325
+ const now = Date.now();
2326
+ const MS_PER_DAY = 86_400_000;
2327
+
2328
+ // Fresh event item (5 days old — well within the 30-day default window)
2329
+ db.insert(memoryItems).values({
2330
+ id: 'item-fresh-event',
2331
+ kind: 'event',
2332
+ subject: 'freshness decay test',
2333
+ statement: 'User attended a workshop on machine learning',
2334
+ status: 'active',
2335
+ confidence: 0.8,
2336
+ importance: 0.5,
2337
+ fingerprint: 'fp-fresh-event',
2338
+ firstSeenAt: now - 5 * MS_PER_DAY,
2339
+ lastSeenAt: now - 5 * MS_PER_DAY,
2340
+ accessCount: 0,
2341
+ verificationState: 'user_confirmed',
2342
+ }).run();
2343
+
2344
+ // Stale event item (60 days old — past the 30-day event window)
2345
+ db.insert(memoryItems).values({
2346
+ id: 'item-stale-event',
2347
+ kind: 'event',
2348
+ subject: 'freshness decay test',
2349
+ statement: 'User attended a workshop on machine learning basics',
2350
+ status: 'active',
2351
+ confidence: 0.8,
2352
+ importance: 0.5,
2353
+ fingerprint: 'fp-stale-event',
2354
+ firstSeenAt: now - 60 * MS_PER_DAY,
2355
+ lastSeenAt: now - 60 * MS_PER_DAY,
2356
+ accessCount: 0,
2357
+ verificationState: 'user_confirmed',
2358
+ }).run();
2359
+
2360
+ const config = {
2361
+ ...DEFAULT_CONFIG,
2362
+ memory: {
2363
+ ...DEFAULT_CONFIG.memory,
2364
+ embeddings: { ...DEFAULT_CONFIG.memory.embeddings, required: false },
2365
+ },
2366
+ };
2367
+
2368
+ const recall = await buildMemoryRecall('machine learning workshop', 'conv-fresh-1', config);
2369
+
2370
+ const fresh = recall.topCandidates.find((c) => c.key === 'item:item-fresh-event');
2371
+ const stale = recall.topCandidates.find((c) => c.key === 'item:item-stale-event');
2372
+ expect(fresh).toBeDefined();
2373
+ expect(stale).toBeDefined();
2374
+
2375
+ // Fresh item should score higher than stale item due to freshness decay
2376
+ expect(fresh!.finalScore).toBeGreaterThan(stale!.finalScore);
2377
+ });
2378
+
2379
+ test('freshness decay: fact items with maxAgeDays=0 are never decayed', async () => {
2380
+ const db = getDb();
2381
+ const now = Date.now();
2382
+ const MS_PER_DAY = 86_400_000;
2383
+
2384
+ // Very old fact item (365 days) — facts have maxAgeDays=0 (no expiry)
2385
+ db.insert(memoryItems).values({
2386
+ id: 'item-old-fact',
2387
+ kind: 'fact',
2388
+ subject: 'freshness no-decay test',
2389
+ statement: 'The speed of light is 299792458 meters per second',
2390
+ status: 'active',
2391
+ confidence: 0.8,
2392
+ importance: 0.5,
2393
+ fingerprint: 'fp-old-fact',
2394
+ firstSeenAt: now - 365 * MS_PER_DAY,
2395
+ lastSeenAt: now - 365 * MS_PER_DAY,
2396
+ accessCount: 0,
2397
+ verificationState: 'user_confirmed',
2398
+ }).run();
2399
+
2400
+ // Recent fact with same text similarity
2401
+ db.insert(memoryItems).values({
2402
+ id: 'item-new-fact',
2403
+ kind: 'fact',
2404
+ subject: 'freshness no-decay test',
2405
+ statement: 'The speed of light is approximately 3e8 meters per second',
2406
+ status: 'active',
2407
+ confidence: 0.8,
2408
+ importance: 0.5,
2409
+ fingerprint: 'fp-new-fact',
2410
+ firstSeenAt: now - 1 * MS_PER_DAY,
2411
+ lastSeenAt: now - 1 * MS_PER_DAY,
2412
+ accessCount: 0,
2413
+ verificationState: 'user_confirmed',
2414
+ }).run();
2415
+
2416
+ const config = {
2417
+ ...DEFAULT_CONFIG,
2418
+ memory: {
2419
+ ...DEFAULT_CONFIG.memory,
2420
+ embeddings: { ...DEFAULT_CONFIG.memory.embeddings, required: false },
2421
+ },
2422
+ };
2423
+
2424
+ const recall = await buildMemoryRecall('speed of light', 'conv-fresh-2', config);
2425
+
2426
+ const oldFact = recall.topCandidates.find((c) => c.key === 'item:item-old-fact');
2427
+ const newFact = recall.topCandidates.find((c) => c.key === 'item:item-new-fact');
2428
+ expect(oldFact).toBeDefined();
2429
+ expect(newFact).toBeDefined();
2430
+
2431
+ // Both should have similar scores — old facts are NOT decayed
2432
+ // The scores may differ slightly due to recency scores, but the ratio should be close to 1
2433
+ const ratio = oldFact!.finalScore / newFact!.finalScore;
2434
+ expect(ratio).toBeGreaterThan(0.8);
2435
+ });
2436
+
2437
+ test('sweepStaleItems marks deeply stale items as invalid', () => {
2438
+ const db = getDb();
2439
+ const now = Date.now();
2440
+ const MS_PER_DAY = 86_400_000;
2441
+
2442
+ // Item 100 days old with kind=event (default maxAgeDays=30, so 2x=60 — past the deep-stale threshold)
2443
+ db.insert(memoryItems).values({
2444
+ id: 'item-deeply-stale',
2445
+ kind: 'event',
2446
+ subject: 'sweep test',
2447
+ statement: 'Old event that should be swept',
2448
+ status: 'active',
2449
+ confidence: 0.8,
2450
+ importance: 0.5,
2451
+ fingerprint: 'fp-sweep-stale',
2452
+ firstSeenAt: now - 100 * MS_PER_DAY,
2453
+ lastSeenAt: now - 100 * MS_PER_DAY,
2454
+ accessCount: 0,
2455
+ verificationState: 'assistant_inferred',
2456
+ }).run();
2457
+
2458
+ // Fresh event item — should NOT be swept
2459
+ db.insert(memoryItems).values({
2460
+ id: 'item-sweep-fresh',
2461
+ kind: 'event',
2462
+ subject: 'sweep test',
2463
+ statement: 'Recent event that should not be swept',
2464
+ status: 'active',
2465
+ confidence: 0.8,
2466
+ importance: 0.5,
2467
+ fingerprint: 'fp-sweep-fresh',
2468
+ firstSeenAt: now - 5 * MS_PER_DAY,
2469
+ lastSeenAt: now - 5 * MS_PER_DAY,
2470
+ accessCount: 0,
2471
+ verificationState: 'assistant_inferred',
2472
+ }).run();
2473
+
2474
+ const marked = sweepStaleItems(DEFAULT_CONFIG);
2475
+ expect(marked).toBeGreaterThanOrEqual(1);
2476
+
2477
+ const staleItem = db.select().from(memoryItems).where(eq(memoryItems.id, 'item-deeply-stale')).get();
2478
+ expect(staleItem).toBeDefined();
2479
+ expect(staleItem!.invalidAt).not.toBeNull();
2480
+
2481
+ const freshItem = db.select().from(memoryItems).where(eq(memoryItems.id, 'item-sweep-fresh')).get();
2482
+ expect(freshItem).toBeDefined();
2483
+ expect(freshItem!.invalidAt).toBeNull();
2484
+ });
2485
+
2486
+ test('sweepStaleItems shields items with recent lastUsedAt', () => {
2487
+ const db = getDb();
2488
+ const now = Date.now();
2489
+ const MS_PER_DAY = 86_400_000;
2490
+
2491
+ // Old event (100 days) but recently retrieved (lastUsedAt = 2 days ago)
2492
+ // reinforcementShieldDays defaults to 14, so this should be shielded
2493
+ db.insert(memoryItems).values({
2494
+ id: 'item-sweep-shielded',
2495
+ kind: 'event',
2496
+ subject: 'sweep shield test',
2497
+ statement: 'Old event that was recently used',
2498
+ status: 'active',
2499
+ confidence: 0.8,
2500
+ importance: 0.5,
2501
+ fingerprint: 'fp-sweep-shielded',
2502
+ firstSeenAt: now - 100 * MS_PER_DAY,
2503
+ lastSeenAt: now - 100 * MS_PER_DAY,
2504
+ lastUsedAt: now - 2 * MS_PER_DAY,
2505
+ accessCount: 3,
2506
+ verificationState: 'assistant_inferred',
2507
+ }).run();
2508
+
2509
+ const marked = sweepStaleItems(DEFAULT_CONFIG);
2510
+
2511
+ // Sweep ran but shielded item was not marked — should return 0
2512
+ expect(marked).toBe(0);
2513
+
2514
+ const shieldedItem = db.select().from(memoryItems).where(eq(memoryItems.id, 'item-sweep-shielded')).get();
2515
+ expect(shieldedItem).toBeDefined();
2516
+ expect(shieldedItem!.invalidAt).toBeNull();
2517
+ });
2518
+
2519
+ test('scope columns: memory items default to scope_id=default', () => {
2520
+ const db = getDb();
2521
+ const now = Date.now();
2522
+
2523
+ db.insert(memoryItems).values({
2524
+ id: 'item-scope-default',
2525
+ kind: 'fact',
2526
+ subject: 'scope test',
2527
+ statement: 'This item should have default scope',
2528
+ status: 'active',
2529
+ confidence: 0.8,
2530
+ importance: 0.5,
2531
+ fingerprint: 'fp-scope-default',
2532
+ firstSeenAt: now,
2533
+ lastSeenAt: now,
2534
+ accessCount: 0,
2535
+ verificationState: 'user_confirmed',
2536
+ }).run();
2537
+
2538
+ const item = db.select().from(memoryItems).where(eq(memoryItems.id, 'item-scope-default')).get();
2539
+ expect(item).toBeDefined();
2540
+ expect(item!.scopeId).toBe('default');
2541
+ });
2542
+
2543
+ test('scope columns: memory items can be inserted with explicit scope_id', () => {
2544
+ const db = getDb();
2545
+ const now = Date.now();
2546
+
2547
+ db.insert(memoryItems).values({
2548
+ id: 'item-scope-custom',
2549
+ kind: 'fact',
2550
+ subject: 'scope test',
2551
+ statement: 'This item has a custom scope',
2552
+ status: 'active',
2553
+ confidence: 0.8,
2554
+ importance: 0.5,
2555
+ fingerprint: 'fp-scope-custom',
2556
+ scopeId: 'project-abc',
2557
+ firstSeenAt: now,
2558
+ lastSeenAt: now,
2559
+ accessCount: 0,
2560
+ verificationState: 'user_confirmed',
2561
+ }).run();
2562
+
2563
+ const item = db.select().from(memoryItems).where(eq(memoryItems.id, 'item-scope-custom')).get();
2564
+ expect(item).toBeDefined();
2565
+ expect(item!.scopeId).toBe('project-abc');
2566
+ });
2567
+
2568
+ test('scope columns: segments get scopeId from indexer input', () => {
2569
+ const db = getDb();
2570
+ const now = Date.now();
2571
+
2572
+ db.insert(conversations).values({
2573
+ id: 'conv-scope-test',
2574
+ title: null,
2575
+ createdAt: now,
2576
+ updatedAt: now,
2577
+ totalInputTokens: 0,
2578
+ totalOutputTokens: 0,
2579
+ totalEstimatedCost: 0,
2580
+ contextSummary: null,
2581
+ contextCompactedMessageCount: 0,
2582
+ contextCompactedAt: null,
2583
+ }).run();
2584
+ db.insert(messages).values({
2585
+ id: 'msg-scope-test',
2586
+ conversationId: 'conv-scope-test',
2587
+ role: 'user',
2588
+ content: JSON.stringify([{ type: 'text', text: 'Remember my scope preference' }]),
2589
+ createdAt: now,
2590
+ }).run();
2591
+
2592
+ indexMessageNow({
2593
+ messageId: 'msg-scope-test',
2594
+ conversationId: 'conv-scope-test',
2595
+ role: 'user',
2596
+ content: JSON.stringify([{ type: 'text', text: 'Remember my scope preference' }]),
2597
+ createdAt: now,
2598
+ scopeId: 'project-xyz',
2599
+ }, DEFAULT_CONFIG.memory);
2600
+
2601
+ const segments = db.select().from(memorySegments).where(eq(memorySegments.messageId, 'msg-scope-test')).all();
2602
+ expect(segments.length).toBeGreaterThan(0);
2603
+ for (const seg of segments) {
2604
+ expect(seg.scopeId).toBe('project-xyz');
2605
+ }
2606
+ });
2607
+
2608
+ test('scope filtering: retrieval excludes items from other scopes', async () => {
2609
+ const db = getDb();
2610
+ const now = Date.now();
2611
+ const convId = 'conv-scope-filter';
2612
+
2613
+ db.insert(conversations).values({
2614
+ id: convId,
2615
+ title: null,
2616
+ createdAt: now,
2617
+ updatedAt: now,
2618
+ totalInputTokens: 0,
2619
+ totalOutputTokens: 0,
2620
+ totalEstimatedCost: 0,
2621
+ contextSummary: null,
2622
+ contextCompactedMessageCount: 0,
2623
+ contextCompactedAt: null,
2624
+ }).run();
2625
+ db.insert(messages).values({
2626
+ id: 'msg-scope-filter',
2627
+ conversationId: convId,
2628
+ role: 'user',
2629
+ content: JSON.stringify([{ type: 'text', text: 'scope test' }]),
2630
+ createdAt: now,
2631
+ }).run();
2632
+
2633
+ // Insert segment in scope "project-a"
2634
+ db.run(`
2635
+ INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
2636
+ 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})
2637
+ `);
2638
+ db.run(`INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-scope-a', 'The quick brown fox jumps over the lazy dog in project alpha')`);
2639
+
2640
+ // Insert segment in scope "project-b"
2641
+ db.run(`
2642
+ INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
2643
+ 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})
2644
+ `);
2645
+ db.run(`INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-scope-b', 'The quick brown fox jumps over the lazy dog in project beta')`);
2646
+
2647
+ // Insert item in scope "project-a"
2648
+ db.insert(memoryItems).values({
2649
+ id: 'item-scope-a',
2650
+ kind: 'fact',
2651
+ subject: 'fox',
2652
+ statement: 'The fox is quick and brown in project alpha',
2653
+ status: 'active',
2654
+ confidence: 0.9,
2655
+ importance: 0.8,
2656
+ fingerprint: 'fp-scope-a',
2657
+ verificationState: 'user_confirmed',
2658
+ scopeId: 'project-a',
2659
+ firstSeenAt: now,
2660
+ lastSeenAt: now,
2661
+ }).run();
2662
+
2663
+ // Insert item in scope "project-b"
2664
+ db.insert(memoryItems).values({
2665
+ id: 'item-scope-b',
2666
+ kind: 'fact',
2667
+ subject: 'fox',
2668
+ statement: 'The fox is quick and brown in project beta',
2669
+ status: 'active',
2670
+ confidence: 0.9,
2671
+ importance: 0.8,
2672
+ fingerprint: 'fp-scope-b',
2673
+ verificationState: 'user_confirmed',
2674
+ scopeId: 'project-b',
2675
+ firstSeenAt: now,
2676
+ lastSeenAt: now,
2677
+ }).run();
2678
+
2679
+ // Query with scopeId "project-a" — should only find project-a items
2680
+ const config = {
2681
+ ...TEST_CONFIG,
2682
+ memory: {
2683
+ ...TEST_CONFIG.memory,
2684
+ embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
2685
+ },
2686
+ };
2687
+ const result = await buildMemoryRecall('quick brown fox', convId, config, { scopeId: 'project-a' });
2688
+ const keys = result.topCandidates.map((c) => c.key);
2689
+
2690
+ // Segments and items from project-b should not appear
2691
+ expect(keys).not.toContain('segment:seg-scope-b');
2692
+ expect(keys).not.toContain('item:item-scope-b');
2693
+
2694
+ // At least one project-a candidate should appear
2695
+ const hasProjectA = keys.some((k) => k.includes('scope-a'));
2696
+ expect(hasProjectA).toBe(true);
2697
+ });
2698
+
2699
+ test('scope filtering: allow_global_fallback includes default scope', async () => {
2700
+ const db = getDb();
2701
+ const now = Date.now();
2702
+ const convId = 'conv-scope-fallback';
2703
+
2704
+ db.insert(conversations).values({
2705
+ id: convId,
2706
+ title: null,
2707
+ createdAt: now,
2708
+ updatedAt: now,
2709
+ totalInputTokens: 0,
2710
+ totalOutputTokens: 0,
2711
+ totalEstimatedCost: 0,
2712
+ contextSummary: null,
2713
+ contextCompactedMessageCount: 0,
2714
+ contextCompactedAt: null,
2715
+ }).run();
2716
+ db.insert(messages).values({
2717
+ id: 'msg-scope-fallback',
2718
+ conversationId: convId,
2719
+ role: 'user',
2720
+ content: JSON.stringify([{ type: 'text', text: 'fallback test' }]),
2721
+ createdAt: now,
2722
+ }).run();
2723
+
2724
+ // Insert segment in default scope
2725
+ db.run(`
2726
+ INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
2727
+ VALUES ('seg-default-scope', 'msg-scope-fallback', '${convId}', 'user', 0, 'Universal knowledge about programming languages and paradigms', 10, 'default', ${now}, ${now})
2728
+ `);
2729
+ db.run(`INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-default-scope', 'Universal knowledge about programming languages and paradigms')`);
2730
+
2731
+ // Insert segment in custom scope
2732
+ db.run(`
2733
+ INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
2734
+ VALUES ('seg-custom-scope', 'msg-scope-fallback', '${convId}', 'user', 1, 'Project-specific knowledge about programming languages and paradigms', 10, 'my-project', ${now}, ${now})
2735
+ `);
2736
+ db.run(`INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-custom-scope', 'Project-specific knowledge about programming languages and paradigms')`);
2737
+
2738
+ // With allow_global_fallback (the default), querying with scopeId "my-project"
2739
+ // should include both "my-project" and "default" scope items
2740
+ const config = {
2741
+ ...TEST_CONFIG,
2742
+ memory: {
2743
+ ...TEST_CONFIG.memory,
2744
+ embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
2745
+ },
2746
+ };
2747
+ const result = await buildMemoryRecall('programming languages', convId, config, { scopeId: 'my-project' });
2748
+ const keys = result.topCandidates.map((c) => c.key);
2749
+
2750
+ // Both default and custom scope segments should be included
2751
+ expect(keys).toContain('segment:seg-default-scope');
2752
+ expect(keys).toContain('segment:seg-custom-scope');
2753
+ });
2754
+
2755
+ test('scope filtering: strict policy excludes default scope', async () => {
2756
+ const db = getDb();
2757
+ const now = Date.now();
2758
+ const convId = 'conv-scope-strict';
2759
+
2760
+ db.insert(conversations).values({
2761
+ id: convId,
2762
+ title: null,
2763
+ createdAt: now,
2764
+ updatedAt: now,
2765
+ totalInputTokens: 0,
2766
+ totalOutputTokens: 0,
2767
+ totalEstimatedCost: 0,
2768
+ contextSummary: null,
2769
+ contextCompactedMessageCount: 0,
2770
+ contextCompactedAt: null,
2771
+ }).run();
2772
+ db.insert(messages).values({
2773
+ id: 'msg-scope-strict',
2774
+ conversationId: convId,
2775
+ role: 'user',
2776
+ content: JSON.stringify([{ type: 'text', text: 'strict test' }]),
2777
+ createdAt: now,
2778
+ }).run();
2779
+
2780
+ // Insert segment in default scope
2781
+ db.run(`
2782
+ INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
2783
+ VALUES ('seg-strict-default', 'msg-scope-strict', '${convId}', 'user', 0, 'Global memory about database optimization techniques', 8, 'default', ${now}, ${now})
2784
+ `);
2785
+ db.run(`INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-strict-default', 'Global memory about database optimization techniques')`);
2786
+
2787
+ // Insert segment in custom scope
2788
+ db.run(`
2789
+ INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
2790
+ VALUES ('seg-strict-custom', 'msg-scope-strict', '${convId}', 'user', 1, 'Project-specific memory about database optimization techniques', 8, 'strict-project', ${now}, ${now})
2791
+ `);
2792
+ db.run(`INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-strict-custom', 'Project-specific memory about database optimization techniques')`);
2793
+
2794
+ // With strict policy, querying with scopeId should only include that scope
2795
+ const strictConfig = {
2796
+ ...TEST_CONFIG,
2797
+ memory: {
2798
+ ...TEST_CONFIG.memory,
2799
+ embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
2800
+ retrieval: {
2801
+ ...TEST_CONFIG.memory.retrieval,
2802
+ scopePolicy: 'strict' as const,
2803
+ },
2804
+ },
2805
+ };
2806
+
2807
+ const result = await buildMemoryRecall('database optimization', convId, strictConfig, { scopeId: 'strict-project' });
2808
+ const keys = result.topCandidates.map((c) => c.key);
2809
+
2810
+ // Only strict-project scope segment should appear
2811
+ expect(keys).not.toContain('segment:seg-strict-default');
2812
+ expect(keys).toContain('segment:seg-strict-custom');
2813
+ });
2814
+
2815
+ test('relation retrieval respects scope and active-item filters', async () => {
2816
+ const db = getDb();
2817
+ const now = Date.now();
2818
+ const convId = 'conv-relation-scope';
2819
+
2820
+ db.insert(conversations).values({
2821
+ id: convId,
2822
+ title: null,
2823
+ createdAt: now,
2824
+ updatedAt: now,
2825
+ totalInputTokens: 0,
2826
+ totalOutputTokens: 0,
2827
+ totalEstimatedCost: 0,
2828
+ contextSummary: null,
2829
+ contextCompactedMessageCount: 0,
2830
+ contextCompactedAt: null,
2831
+ }).run();
2832
+ db.insert(messages).values({
2833
+ id: 'msg-relation-scope',
2834
+ conversationId: convId,
2835
+ role: 'user',
2836
+ content: JSON.stringify([{ type: 'text', text: 'atlas reliability memo' }]),
2837
+ createdAt: now,
2838
+ }).run();
2839
+
2840
+ db.insert(memoryItems).values([
2841
+ {
2842
+ id: 'item-rel-a-active',
2843
+ kind: 'fact',
2844
+ subject: 'autoscaling policy',
2845
+ statement: 'Use Kubernetes HPA for sustained traffic spikes',
2846
+ status: 'active',
2847
+ confidence: 0.9,
2848
+ importance: 0.8,
2849
+ fingerprint: 'fp-rel-a-active',
2850
+ verificationState: 'user_confirmed',
2851
+ scopeId: 'project-a',
2852
+ firstSeenAt: now,
2853
+ lastSeenAt: now,
2854
+ },
2855
+ {
2856
+ id: 'item-rel-b-active',
2857
+ kind: 'fact',
2858
+ subject: 'scheduler policy',
2859
+ statement: 'Use Nomad system jobs for batch workloads',
2860
+ status: 'active',
2861
+ confidence: 0.9,
2862
+ importance: 0.8,
2863
+ fingerprint: 'fp-rel-b-active',
2864
+ verificationState: 'user_confirmed',
2865
+ scopeId: 'project-b',
2866
+ firstSeenAt: now,
2867
+ lastSeenAt: now,
2868
+ },
2869
+ {
2870
+ id: 'item-rel-a-invalid',
2871
+ kind: 'fact',
2872
+ subject: 'deprecated platform',
2873
+ statement: 'Legacy Kubernetes cluster should still be used',
2874
+ status: 'active',
2875
+ confidence: 0.9,
2876
+ importance: 0.8,
2877
+ fingerprint: 'fp-rel-a-invalid',
2878
+ verificationState: 'user_confirmed',
2879
+ scopeId: 'project-a',
2880
+ firstSeenAt: now,
2881
+ lastSeenAt: now,
2882
+ invalidAt: now + 1,
2883
+ },
2884
+ {
2885
+ id: 'item-rel-a-pending',
2886
+ kind: 'fact',
2887
+ subject: 'pending platform policy',
2888
+ statement: 'Pending clarification platform statement',
2889
+ status: 'pending_clarification',
2890
+ confidence: 0.9,
2891
+ importance: 0.8,
2892
+ fingerprint: 'fp-rel-a-pending',
2893
+ verificationState: 'assistant_inferred',
2894
+ scopeId: 'project-a',
2895
+ firstSeenAt: now,
2896
+ lastSeenAt: now,
2897
+ },
2898
+ ]).run();
2899
+
2900
+ db.insert(memoryItemSources).values([
2901
+ {
2902
+ memoryItemId: 'item-rel-a-active',
2903
+ messageId: 'msg-relation-scope',
2904
+ evidence: 'source a active',
2905
+ createdAt: now,
2906
+ },
2907
+ {
2908
+ memoryItemId: 'item-rel-b-active',
2909
+ messageId: 'msg-relation-scope',
2910
+ evidence: 'source b active',
2911
+ createdAt: now,
2912
+ },
2913
+ {
2914
+ memoryItemId: 'item-rel-a-invalid',
2915
+ messageId: 'msg-relation-scope',
2916
+ evidence: 'source a invalid',
2917
+ createdAt: now,
2918
+ },
2919
+ {
2920
+ memoryItemId: 'item-rel-a-pending',
2921
+ messageId: 'msg-relation-scope',
2922
+ evidence: 'source a pending',
2923
+ createdAt: now,
2924
+ },
2925
+ ]).run();
2926
+
2927
+ db.insert(memoryEntities).values([
2928
+ {
2929
+ id: 'entity-atlas-test',
2930
+ name: 'Project Atlas',
2931
+ type: 'project',
2932
+ aliases: JSON.stringify(['atlas']),
2933
+ description: null,
2934
+ firstSeenAt: now,
2935
+ lastSeenAt: now,
2936
+ mentionCount: 1,
2937
+ },
2938
+ {
2939
+ id: 'entity-k8s-test',
2940
+ name: 'Kubernetes',
2941
+ type: 'tool',
2942
+ aliases: JSON.stringify(['k8s']),
2943
+ description: null,
2944
+ firstSeenAt: now,
2945
+ lastSeenAt: now,
2946
+ mentionCount: 1,
2947
+ },
2948
+ {
2949
+ id: 'entity-nomad-test',
2950
+ name: 'Nomad',
2951
+ type: 'tool',
2952
+ aliases: JSON.stringify(['nomad']),
2953
+ description: null,
2954
+ firstSeenAt: now,
2955
+ lastSeenAt: now,
2956
+ mentionCount: 1,
2957
+ },
2958
+ ]).run();
2959
+
2960
+ db.insert(memoryEntityRelations).values([
2961
+ {
2962
+ id: 'rel-atlas-k8s-test',
2963
+ sourceEntityId: 'entity-atlas-test',
2964
+ targetEntityId: 'entity-k8s-test',
2965
+ relation: 'uses',
2966
+ evidence: 'Atlas uses Kubernetes',
2967
+ firstSeenAt: now,
2968
+ lastSeenAt: now,
2969
+ },
2970
+ {
2971
+ id: 'rel-atlas-nomad-test',
2972
+ sourceEntityId: 'entity-atlas-test',
2973
+ targetEntityId: 'entity-nomad-test',
2974
+ relation: 'uses',
2975
+ evidence: 'Atlas also uses Nomad in a different scope',
2976
+ firstSeenAt: now,
2977
+ lastSeenAt: now,
2978
+ },
2979
+ ]).run();
2980
+
2981
+ db.insert(memoryItemEntities).values([
2982
+ {
2983
+ memoryItemId: 'item-rel-a-active',
2984
+ entityId: 'entity-k8s-test',
2985
+ },
2986
+ {
2987
+ memoryItemId: 'item-rel-a-invalid',
2988
+ entityId: 'entity-k8s-test',
2989
+ },
2990
+ {
2991
+ memoryItemId: 'item-rel-a-pending',
2992
+ entityId: 'entity-k8s-test',
2993
+ },
2994
+ {
2995
+ memoryItemId: 'item-rel-b-active',
2996
+ entityId: 'entity-nomad-test',
2997
+ },
2998
+ ]).run();
2999
+
3000
+ const relationConfig = {
3001
+ ...TEST_CONFIG,
3002
+ memory: {
3003
+ ...TEST_CONFIG.memory,
3004
+ embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
3005
+ entity: {
3006
+ ...TEST_CONFIG.memory.entity,
3007
+ relationRetrieval: {
3008
+ ...TEST_CONFIG.memory.entity.relationRetrieval,
3009
+ enabled: true,
3010
+ maxSeedEntities: 6,
3011
+ maxNeighborEntities: 6,
3012
+ maxEdges: 10,
3013
+ neighborScoreMultiplier: 0.7,
3014
+ },
3015
+ },
3016
+ },
3017
+ };
3018
+
3019
+ const result = await buildMemoryRecall(
3020
+ 'atlas reliability roadmap',
3021
+ convId,
3022
+ relationConfig,
3023
+ { scopeId: 'project-a' },
3024
+ );
3025
+ const keys = result.topCandidates.map((candidate) => candidate.key);
3026
+
3027
+ expect(keys).toContain('item:item-rel-a-active');
3028
+ expect(keys).not.toContain('item:item-rel-b-active');
3029
+ expect(keys).not.toContain('item:item-rel-a-invalid');
3030
+ expect(keys).not.toContain('item:item-rel-a-pending');
3031
+ });
3032
+
3033
+ test('scope columns: summaries default to scope_id=default', () => {
3034
+ const db = getDb();
3035
+ const now = Date.now();
3036
+
3037
+ db.insert(memorySummaries).values({
3038
+ id: 'summary-scope-test',
3039
+ scope: 'weekly_global',
3040
+ scopeKey: '2025-W01',
3041
+ summary: 'Test summary for scope',
3042
+ tokenEstimate: 10,
3043
+ startAt: now - 7 * 86_400_000,
3044
+ endAt: now,
3045
+ createdAt: now,
3046
+ updatedAt: now,
3047
+ }).run();
3048
+
3049
+ const summary = db.select().from(memorySummaries).where(eq(memorySummaries.id, 'summary-scope-test')).get();
3050
+ expect(summary).toBeDefined();
3051
+ expect(summary!.scopeId).toBe('default');
3052
+ });
3053
+
3054
+ test('forced backfill does not double-schedule entity extraction via relation backfill', async () => {
3055
+ const db = getDb();
3056
+ const now = 1_700_002_000_000;
3057
+ const originalEnabled = TEST_CONFIG.memory.entity.enabled;
3058
+ const originalRelationsEnabled = TEST_CONFIG.memory.entity.extractRelations.enabled;
3059
+ TEST_CONFIG.memory.entity.enabled = true;
3060
+ TEST_CONFIG.memory.entity.extractRelations.enabled = true;
3061
+
3062
+ try {
3063
+ db.insert(conversations).values({
3064
+ id: 'conv-no-double',
3065
+ title: null,
3066
+ createdAt: now,
3067
+ updatedAt: now,
3068
+ totalInputTokens: 0,
3069
+ totalOutputTokens: 0,
3070
+ totalEstimatedCost: 0,
3071
+ contextSummary: null,
3072
+ contextCompactedMessageCount: 0,
3073
+ contextCompactedAt: null,
3074
+ }).run();
3075
+
3076
+ // Insert fewer than 200 messages so the backfill completes in one batch
3077
+ for (let i = 0; i < 3; i++) {
3078
+ db.insert(messages).values({
3079
+ id: `msg-no-double-${i}`,
3080
+ conversationId: 'conv-no-double',
3081
+ role: 'user',
3082
+ content: JSON.stringify([{ type: 'text', text: `Test message ${i} for double scheduling` }]),
3083
+ createdAt: now + i + 1,
3084
+ }).run();
3085
+ }
3086
+
3087
+ // Enqueue a forced backfill
3088
+ enqueueMemoryJob('backfill', { force: true });
3089
+ await runMemoryJobsOnce();
3090
+
3091
+ // The backfill should have completed (< 200 msgs) and enqueued a
3092
+ // non-forced relation backfill. Count extract_entities jobs: they
3093
+ // should come only from the extract_items chain, not duplicated by
3094
+ // the relation backfill (which hasn't run yet).
3095
+ const relationBackfillJobs = db
3096
+ .select()
3097
+ .from(memoryJobs)
3098
+ .where(and(
3099
+ eq(memoryJobs.type, 'backfill_entity_relations'),
3100
+ eq(memoryJobs.status, 'pending'),
3101
+ ))
3102
+ .all();
3103
+
3104
+ // A non-forced relation backfill should be enqueued
3105
+ expect(relationBackfillJobs.length).toBeLessThanOrEqual(1);
3106
+
3107
+ // Verify the relation backfill was NOT force-flagged
3108
+ if (relationBackfillJobs.length === 1) {
3109
+ const payload = JSON.parse(relationBackfillJobs[0].payload);
3110
+ expect(payload.force).not.toBe(true);
3111
+ }
3112
+ } finally {
3113
+ TEST_CONFIG.memory.entity.enabled = originalEnabled;
3114
+ TEST_CONFIG.memory.entity.extractRelations.enabled = originalRelationsEnabled;
3115
+ }
3116
+ });
3117
+
3118
+ test('backfill enqueues relation backfill when message count is exact multiple of 200', async () => {
3119
+ const db = getDb();
3120
+ const now = 1_700_004_000_000;
3121
+ const originalEnabled = TEST_CONFIG.memory.entity.enabled;
3122
+ const originalRelationsEnabled = TEST_CONFIG.memory.entity.extractRelations.enabled;
3123
+ TEST_CONFIG.memory.entity.enabled = true;
3124
+ TEST_CONFIG.memory.entity.extractRelations.enabled = true;
3125
+
3126
+ try {
3127
+ db.insert(conversations).values({
3128
+ id: 'conv-exact-200',
3129
+ title: null,
3130
+ createdAt: now,
3131
+ updatedAt: now,
3132
+ totalInputTokens: 0,
3133
+ totalOutputTokens: 0,
3134
+ totalEstimatedCost: 0,
3135
+ contextSummary: null,
3136
+ contextCompactedMessageCount: 0,
3137
+ contextCompactedAt: null,
3138
+ }).run();
3139
+
3140
+ // Insert exactly 200 messages so the first backfill batch is full
3141
+ for (let i = 0; i < 200; i++) {
3142
+ db.insert(messages).values({
3143
+ id: `msg-exact-200-${String(i).padStart(4, '0')}`,
3144
+ conversationId: 'conv-exact-200',
3145
+ role: 'user',
3146
+ content: JSON.stringify([{ type: 'text', text: `Message ${i}` }]),
3147
+ createdAt: now + i + 1,
3148
+ }).run();
3149
+ }
3150
+
3151
+ // First backfill: processes 200 messages, should enqueue another backfill
3152
+ enqueueMemoryJob('backfill', {});
3153
+ await runMemoryJobsOnce();
3154
+
3155
+ // Should have enqueued a follow-up backfill (batch was full)
3156
+ const followUpBackfill = db
3157
+ .select()
3158
+ .from(memoryJobs)
3159
+ .where(and(eq(memoryJobs.type, 'backfill'), eq(memoryJobs.status, 'pending')))
3160
+ .all();
3161
+ expect(followUpBackfill).toHaveLength(1);
3162
+
3163
+ // No relation backfill yet (batch was full, more work expected)
3164
+ const relationBefore = db
3165
+ .select()
3166
+ .from(memoryJobs)
3167
+ .where(and(eq(memoryJobs.type, 'backfill_entity_relations'), eq(memoryJobs.status, 'pending')))
3168
+ .all();
3169
+ expect(relationBefore).toHaveLength(0);
3170
+
3171
+ // Clear all non-backfill pending jobs so the next runMemoryJobsOnce
3172
+ // picks up the follow-up backfill job (claimMemoryJobs has a concurrency
3173
+ // limit and processes jobs in creation order)
3174
+ db.run(`DELETE FROM memory_jobs WHERE type != 'backfill' AND status = 'pending'`);
3175
+
3176
+ // Second backfill: reads 0 messages (terminal empty batch), should
3177
+ // still enqueue the relation backfill
3178
+ await runMemoryJobsOnce();
3179
+
3180
+ const relationAfter = db
3181
+ .select()
3182
+ .from(memoryJobs)
3183
+ .where(and(eq(memoryJobs.type, 'backfill_entity_relations'), eq(memoryJobs.status, 'pending')))
3184
+ .all();
3185
+ expect(relationAfter).toHaveLength(1);
3186
+ } finally {
3187
+ TEST_CONFIG.memory.entity.enabled = originalEnabled;
3188
+ TEST_CONFIG.memory.entity.extractRelations.enabled = originalRelationsEnabled;
3189
+ }
3190
+ });
3191
+
3192
+ test('relation backfill respects extractFromAssistant=false config', async () => {
3193
+ const db = getDb();
3194
+ const now = 1_700_003_000_000;
3195
+ const originalEnabled = TEST_CONFIG.memory.entity.enabled;
3196
+ const originalRelationsEnabled = TEST_CONFIG.memory.entity.extractRelations.enabled;
3197
+ const originalBatchSize = TEST_CONFIG.memory.entity.extractRelations.backfillBatchSize;
3198
+ const originalExtractFromAssistant = TEST_CONFIG.memory.extraction.extractFromAssistant;
3199
+ TEST_CONFIG.memory.entity.enabled = true;
3200
+ TEST_CONFIG.memory.entity.extractRelations.enabled = true;
3201
+ TEST_CONFIG.memory.entity.extractRelations.backfillBatchSize = 10;
3202
+ TEST_CONFIG.memory.extraction.extractFromAssistant = false;
3203
+
3204
+ try {
3205
+ db.insert(conversations).values({
3206
+ id: 'conv-role-filter',
3207
+ title: null,
3208
+ createdAt: now,
3209
+ updatedAt: now,
3210
+ totalInputTokens: 0,
3211
+ totalOutputTokens: 0,
3212
+ totalEstimatedCost: 0,
3213
+ contextSummary: null,
3214
+ contextCompactedMessageCount: 0,
3215
+ contextCompactedAt: null,
3216
+ }).run();
3217
+
3218
+ db.insert(messages).values([
3219
+ {
3220
+ id: 'msg-role-user',
3221
+ conversationId: 'conv-role-filter',
3222
+ role: 'user',
3223
+ content: JSON.stringify([{ type: 'text', text: 'User message for entity extraction.' }]),
3224
+ createdAt: now + 1,
3225
+ },
3226
+ {
3227
+ id: 'msg-role-assistant',
3228
+ conversationId: 'conv-role-filter',
3229
+ role: 'assistant',
3230
+ content: JSON.stringify([{ type: 'text', text: 'Assistant message that should be skipped.' }]),
3231
+ createdAt: now + 2,
3232
+ },
3233
+ {
3234
+ id: 'msg-role-user-2',
3235
+ conversationId: 'conv-role-filter',
3236
+ role: 'user',
3237
+ content: JSON.stringify([{ type: 'text', text: 'Another user message for extraction.' }]),
3238
+ createdAt: now + 3,
3239
+ },
3240
+ ]).run();
3241
+
3242
+ enqueueBackfillEntityRelationsJob(true);
3243
+ await runMemoryJobsOnce();
3244
+
3245
+ // Only user messages should have extract_entities jobs
3246
+ const extractJobs = db
3247
+ .select()
3248
+ .from(memoryJobs)
3249
+ .where(eq(memoryJobs.type, 'extract_entities'))
3250
+ .all();
3251
+
3252
+ const extractedMessageIds = extractJobs.map((j) => {
3253
+ const payload = JSON.parse(j.payload);
3254
+ return payload.messageId;
3255
+ });
3256
+
3257
+ expect(extractedMessageIds).toContain('msg-role-user');
3258
+ expect(extractedMessageIds).toContain('msg-role-user-2');
3259
+ expect(extractedMessageIds).not.toContain('msg-role-assistant');
3260
+ } finally {
3261
+ TEST_CONFIG.memory.entity.enabled = originalEnabled;
3262
+ TEST_CONFIG.memory.entity.extractRelations.enabled = originalRelationsEnabled;
3263
+ TEST_CONFIG.memory.entity.extractRelations.backfillBatchSize = originalBatchSize;
3264
+ TEST_CONFIG.memory.extraction.extractFromAssistant = originalExtractFromAssistant;
3265
+ }
3266
+ });
3267
+
3268
+ test('entity relations upsert is idempotent under repeated processing', () => {
3269
+ const db = getDb();
3270
+ const sourceEntityId = upsertEntity({
3271
+ name: 'Project Atlas',
3272
+ type: 'project',
3273
+ aliases: ['atlas'],
3274
+ });
3275
+ const targetEntityId = upsertEntity({
3276
+ name: 'Qdrant',
3277
+ type: 'tool',
3278
+ aliases: [],
3279
+ });
3280
+
3281
+ upsertEntityRelation({
3282
+ sourceEntityId,
3283
+ targetEntityId,
3284
+ relation: 'uses',
3285
+ evidence: 'Project Atlas uses Qdrant for vector search',
3286
+ seenAt: 1_700_000_000_000,
3287
+ });
3288
+ upsertEntityRelation({
3289
+ sourceEntityId,
3290
+ targetEntityId,
3291
+ relation: 'uses',
3292
+ evidence: null,
3293
+ seenAt: 1_700_000_100_000,
3294
+ });
3295
+ upsertEntityRelation({
3296
+ sourceEntityId,
3297
+ targetEntityId,
3298
+ relation: 'uses',
3299
+ evidence: 'Atlas currently depends on Qdrant',
3300
+ seenAt: 1_700_000_200_000,
3301
+ });
3302
+
3303
+ const rows = db
3304
+ .select()
3305
+ .from(memoryEntityRelations)
3306
+ .where(and(
3307
+ eq(memoryEntityRelations.sourceEntityId, sourceEntityId),
3308
+ eq(memoryEntityRelations.targetEntityId, targetEntityId),
3309
+ eq(memoryEntityRelations.relation, 'uses'),
3310
+ ))
3311
+ .all();
3312
+
3313
+ expect(rows.length).toBe(1);
3314
+ expect(rows[0].firstSeenAt).toBe(1_700_000_000_000);
3315
+ expect(rows[0].lastSeenAt).toBe(1_700_000_200_000);
3316
+ expect(rows[0].evidence).toBe('Atlas currently depends on Qdrant');
3317
+ });
3318
+
3319
+ // ── scopePolicyOverride tests ───────────────────────────────────────
3320
+
3321
+ test('scopePolicyOverride with fallbackToDefault includes both scopes even when global policy is strict', async () => {
3322
+ const db = getDb();
3323
+ const now = Date.now();
3324
+ const convId = 'conv-scope-override-fallback';
3325
+
3326
+ db.insert(conversations).values({
3327
+ id: convId,
3328
+ title: null,
3329
+ createdAt: now,
3330
+ updatedAt: now,
3331
+ totalInputTokens: 0,
3332
+ totalOutputTokens: 0,
3333
+ totalEstimatedCost: 0,
3334
+ contextSummary: null,
3335
+ contextCompactedMessageCount: 0,
3336
+ contextCompactedAt: null,
3337
+ }).run();
3338
+ db.insert(messages).values({
3339
+ id: 'msg-override-fallback',
3340
+ conversationId: convId,
3341
+ role: 'user',
3342
+ content: JSON.stringify([{ type: 'text', text: 'override fallback test' }]),
3343
+ createdAt: now,
3344
+ }).run();
3345
+
3346
+ // Insert segment in default scope
3347
+ db.run(`
3348
+ INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
3349
+ VALUES ('seg-ovr-default', 'msg-override-fallback', '${convId}', 'user', 0, 'Global memory about microservices architecture patterns', 10, 'default', ${now}, ${now})
3350
+ `);
3351
+ db.run(`INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-ovr-default', 'Global memory about microservices architecture patterns')`);
3352
+
3353
+ // Insert segment in private thread scope
3354
+ db.run(`
3355
+ INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
3356
+ VALUES ('seg-ovr-private', 'msg-override-fallback', '${convId}', 'user', 1, 'Private thread memory about microservices architecture patterns', 10, 'private-thread-42', ${now}, ${now})
3357
+ `);
3358
+ db.run(`INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-ovr-private', 'Private thread memory about microservices architecture patterns')`);
3359
+
3360
+ // Global policy is strict, but override requests fallback to default
3361
+ const strictConfig = {
3362
+ ...TEST_CONFIG,
3363
+ memory: {
3364
+ ...TEST_CONFIG.memory,
3365
+ embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
3366
+ retrieval: {
3367
+ ...TEST_CONFIG.memory.retrieval,
3368
+ scopePolicy: 'strict' as const,
3369
+ },
3370
+ },
3371
+ };
3372
+
3373
+ const result = await buildMemoryRecall('microservices architecture', convId, strictConfig, {
3374
+ scopePolicyOverride: {
3375
+ scopeId: 'private-thread-42',
3376
+ fallbackToDefault: true,
3377
+ },
3378
+ });
3379
+ const keys = result.topCandidates.map((c) => c.key);
3380
+
3381
+ // Override should include both private and default scope despite strict global policy
3382
+ expect(keys).toContain('segment:seg-ovr-default');
3383
+ expect(keys).toContain('segment:seg-ovr-private');
3384
+ });
3385
+
3386
+ test('scopePolicyOverride without fallback excludes default scope even when global policy is allow_global_fallback', async () => {
3387
+ const db = getDb();
3388
+ const now = Date.now();
3389
+ const convId = 'conv-scope-override-nofallback';
3390
+
3391
+ db.insert(conversations).values({
3392
+ id: convId,
3393
+ title: null,
3394
+ createdAt: now,
3395
+ updatedAt: now,
3396
+ totalInputTokens: 0,
3397
+ totalOutputTokens: 0,
3398
+ totalEstimatedCost: 0,
3399
+ contextSummary: null,
3400
+ contextCompactedMessageCount: 0,
3401
+ contextCompactedAt: null,
3402
+ }).run();
3403
+ db.insert(messages).values({
3404
+ id: 'msg-override-nofallback',
3405
+ conversationId: convId,
3406
+ role: 'user',
3407
+ content: JSON.stringify([{ type: 'text', text: 'override no fallback' }]),
3408
+ createdAt: now,
3409
+ }).run();
3410
+
3411
+ // Insert segment in default scope
3412
+ db.run(`
3413
+ INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
3414
+ VALUES ('seg-ovr-nf-default', 'msg-override-nofallback', '${convId}', 'user', 0, 'Global memory about container orchestration strategies', 10, 'default', ${now}, ${now})
3415
+ `);
3416
+ db.run(`INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-ovr-nf-default', 'Global memory about container orchestration strategies')`);
3417
+
3418
+ // Insert segment in isolated scope
3419
+ db.run(`
3420
+ INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
3421
+ VALUES ('seg-ovr-nf-isolated', 'msg-override-nofallback', '${convId}', 'user', 1, 'Isolated memory about container orchestration strategies', 10, 'isolated-scope', ${now}, ${now})
3422
+ `);
3423
+ db.run(`INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-ovr-nf-isolated', 'Isolated memory about container orchestration strategies')`);
3424
+
3425
+ // Global policy allows fallback, but override says no fallback
3426
+ const fallbackConfig = {
3427
+ ...TEST_CONFIG,
3428
+ memory: {
3429
+ ...TEST_CONFIG.memory,
3430
+ embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
3431
+ retrieval: {
3432
+ ...TEST_CONFIG.memory.retrieval,
3433
+ scopePolicy: 'allow_global_fallback' as const,
3434
+ },
3435
+ },
3436
+ };
3437
+
3438
+ const result = await buildMemoryRecall('container orchestration', convId, fallbackConfig, {
3439
+ scopePolicyOverride: {
3440
+ scopeId: 'isolated-scope',
3441
+ fallbackToDefault: false,
3442
+ },
3443
+ });
3444
+ const keys = result.topCandidates.map((c) => c.key);
3445
+
3446
+ // Override disables fallback — only isolated scope should appear
3447
+ expect(keys).not.toContain('segment:seg-ovr-nf-default');
3448
+ expect(keys).toContain('segment:seg-ovr-nf-isolated');
3449
+ });
3450
+
3451
+ test('scopePolicyOverride takes precedence over scopeId option', async () => {
3452
+ const db = getDb();
3453
+ const now = Date.now();
3454
+ const convId = 'conv-scope-override-precedence';
3455
+
3456
+ db.insert(conversations).values({
3457
+ id: convId,
3458
+ title: null,
3459
+ createdAt: now,
3460
+ updatedAt: now,
3461
+ totalInputTokens: 0,
3462
+ totalOutputTokens: 0,
3463
+ totalEstimatedCost: 0,
3464
+ contextSummary: null,
3465
+ contextCompactedMessageCount: 0,
3466
+ contextCompactedAt: null,
3467
+ }).run();
3468
+ db.insert(messages).values({
3469
+ id: 'msg-override-precedence',
3470
+ conversationId: convId,
3471
+ role: 'user',
3472
+ content: JSON.stringify([{ type: 'text', text: 'precedence test' }]),
3473
+ createdAt: now,
3474
+ }).run();
3475
+
3476
+ // Insert segment in scope-a (what scopeId would resolve to)
3477
+ db.run(`
3478
+ INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
3479
+ VALUES ('seg-ovr-prec-a', 'msg-override-precedence', '${convId}', 'user', 0, 'Scope A memory about distributed caching patterns', 10, 'scope-a', ${now}, ${now})
3480
+ `);
3481
+ db.run(`INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-ovr-prec-a', 'Scope A memory about distributed caching patterns')`);
3482
+
3483
+ // Insert segment in scope-b (what the override targets)
3484
+ db.run(`
3485
+ INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
3486
+ VALUES ('seg-ovr-prec-b', 'msg-override-precedence', '${convId}', 'user', 1, 'Scope B memory about distributed caching patterns', 10, 'scope-b', ${now}, ${now})
3487
+ `);
3488
+ db.run(`INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-ovr-prec-b', 'Scope B memory about distributed caching patterns')`);
3489
+
3490
+ const config = {
3491
+ ...TEST_CONFIG,
3492
+ memory: {
3493
+ ...TEST_CONFIG.memory,
3494
+ embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
3495
+ retrieval: {
3496
+ ...TEST_CONFIG.memory.retrieval,
3497
+ scopePolicy: 'strict' as const,
3498
+ },
3499
+ },
3500
+ };
3501
+
3502
+ // scopeId says 'scope-a', but override says 'scope-b' — override wins
3503
+ const result = await buildMemoryRecall('distributed caching', convId, config, {
3504
+ scopeId: 'scope-a',
3505
+ scopePolicyOverride: {
3506
+ scopeId: 'scope-b',
3507
+ fallbackToDefault: false,
3508
+ },
3509
+ });
3510
+ const keys = result.topCandidates.map((c) => c.key);
3511
+
3512
+ expect(keys).not.toContain('segment:seg-ovr-prec-a');
3513
+ expect(keys).toContain('segment:seg-ovr-prec-b');
3514
+ });
3515
+
3516
+ test('scopePolicyOverride with default as primary scope and fallback=true returns only default', async () => {
3517
+ const db = getDb();
3518
+ const now = Date.now();
3519
+ const convId = 'conv-scope-override-default-primary';
3520
+
3521
+ db.insert(conversations).values({
3522
+ id: convId,
3523
+ title: null,
3524
+ createdAt: now,
3525
+ updatedAt: now,
3526
+ totalInputTokens: 0,
3527
+ totalOutputTokens: 0,
3528
+ totalEstimatedCost: 0,
3529
+ contextSummary: null,
3530
+ contextCompactedMessageCount: 0,
3531
+ contextCompactedAt: null,
3532
+ }).run();
3533
+ db.insert(messages).values({
3534
+ id: 'msg-override-default-primary',
3535
+ conversationId: convId,
3536
+ role: 'user',
3537
+ content: JSON.stringify([{ type: 'text', text: 'default primary' }]),
3538
+ createdAt: now,
3539
+ }).run();
3540
+
3541
+ // Insert segment in default scope
3542
+ db.run(`
3543
+ INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
3544
+ VALUES ('seg-ovr-dp-default', 'msg-override-default-primary', '${convId}', 'user', 0, 'Default scope memory about event driven design', 10, 'default', ${now}, ${now})
3545
+ `);
3546
+ db.run(`INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-ovr-dp-default', 'Default scope memory about event driven design')`);
3547
+
3548
+ // Insert segment in other scope
3549
+ db.run(`
3550
+ INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
3551
+ VALUES ('seg-ovr-dp-other', 'msg-override-default-primary', '${convId}', 'user', 1, 'Other scope memory about event driven design', 10, 'other-scope', ${now}, ${now})
3552
+ `);
3553
+ db.run(`INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-ovr-dp-other', 'Other scope memory about event driven design')`);
3554
+
3555
+ const config = {
3556
+ ...TEST_CONFIG,
3557
+ memory: {
3558
+ ...TEST_CONFIG.memory,
3559
+ embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
3560
+ },
3561
+ };
3562
+
3563
+ // When primary scope IS 'default' with fallback=true, no duplication —
3564
+ // just ['default'] is used
3565
+ const result = await buildMemoryRecall('event driven design', convId, config, {
3566
+ scopePolicyOverride: {
3567
+ scopeId: 'default',
3568
+ fallbackToDefault: true,
3569
+ },
3570
+ });
3571
+ const keys = result.topCandidates.map((c) => c.key);
3572
+
3573
+ expect(keys).toContain('segment:seg-ovr-dp-default');
3574
+ expect(keys).not.toContain('segment:seg-ovr-dp-other');
3575
+ });
3576
+
3577
+ // PR-17: addMessage() passes conversation scope to the indexer
3578
+ test('addMessage inherits private conversation scope on memory segments', () => {
3579
+ const conv = createConversation({ title: 'Private thread', threadType: 'private' });
3580
+ expect(conv.memoryScopeId).toMatch(/^private:/);
3581
+
3582
+ const msg = addMessage(conv.id, 'user', 'My secret project details for the private thread.');
3583
+
3584
+ const db = getDb();
3585
+ const segments = db
3586
+ .select()
3587
+ .from(memorySegments)
3588
+ .where(eq(memorySegments.messageId, msg.id))
3589
+ .all();
3590
+
3591
+ expect(segments.length).toBeGreaterThan(0);
3592
+ for (const seg of segments) {
3593
+ expect(seg.scopeId).toBe(conv.memoryScopeId);
3594
+ }
3595
+ });
3596
+
3597
+ test('addMessage uses default scope for standard conversations', () => {
3598
+ const conv = createConversation({ title: 'Standard thread', threadType: 'standard' });
3599
+ expect(conv.memoryScopeId).toBe('default');
3600
+
3601
+ const msg = addMessage(conv.id, 'user', 'Normal conversation content for testing scope defaults.');
3602
+
3603
+ const db = getDb();
3604
+ const segments = db
3605
+ .select()
3606
+ .from(memorySegments)
3607
+ .where(eq(memorySegments.messageId, msg.id))
3608
+ .all();
3609
+
3610
+ expect(segments.length).toBeGreaterThan(0);
3611
+ for (const seg of segments) {
3612
+ expect(seg.scopeId).toBe('default');
3613
+ }
3614
+ });
3615
+
3616
+ // PR-18: extract_items jobs carry scopeId through the async pipeline
3617
+ test('extract_items job payload includes scopeId from private conversation', () => {
3618
+ const conv = createConversation({ title: 'Private scope job test', threadType: 'private' });
3619
+ expect(conv.memoryScopeId).toMatch(/^private:/);
3620
+
3621
+ addMessage(conv.id, 'user', 'Important data that should trigger extraction in private scope.');
3622
+
3623
+ const db = getDb();
3624
+ const extractJobs = db
3625
+ .select()
3626
+ .from(memoryJobs)
3627
+ .where(eq(memoryJobs.type, 'extract_items'))
3628
+ .all();
3629
+
3630
+ expect(extractJobs.length).toBeGreaterThan(0);
3631
+ const lastJob = extractJobs[extractJobs.length - 1];
3632
+ const payload = JSON.parse(lastJob.payload) as Record<string, unknown>;
3633
+ expect(payload.scopeId).toBe(conv.memoryScopeId);
3634
+ });
3635
+
3636
+ test('extract_items job payload defaults scopeId to default for standard conversations', () => {
3637
+ const conv = createConversation({ title: 'Standard scope job test', threadType: 'standard' });
3638
+ expect(conv.memoryScopeId).toBe('default');
3639
+
3640
+ addMessage(conv.id, 'user', 'Regular content for extraction in default scope.');
3641
+
3642
+ const db = getDb();
3643
+ const extractJobs = db
3644
+ .select()
3645
+ .from(memoryJobs)
3646
+ .where(eq(memoryJobs.type, 'extract_items'))
3647
+ .all();
3648
+
3649
+ expect(extractJobs.length).toBeGreaterThan(0);
3650
+ const lastJob = extractJobs[extractJobs.length - 1];
3651
+ const payload = JSON.parse(lastJob.payload) as Record<string, unknown>;
3652
+ expect(payload.scopeId).toBe('default');
3653
+ });
3654
+
3655
+ test('extract_items backward compat: old payloads without scopeId default to default', () => {
3656
+ // Simulate an old-style extract_items job without scopeId
3657
+ const jobId = enqueueMemoryJob('extract_items', { messageId: 'legacy-msg-id' });
3658
+
3659
+ const db = getDb();
3660
+ const job = db
3661
+ .select()
3662
+ .from(memoryJobs)
3663
+ .where(eq(memoryJobs.id, jobId))
3664
+ .get();
3665
+
3666
+ expect(job).toBeTruthy();
3667
+ const payload = JSON.parse(job!.payload) as Record<string, unknown>;
3668
+ // Old payloads will not have scopeId — consumers must default to 'default'
3669
+ expect(payload.scopeId).toBeUndefined();
3670
+
3671
+ // Verify the job handler's backward-compat logic: claim and inspect
3672
+ const claimed = claimMemoryJobs(100);
3673
+ const claimedJob = claimed.find((j) => j.id === jobId);
3674
+ expect(claimedJob).toBeTruthy();
3675
+ // The parsed payload should not have scopeId (it was never set)
3676
+ const resolvedScope = typeof claimedJob!.payload.scopeId === 'string' && claimedJob!.payload.scopeId
3677
+ ? claimedJob!.payload.scopeId
3678
+ : 'default';
3679
+ expect(resolvedScope).toBe('default');
3680
+ });
3681
+
3682
+ // PR-19: extractItemsJob forwards scopeId to extractAndUpsertMemoryItemsForMessage
3683
+ test('extractAndUpsertMemoryItemsForMessage accepts optional scopeId without breaking', async () => {
3684
+ const db = getDb();
3685
+ const now = Date.now();
3686
+
3687
+ db.insert(conversations).values({
3688
+ id: 'conv-scope-pass',
3689
+ title: null,
3690
+ createdAt: now,
3691
+ updatedAt: now,
3692
+ threadType: 'standard',
3693
+ memoryScopeId: 'default',
3694
+ }).run();
3695
+ db.insert(messages).values({
3696
+ id: 'msg-scope-pass',
3697
+ conversationId: 'conv-scope-pass',
3698
+ role: 'user',
3699
+ content: JSON.stringify([{ type: 'text', text: 'I prefer TypeScript over JavaScript for all new projects.' }]),
3700
+ createdAt: now,
3701
+ }).run();
3702
+
3703
+ // Call without scopeId — backward compat: should work exactly as before
3704
+ const withoutScope = await extractAndUpsertMemoryItemsForMessage('msg-scope-pass');
3705
+ expect(withoutScope).toBeGreaterThan(0);
3706
+
3707
+ // Call with explicit scopeId — should also succeed and scope items accordingly
3708
+ db.insert(messages).values({
3709
+ id: 'msg-scope-pass-2',
3710
+ conversationId: 'conv-scope-pass',
3711
+ role: 'user',
3712
+ content: JSON.stringify([{ type: 'text', text: 'I dislike using var in JavaScript, prefer const and let.' }]),
3713
+ createdAt: now + 1,
3714
+ }).run();
3715
+ const withScope = await extractAndUpsertMemoryItemsForMessage('msg-scope-pass-2', 'private:thread-42');
3716
+ expect(withScope).toBeGreaterThan(0);
3717
+ });
3718
+
3719
+ // PR-20: same statement in different scopes produces separate active items
3720
+ test('same statement in different scopes produces separate active memory items', async () => {
3721
+ const db = getDb();
3722
+ const now = Date.now();
3723
+
3724
+ db.insert(conversations).values({
3725
+ id: 'conv-scope-separate',
3726
+ title: null,
3727
+ createdAt: now,
3728
+ updatedAt: now,
3729
+ threadType: 'standard',
3730
+ memoryScopeId: 'default',
3731
+ }).run();
3732
+
3733
+ // Insert two messages with identical content
3734
+ db.insert(messages).values({
3735
+ id: 'msg-scope-default',
3736
+ conversationId: 'conv-scope-separate',
3737
+ role: 'user',
3738
+ content: JSON.stringify([{ type: 'text', text: 'I prefer dark mode for all my editors and terminals.' }]),
3739
+ createdAt: now,
3740
+ }).run();
3741
+ db.insert(messages).values({
3742
+ id: 'msg-scope-private',
3743
+ conversationId: 'conv-scope-separate',
3744
+ role: 'user',
3745
+ content: JSON.stringify([{ type: 'text', text: 'I prefer dark mode for all my editors and terminals.' }]),
3746
+ createdAt: now + 1,
3747
+ }).run();
3748
+
3749
+ // Extract into default scope
3750
+ const defaultCount = await extractAndUpsertMemoryItemsForMessage('msg-scope-default');
3751
+ expect(defaultCount).toBeGreaterThan(0);
3752
+
3753
+ // Extract identical statement into a private scope
3754
+ const privateCount = await extractAndUpsertMemoryItemsForMessage('msg-scope-private', 'private:thread-99');
3755
+ expect(privateCount).toBeGreaterThan(0);
3756
+
3757
+ // Both scopes should have separate active items
3758
+ const defaultItems = db.select().from(memoryItems)
3759
+ .where(and(eq(memoryItems.scopeId, 'default'), eq(memoryItems.status, 'active')))
3760
+ .all();
3761
+ const privateItems = db.select().from(memoryItems)
3762
+ .where(and(eq(memoryItems.scopeId, 'private:thread-99'), eq(memoryItems.status, 'active')))
3763
+ .all();
3764
+
3765
+ expect(defaultItems.length).toBeGreaterThan(0);
3766
+ expect(privateItems.length).toBeGreaterThan(0);
3767
+
3768
+ // Scope-salted fingerprints: same content in different scopes yields distinct fingerprints
3769
+ const defaultFingerprints = new Set(defaultItems.map(i => i.fingerprint));
3770
+ const matchingPrivate = privateItems.filter(i => defaultFingerprints.has(i.fingerprint));
3771
+ expect(matchingPrivate.length).toBe(0);
3772
+ });
3773
+
3774
+ // PR-21: identical fact in default vs private scopes gets distinct fingerprints
3775
+ test('identical content in different scopes produces distinct fingerprints', async () => {
3776
+ const db = getDb();
3777
+ const now = Date.now();
3778
+ const statement = 'I prefer using Vim keybindings in all my text editors.';
3779
+
3780
+ db.insert(conversations).values({
3781
+ id: 'conv-fp-salt',
3782
+ title: null,
3783
+ createdAt: now,
3784
+ updatedAt: now,
3785
+ threadType: 'standard',
3786
+ memoryScopeId: 'default',
3787
+ }).run();
3788
+
3789
+ db.insert(messages).values({
3790
+ id: 'msg-fp-salt-default',
3791
+ conversationId: 'conv-fp-salt',
3792
+ role: 'user',
3793
+ content: JSON.stringify([{ type: 'text', text: statement }]),
3794
+ createdAt: now,
3795
+ }).run();
3796
+ db.insert(messages).values({
3797
+ id: 'msg-fp-salt-private',
3798
+ conversationId: 'conv-fp-salt',
3799
+ role: 'user',
3800
+ content: JSON.stringify([{ type: 'text', text: statement }]),
3801
+ createdAt: now + 1,
3802
+ }).run();
3803
+
3804
+ await extractAndUpsertMemoryItemsForMessage('msg-fp-salt-default');
3805
+ await extractAndUpsertMemoryItemsForMessage('msg-fp-salt-private', 'private:fp-test');
3806
+
3807
+ const defaultItems = db.select().from(memoryItems)
3808
+ .where(eq(memoryItems.scopeId, 'default'))
3809
+ .all()
3810
+ .filter(i => i.statement === statement);
3811
+ const privateItems = db.select().from(memoryItems)
3812
+ .where(eq(memoryItems.scopeId, 'private:fp-test'))
3813
+ .all()
3814
+ .filter(i => i.statement === statement);
3815
+
3816
+ expect(defaultItems.length).toBe(1);
3817
+ expect(privateItems.length).toBe(1);
3818
+ // Same content, different scopes — fingerprints must differ
3819
+ expect(defaultItems[0].fingerprint).not.toBe(privateItems[0].fingerprint);
3820
+ // But the actual content should be identical
3821
+ expect(defaultItems[0].kind).toBe(privateItems[0].kind);
3822
+ expect(defaultItems[0].subject).toBe(privateItems[0].subject);
3823
+ expect(defaultItems[0].statement).toBe(privateItems[0].statement);
3824
+ });
3825
+
3826
+ // PR-20: default scope items are not affected by private scope operations
3827
+ test('default scope items are not superseded by private scope operations', async () => {
3828
+ const db = getDb();
3829
+ const now = Date.now();
3830
+
3831
+ db.insert(conversations).values({
3832
+ id: 'conv-scope-isolate',
3833
+ title: null,
3834
+ createdAt: now,
3835
+ updatedAt: now,
3836
+ threadType: 'standard',
3837
+ memoryScopeId: 'default',
3838
+ }).run();
3839
+
3840
+ // Insert a decision in the default scope
3841
+ db.insert(messages).values({
3842
+ id: 'msg-decision-default',
3843
+ conversationId: 'conv-scope-isolate',
3844
+ role: 'user',
3845
+ content: JSON.stringify([{ type: 'text', text: 'We decided to use PostgreSQL for the production database.' }]),
3846
+ createdAt: now,
3847
+ }).run();
3848
+ await extractAndUpsertMemoryItemsForMessage('msg-decision-default');
3849
+
3850
+ const defaultBefore = db.select().from(memoryItems)
3851
+ .where(and(eq(memoryItems.scopeId, 'default'), eq(memoryItems.status, 'active')))
3852
+ .all();
3853
+ expect(defaultBefore.length).toBeGreaterThan(0);
3854
+
3855
+ // Now insert a superseding decision in a private scope
3856
+ db.insert(messages).values({
3857
+ id: 'msg-decision-private',
3858
+ conversationId: 'conv-scope-isolate',
3859
+ role: 'user',
3860
+ content: JSON.stringify([{ type: 'text', text: 'We decided to use SQLite for the production database instead.' }]),
3861
+ createdAt: now + 1,
3862
+ }).run();
3863
+ await extractAndUpsertMemoryItemsForMessage('msg-decision-private', 'private:thread-55');
3864
+
3865
+ // The default scope items should still be active — private scope supersede must not affect them
3866
+ const defaultAfter = db.select().from(memoryItems)
3867
+ .where(and(eq(memoryItems.scopeId, 'default'), eq(memoryItems.status, 'active')))
3868
+ .all();
3869
+
3870
+ expect(defaultAfter.length).toBe(defaultBefore.length);
3871
+ for (const item of defaultAfter) {
3872
+ expect(item.status).toBe('active');
3873
+ }
3874
+ });
3875
+
3876
+ test('private conversation summary inherits private scope_id', async () => {
3877
+ const db = getDb();
3878
+ const conv = createConversation({ threadType: 'private' });
3879
+ const scopeId = getConversationMemoryScopeId(conv.id);
3880
+ expect(scopeId).toMatch(/^private:/);
3881
+
3882
+ // Insert messages and segments so the summarizer has input
3883
+ db.insert(messages).values({
3884
+ id: 'msg-priv-sum-1',
3885
+ conversationId: conv.id,
3886
+ role: 'user',
3887
+ content: JSON.stringify([{ type: 'text', text: 'Secret project details' }]),
3888
+ createdAt: conv.createdAt + 1,
3889
+ }).run();
3890
+ db.run(`
3891
+ INSERT INTO memory_segments (
3892
+ id, message_id, conversation_id, role, segment_index, text,
3893
+ token_estimate, scope_id, created_at, updated_at
3894
+ ) VALUES (
3895
+ 'seg-priv-sum-1', 'msg-priv-sum-1', '${conv.id}', 'user', 0,
3896
+ 'Secret project details', 5, '${scopeId}',
3897
+ ${conv.createdAt + 1}, ${conv.createdAt + 1}
3898
+ )
3899
+ `);
3900
+
3901
+ // Run the conversation summarizer
3902
+ const fakeJob = {
3903
+ id: 'job-priv-sum',
3904
+ type: 'build_conversation_summary' as const,
3905
+ payload: { conversationId: conv.id },
3906
+ status: 'running' as const,
3907
+ attempts: 0,
3908
+ deferrals: 0,
3909
+ runAfter: 0,
3910
+ lastError: null,
3911
+ createdAt: Date.now(),
3912
+ updatedAt: Date.now(),
3913
+ };
3914
+ await buildConversationSummaryJob(fakeJob, TEST_CONFIG);
3915
+
3916
+ const summary = db.select().from(memorySummaries)
3917
+ .where(and(
3918
+ eq(memorySummaries.scope, 'conversation'),
3919
+ eq(memorySummaries.scopeKey, conv.id),
3920
+ ))
3921
+ .get();
3922
+
3923
+ expect(summary).toBeDefined();
3924
+ expect(summary!.scopeId).toBe(scopeId);
3925
+ });
3926
+
3927
+ test('default-scope summary retrieval excludes private summaries', async () => {
3928
+ const db = getDb();
3929
+ const now = Date.now();
3930
+
3931
+ // Create a private conversation and build its summary
3932
+ const privConv = createConversation({ threadType: 'private' });
3933
+ const privScope = getConversationMemoryScopeId(privConv.id);
3934
+
3935
+ db.insert(messages).values({
3936
+ id: 'msg-scope-excl-1',
3937
+ conversationId: privConv.id,
3938
+ role: 'user',
3939
+ content: JSON.stringify([{ type: 'text', text: 'Private memo' }]),
3940
+ createdAt: now + 1,
3941
+ }).run();
3942
+ db.run(`
3943
+ INSERT INTO memory_segments (
3944
+ id, message_id, conversation_id, role, segment_index, text,
3945
+ token_estimate, scope_id, created_at, updated_at
3946
+ ) VALUES (
3947
+ 'seg-scope-excl-1', 'msg-scope-excl-1', '${privConv.id}', 'user', 0,
3948
+ 'Private memo', 3, '${privScope}',
3949
+ ${now + 1}, ${now + 1}
3950
+ )
3951
+ `);
3952
+
3953
+ await buildConversationSummaryJob({
3954
+ id: 'job-scope-excl-priv',
3955
+ type: 'build_conversation_summary' as const,
3956
+ payload: { conversationId: privConv.id },
3957
+ status: 'running' as const,
3958
+ attempts: 0, deferrals: 0, runAfter: 0, lastError: null,
3959
+ createdAt: now, updatedAt: now,
3960
+ }, TEST_CONFIG);
3961
+
3962
+ // Create a standard conversation and build its summary
3963
+ const stdConv = createConversation({ title: 'Standard conv' });
3964
+ const stdScope = getConversationMemoryScopeId(stdConv.id);
3965
+ expect(stdScope).toBe('default');
3966
+
3967
+ db.insert(messages).values({
3968
+ id: 'msg-scope-excl-2',
3969
+ conversationId: stdConv.id,
3970
+ role: 'user',
3971
+ content: JSON.stringify([{ type: 'text', text: 'Public notes' }]),
3972
+ createdAt: now + 2,
3973
+ }).run();
3974
+ db.run(`
3975
+ INSERT INTO memory_segments (
3976
+ id, message_id, conversation_id, role, segment_index, text,
3977
+ token_estimate, scope_id, created_at, updated_at
3978
+ ) VALUES (
3979
+ 'seg-scope-excl-2', 'msg-scope-excl-2', '${stdConv.id}', 'user', 0,
3980
+ 'Public notes', 3, 'default',
3981
+ ${now + 2}, ${now + 2}
3982
+ )
3983
+ `);
3984
+
3985
+ await buildConversationSummaryJob({
3986
+ id: 'job-scope-excl-std',
3987
+ type: 'build_conversation_summary' as const,
3988
+ payload: { conversationId: stdConv.id },
3989
+ status: 'running' as const,
3990
+ attempts: 0, deferrals: 0, runAfter: 0, lastError: null,
3991
+ createdAt: now, updatedAt: now,
3992
+ }, TEST_CONFIG);
3993
+
3994
+ // Query summaries scoped to 'default' — should only include the standard one
3995
+ const defaultSummaries = db.select().from(memorySummaries)
3996
+ .where(eq(memorySummaries.scopeId, 'default'))
3997
+ .all();
3998
+ const privateSummaries = db.select().from(memorySummaries)
3999
+ .where(eq(memorySummaries.scopeId, privScope))
4000
+ .all();
4001
+
4002
+ expect(defaultSummaries).toHaveLength(1);
4003
+ expect(defaultSummaries[0].scopeKey).toBe(stdConv.id);
4004
+
4005
+ expect(privateSummaries).toHaveLength(1);
4006
+ expect(privateSummaries[0].scopeKey).toBe(privConv.id);
4007
+ });
4008
+
4009
+ // ── End-to-end memory-boundary regression tests ─────────────────────
4010
+
4011
+ test('e2e: private-only facts are recalled in private thread but not in standard thread', async () => {
4012
+ const db = getDb();
4013
+
4014
+ // 1. Create a private conversation and add a message with a distinctive fact
4015
+ const privConv = createConversation({ title: 'Private e2e test', threadType: 'private' });
4016
+ const privScope = getConversationMemoryScopeId(privConv.id);
4017
+ expect(privScope).toMatch(/^private:/);
4018
+
4019
+ const privMsg = addMessage(
4020
+ privConv.id,
4021
+ 'user',
4022
+ 'I prefer using the Zephyr framework for all backend microservices.',
4023
+ );
4024
+
4025
+ // 2. Extract memory items — they inherit the private scope
4026
+ const upserted = await extractAndUpsertMemoryItemsForMessage(privMsg.id, privScope);
4027
+ expect(upserted).toBeGreaterThan(0);
4028
+
4029
+ // Verify items were stored with the private scope
4030
+ const privateItems = db
4031
+ .select()
4032
+ .from(memoryItems)
4033
+ .where(eq(memoryItems.scopeId, privScope))
4034
+ .all();
4035
+ expect(privateItems.length).toBeGreaterThan(0);
4036
+ expect(privateItems.some((i) => i.statement.toLowerCase().includes('zephyr'))).toBe(true);
4037
+
4038
+ // Collect the item IDs so we can check them in recall results
4039
+ const privateItemKeys = privateItems.map((i) => `item:${i.id}`);
4040
+
4041
+ // 3. Create a standard conversation for the "standard thread" perspective
4042
+ const stdConv = createConversation({ title: 'Standard e2e test', threadType: 'standard' });
4043
+ const stdScope = getConversationMemoryScopeId(stdConv.id);
4044
+ expect(stdScope).toBe('default');
4045
+
4046
+ db.insert(messages).values({
4047
+ id: 'msg-std-e2e-noleak',
4048
+ conversationId: stdConv.id,
4049
+ role: 'user',
4050
+ content: JSON.stringify([{ type: 'text', text: 'placeholder for standard conv' }]),
4051
+ createdAt: Date.now(),
4052
+ }).run();
4053
+
4054
+ const recallConfig = {
4055
+ ...TEST_CONFIG,
4056
+ memory: {
4057
+ ...TEST_CONFIG.memory,
4058
+ embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
4059
+ },
4060
+ };
4061
+
4062
+ // 4. Private thread recall — should find the Zephyr fact
4063
+ const privRecall = await buildMemoryRecall('Zephyr framework microservices', privConv.id, recallConfig, {
4064
+ scopePolicyOverride: {
4065
+ scopeId: privScope,
4066
+ fallbackToDefault: true,
4067
+ },
4068
+ });
4069
+ const privCandidateKeys = privRecall.topCandidates.map((c) => c.key);
4070
+ const hasZephyrInPrivate = privateItemKeys.some((k) => privCandidateKeys.includes(k));
4071
+ expect(hasZephyrInPrivate).toBe(true);
4072
+ expect(privRecall.injectedText.toLowerCase()).toContain('zephyr');
4073
+
4074
+ // 5. Standard thread recall — must NOT find the Zephyr fact (no leak)
4075
+ // Mirror the production call in session-memory.ts: for standard threads
4076
+ // (scopeId === 'default'), scopePolicyOverride is undefined.
4077
+ const stdRecall = await buildMemoryRecall('Zephyr framework microservices', stdConv.id, recallConfig, {
4078
+ scopeId: stdScope,
4079
+ scopePolicyOverride: undefined,
4080
+ });
4081
+ const stdCandidateKeys = stdRecall.topCandidates.map((c) => c.key);
4082
+ const hasZephyrInStandard = privateItemKeys.some((k) => stdCandidateKeys.includes(k));
4083
+ expect(hasZephyrInStandard).toBe(false);
4084
+ expect(stdRecall.injectedText.toLowerCase()).not.toContain('zephyr');
4085
+ });
4086
+
4087
+ test('e2e: private thread still recalls facts from default memory scope', async () => {
4088
+ const db = getDb();
4089
+ const now = Date.now();
4090
+
4091
+ // 1. Create a standard conversation and add a fact to default scope
4092
+ const stdConv = createConversation({ title: 'Default scope source', threadType: 'standard' });
4093
+ const stdScope = getConversationMemoryScopeId(stdConv.id);
4094
+ expect(stdScope).toBe('default');
4095
+
4096
+ const stdMsg = addMessage(
4097
+ stdConv.id,
4098
+ 'user',
4099
+ 'I prefer using the Obsidian editor for all my note-taking workflows.',
4100
+ );
4101
+
4102
+ const upsertedDefault = await extractAndUpsertMemoryItemsForMessage(stdMsg.id, stdScope);
4103
+ expect(upsertedDefault).toBeGreaterThan(0);
4104
+
4105
+ // Verify items landed in the default scope
4106
+ const defaultItems = db
4107
+ .select()
4108
+ .from(memoryItems)
4109
+ .where(and(eq(memoryItems.scopeId, 'default'), eq(memoryItems.status, 'active')))
4110
+ .all();
4111
+ const hasObsidian = defaultItems.some((i) => i.statement.toLowerCase().includes('obsidian'));
4112
+ expect(hasObsidian).toBe(true);
4113
+
4114
+ // Collect default item IDs containing "obsidian" for key-based verification
4115
+ const obsidianItemKeys = defaultItems
4116
+ .filter((i) => i.statement.toLowerCase().includes('obsidian'))
4117
+ .map((i) => `item:${i.id}`);
4118
+
4119
+ // 2. Create a private conversation
4120
+ const privConv = createConversation({ title: 'Private fallback test', threadType: 'private' });
4121
+ const privScope = getConversationMemoryScopeId(privConv.id);
4122
+ expect(privScope).toMatch(/^private:/);
4123
+
4124
+ db.insert(messages).values({
4125
+ id: 'msg-priv-e2e-fallback',
4126
+ conversationId: privConv.id,
4127
+ role: 'user',
4128
+ content: JSON.stringify([{ type: 'text', text: 'placeholder for private conv fallback test' }]),
4129
+ createdAt: now + 1,
4130
+ }).run();
4131
+
4132
+ const recallConfig = {
4133
+ ...TEST_CONFIG,
4134
+ memory: {
4135
+ ...TEST_CONFIG.memory,
4136
+ embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
4137
+ },
4138
+ };
4139
+
4140
+ // 3. Private thread recall with fallback to default — should find the Obsidian fact
4141
+ const privRecall = await buildMemoryRecall('Obsidian editor note-taking', privConv.id, recallConfig, {
4142
+ scopePolicyOverride: {
4143
+ scopeId: privScope,
4144
+ fallbackToDefault: true,
4145
+ },
4146
+ });
4147
+ const privCandidateKeys = privRecall.topCandidates.map((c) => c.key);
4148
+ const hasObsidianInPrivate = obsidianItemKeys.some((k) => privCandidateKeys.includes(k));
4149
+ expect(hasObsidianInPrivate).toBe(true);
4150
+ expect(privRecall.injectedText.toLowerCase()).toContain('obsidian');
4151
+ });
4152
+
4153
+ test('global weekly summary excludes private-scope memory items', async () => {
4154
+ const db = getDb();
4155
+ const now = new Date();
4156
+ const { startMs, endMs } = currentWeekWindow(now);
4157
+ const midMs = Math.floor((startMs + endMs) / 2);
4158
+
4159
+ // Insert a default-scope memory item within the current week window
4160
+ db.insert(memoryItems).values({
4161
+ id: 'item-global-weekly-default',
4162
+ kind: 'preference',
4163
+ subject: 'editor',
4164
+ statement: 'User prefers VSCode for all editing',
4165
+ status: 'active',
4166
+ confidence: 0.9,
4167
+ fingerprint: 'fp-global-weekly-default',
4168
+ scopeId: 'default',
4169
+ firstSeenAt: midMs,
4170
+ lastSeenAt: midMs,
4171
+ }).run();
4172
+
4173
+ // Insert a private-scope memory item within the same window
4174
+ db.insert(memoryItems).values({
4175
+ id: 'item-global-weekly-private',
4176
+ kind: 'preference',
4177
+ subject: 'secret-tool',
4178
+ statement: 'User uses SecretTool for private work',
4179
+ status: 'active',
4180
+ confidence: 0.9,
4181
+ fingerprint: 'fp-global-weekly-private',
4182
+ scopeId: 'private:thread-weekly-test',
4183
+ firstSeenAt: midMs,
4184
+ lastSeenAt: midMs,
4185
+ }).run();
4186
+
4187
+ const summaryConfig = {
4188
+ ...TEST_CONFIG,
4189
+ memory: {
4190
+ ...TEST_CONFIG.memory,
4191
+ summarization: {
4192
+ ...TEST_CONFIG.memory.summarization,
4193
+ useLLM: false,
4194
+ },
4195
+ },
4196
+ };
4197
+
4198
+ await buildGlobalSummaryJob('weekly_global', summaryConfig);
4199
+
4200
+ const summaries = db.select().from(memorySummaries)
4201
+ .where(eq(memorySummaries.scope, 'weekly_global'))
4202
+ .all();
4203
+
4204
+ expect(summaries).toHaveLength(1);
4205
+ const summaryText = summaries[0].summary.toLowerCase();
4206
+ // Default-scope content should appear
4207
+ expect(summaryText).toContain('vscode');
4208
+ // Private-scope content must NOT leak into the global summary
4209
+ expect(summaryText).not.toContain('secrettool');
4210
+ });
4211
+
4212
+ test('global monthly summary excludes private conversation summaries', async () => {
4213
+ const db = getDb();
4214
+ const now = new Date();
4215
+ const { startMs, endMs } = currentMonthWindow(now);
4216
+ const midMs = Math.floor((startMs + endMs) / 2);
4217
+
4218
+ // Insert a default-scope conversation summary within the current month
4219
+ db.insert(memorySummaries).values({
4220
+ id: 'summary-monthly-default',
4221
+ scope: 'conversation',
4222
+ scopeKey: 'conv-monthly-default',
4223
+ scopeId: 'default',
4224
+ summary: 'User discussed PublicFramework integration patterns',
4225
+ tokenEstimate: 10,
4226
+ version: 1,
4227
+ startAt: midMs - 1000,
4228
+ endAt: midMs,
4229
+ createdAt: midMs,
4230
+ updatedAt: midMs,
4231
+ }).run();
4232
+
4233
+ // Insert a private-scope conversation summary within the same month
4234
+ db.insert(memorySummaries).values({
4235
+ id: 'summary-monthly-private',
4236
+ scope: 'conversation',
4237
+ scopeKey: 'conv-monthly-private',
4238
+ scopeId: 'private:thread-monthly-test',
4239
+ summary: 'User discussed ConfidentialProject secret architecture',
4240
+ tokenEstimate: 10,
4241
+ version: 1,
4242
+ startAt: midMs - 1000,
4243
+ endAt: midMs,
4244
+ createdAt: midMs,
4245
+ updatedAt: midMs,
4246
+ }).run();
4247
+
4248
+ const summaryConfig = {
4249
+ ...TEST_CONFIG,
4250
+ memory: {
4251
+ ...TEST_CONFIG.memory,
4252
+ summarization: {
4253
+ ...TEST_CONFIG.memory.summarization,
4254
+ useLLM: false,
4255
+ },
4256
+ },
4257
+ };
4258
+
4259
+ await buildGlobalSummaryJob('monthly_global', summaryConfig);
4260
+
4261
+ const summaries = db.select().from(memorySummaries)
4262
+ .where(eq(memorySummaries.scope, 'monthly_global'))
4263
+ .all();
4264
+
4265
+ expect(summaries).toHaveLength(1);
4266
+ const summaryText = summaries[0].summary.toLowerCase();
4267
+ // Default-scope conversation summary content should appear
4268
+ expect(summaryText).toContain('publicframework');
4269
+ // Private-scope conversation summary content must NOT leak
4270
+ expect(summaryText).not.toContain('confidentialproject');
4271
+ });
4272
+
4273
+ // ── searchMemoryItems scopePolicyOverride tests ────────────────────
4274
+
4275
+ test('memory_search in private thread includes default-scope items via fallback', async () => {
4276
+ const db = getDb();
4277
+ const now = Date.now();
4278
+ const convId = 'conv-search-fb';
4279
+
4280
+ db.insert(conversations).values({
4281
+ id: convId,
4282
+ title: null,
4283
+ createdAt: now,
4284
+ updatedAt: now,
4285
+ totalInputTokens: 0,
4286
+ totalOutputTokens: 0,
4287
+ totalEstimatedCost: 0,
4288
+ contextSummary: null,
4289
+ contextCompactedMessageCount: 0,
4290
+ contextCompactedAt: null,
4291
+ }).run();
4292
+ db.insert(messages).values({
4293
+ id: 'msg-search-fb-1',
4294
+ conversationId: convId,
4295
+ role: 'user',
4296
+ content: JSON.stringify([{ type: 'text', text: 'search fallback test' }]),
4297
+ createdAt: now,
4298
+ }).run();
4299
+
4300
+ // Insert a default-scope segment with FTS so lexical search can find it
4301
+ db.run(`
4302
+ INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
4303
+ VALUES ('seg-search-fb-default', 'msg-search-fb-1', '${convId}', 'user', 0, 'The team uses Erlang for distributed message processing systems', 10, 'default', ${now}, ${now})
4304
+ `);
4305
+ db.run(`INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-search-fb-default', 'The team uses Erlang for distributed message processing systems')`);
4306
+
4307
+ const strictConfig = {
4308
+ ...TEST_CONFIG,
4309
+ memory: {
4310
+ ...TEST_CONFIG.memory,
4311
+ embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
4312
+ retrieval: {
4313
+ ...TEST_CONFIG.memory.retrieval,
4314
+ scopePolicy: 'strict' as const,
4315
+ },
4316
+ },
4317
+ };
4318
+
4319
+ // With the scopePolicyOverride, default-scope items should be included
4320
+ // even though the global policy is strict.
4321
+ const results = await searchMemoryItems(
4322
+ 'Erlang distributed message processing',
4323
+ 10,
4324
+ strictConfig,
4325
+ 'private:thread-search-test',
4326
+ { scopeId: 'private:thread-search-test', fallbackToDefault: true },
4327
+ );
4328
+
4329
+ const ids = results.map((r) => r.id);
4330
+ expect(ids).toContain('seg-search-fb-default');
4331
+ });
4332
+
4333
+ test('memory_search in private thread still returns private-scope items', async () => {
4334
+ const db = getDb();
4335
+ const now = Date.now();
4336
+ const convId = 'conv-search-priv';
4337
+ const privateScopeId = 'private:thread-search-priv';
4338
+
4339
+ db.insert(conversations).values({
4340
+ id: convId,
4341
+ title: null,
4342
+ createdAt: now,
4343
+ updatedAt: now,
4344
+ totalInputTokens: 0,
4345
+ totalOutputTokens: 0,
4346
+ totalEstimatedCost: 0,
4347
+ contextSummary: null,
4348
+ contextCompactedMessageCount: 0,
4349
+ contextCompactedAt: null,
4350
+ }).run();
4351
+ db.insert(messages).values({
4352
+ id: 'msg-search-priv-1',
4353
+ conversationId: convId,
4354
+ role: 'user',
4355
+ content: JSON.stringify([{ type: 'text', text: 'search private scope test' }]),
4356
+ createdAt: now,
4357
+ }).run();
4358
+
4359
+ // Insert a private-scope segment with FTS
4360
+ db.run(`
4361
+ INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
4362
+ VALUES ('seg-search-priv-scope', 'msg-search-priv-1', '${convId}', 'user', 0, 'User prefers Haskell for type-safe functional programming', 10, '${privateScopeId}', ${now}, ${now})
4363
+ `);
4364
+ db.run(`INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-search-priv-scope', 'User prefers Haskell for type-safe functional programming')`);
4365
+
4366
+ const strictConfig = {
4367
+ ...TEST_CONFIG,
4368
+ memory: {
4369
+ ...TEST_CONFIG.memory,
4370
+ embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
4371
+ retrieval: {
4372
+ ...TEST_CONFIG.memory.retrieval,
4373
+ scopePolicy: 'strict' as const,
4374
+ },
4375
+ },
4376
+ };
4377
+
4378
+ const results = await searchMemoryItems(
4379
+ 'Haskell functional programming',
4380
+ 10,
4381
+ strictConfig,
4382
+ privateScopeId,
4383
+ { scopeId: privateScopeId, fallbackToDefault: true },
4384
+ );
4385
+
4386
+ const ids = results.map((r) => r.id);
4387
+ expect(ids).toContain('seg-search-priv-scope');
4388
+ });
4389
+
4390
+ // Backfill preserves private conversation scope on memory segments
4391
+ test('backfillJob preserves private conversation scope during reindex', () => {
4392
+ const db = getDb();
4393
+
4394
+ // Create a private conversation with a message
4395
+ const conv = createConversation({ title: 'Backfill scope test', threadType: 'private' });
4396
+ expect(conv.memoryScopeId).toMatch(/^private:/);
4397
+
4398
+ // Insert a message directly (bypassing addMessage to avoid pre-indexing)
4399
+ const msgId = 'msg-backfill-scope-test';
4400
+ db.insert(messages).values({
4401
+ id: msgId,
4402
+ conversationId: conv.id,
4403
+ role: 'user',
4404
+ content: 'My confidential backfill test content for private thread preservation.',
4405
+ createdAt: conv.createdAt + 1,
4406
+ }).run();
4407
+
4408
+ // Run the backfill job — it should look up the conversation scope
4409
+ const fakeJob = {
4410
+ id: 'job-backfill-scope',
4411
+ type: 'backfill' as const,
4412
+ payload: { force: true },
4413
+ status: 'running' as const,
4414
+ attempts: 0,
4415
+ deferrals: 0,
4416
+ runAfter: 0,
4417
+ lastError: null,
4418
+ createdAt: Date.now(),
4419
+ updatedAt: Date.now(),
4420
+ };
4421
+ backfillJob(fakeJob, TEST_CONFIG);
4422
+
4423
+ // Verify the segments were indexed with the private scope
4424
+ const segments = db
4425
+ .select()
4426
+ .from(memorySegments)
4427
+ .where(eq(memorySegments.messageId, msgId))
4428
+ .all();
4429
+
4430
+ expect(segments.length).toBeGreaterThan(0);
4431
+ for (const seg of segments) {
4432
+ expect(seg.scopeId).toBe(conv.memoryScopeId);
4433
+ }
4434
+ });
4435
+ });