mixdog 0.7.1

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 (404) hide show
  1. package/.claude-plugin/marketplace.json +31 -0
  2. package/.claude-plugin/plugin.json +20 -0
  3. package/.gitattributes +34 -0
  4. package/.mcp.json +14 -0
  5. package/ARCHITECTURE.md +77 -0
  6. package/CHANGELOG.md +7 -0
  7. package/CONTRIBUTING.md +45 -0
  8. package/DATA-FLOW.md +79 -0
  9. package/LICENSE +21 -0
  10. package/README.md +389 -0
  11. package/SECURITY.md +138 -0
  12. package/UNINSTALL.md +112 -0
  13. package/agents/maintenance.md +5 -0
  14. package/agents/memory-classification.md +30 -0
  15. package/agents/scheduler-task.md +18 -0
  16. package/agents/webhook-handler.md +27 -0
  17. package/agents/worker.md +24 -0
  18. package/bin/bridge +133 -0
  19. package/bin/statusline-launcher.mjs +78 -0
  20. package/bin/statusline-lib.mjs +550 -0
  21. package/bin/statusline.mjs +607 -0
  22. package/bun.lock +802 -0
  23. package/commands/config.md +16 -0
  24. package/commands/doctor.md +13 -0
  25. package/commands/setup.md +17 -0
  26. package/defaults/cycle3-review-prompt.md +90 -0
  27. package/defaults/hidden-roles.json +65 -0
  28. package/defaults/memory-chunk-prompt.md +63 -0
  29. package/defaults/memory-promote-prompt.md +135 -0
  30. package/defaults/mixdog-config.template.json +27 -0
  31. package/defaults/user-workflow.json +8 -0
  32. package/defaults/user-workflow.md +12 -0
  33. package/hooks/hooks.json +73 -0
  34. package/hooks/lib/active-instance.cjs +77 -0
  35. package/hooks/lib/permission-evaluator.cjs +411 -0
  36. package/hooks/lib/permission-route.cjs +63 -0
  37. package/hooks/lib/permission-rules.cjs +170 -0
  38. package/hooks/lib/settings-loader.cjs +116 -0
  39. package/hooks/post-tool-use.cjs +84 -0
  40. package/hooks/pre-mcp-sandbox.cjs +158 -0
  41. package/hooks/pre-tool-subagent.cjs +253 -0
  42. package/hooks/session-start.cjs +1372 -0
  43. package/hooks/turn-timer.cjs +82 -0
  44. package/lib/claude-md-writer.cjs +386 -0
  45. package/lib/config-cjs.cjs +61 -0
  46. package/lib/hook-pipe-path.cjs +10 -0
  47. package/lib/keychain-cjs.cjs +263 -0
  48. package/lib/plugin-paths.cjs +61 -0
  49. package/lib/rules-builder.cjs +241 -0
  50. package/lib/text-utils.cjs +61 -0
  51. package/native/README.md +117 -0
  52. package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
  53. package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
  54. package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
  55. package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
  56. package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
  57. package/package.json +107 -0
  58. package/prompts/code-review.txt +16 -0
  59. package/prompts/security-audit.txt +17 -0
  60. package/rules/bridge/00-common.md +39 -0
  61. package/rules/bridge/20-skip-protocol.md +18 -0
  62. package/rules/bridge/30-explorer.md +33 -0
  63. package/rules/bridge/40-cycle1-agent.md +52 -0
  64. package/rules/bridge/41-cycle2-agent.md +62 -0
  65. package/rules/bridge/42-cycle3-agent.md +44 -0
  66. package/rules/lead/00-tool-lead.md +61 -0
  67. package/rules/lead/01-general.md +23 -0
  68. package/rules/lead/02-channels.md +49 -0
  69. package/rules/lead/03-team.md +27 -0
  70. package/rules/lead/04-workflow.md +20 -0
  71. package/rules/shared/00-language.md +14 -0
  72. package/rules/shared/01-tool.md +138 -0
  73. package/scripts/bootstrap.mjs +184 -0
  74. package/scripts/bridge-unify-smoke.mjs +308 -0
  75. package/scripts/build-runtime-linux.sh +348 -0
  76. package/scripts/build-runtime-macos.sh +217 -0
  77. package/scripts/build-runtime-windows.ps1 +242 -0
  78. package/scripts/builtin-utils-smoke.mjs +392 -0
  79. package/scripts/check-json.mjs +45 -0
  80. package/scripts/check-syntax-changed.mjs +102 -0
  81. package/scripts/check-syntax.mjs +58 -0
  82. package/scripts/code-graph-batch.test.mjs +33 -0
  83. package/scripts/config-preserve-smoke.mjs +180 -0
  84. package/scripts/doctor.mjs +484 -0
  85. package/scripts/edit-normalize-fuzz.mjs +130 -0
  86. package/scripts/edit-normalize-smoke.mjs +401 -0
  87. package/scripts/edit-operation-smoke.mjs +369 -0
  88. package/scripts/edit2-smoke.mjs +63 -0
  89. package/scripts/fuzzy-e2e.mjs +28 -0
  90. package/scripts/fuzzy-smoke.mjs +26 -0
  91. package/scripts/generate-runtime-manifest.mjs +166 -0
  92. package/scripts/guard-smoke.mjs +66 -0
  93. package/scripts/hidden-role-schema-smoke.mjs +162 -0
  94. package/scripts/hook-routing-smoke.mjs +29 -0
  95. package/scripts/inject-input.ps1 +204 -0
  96. package/scripts/io-complex-smoke.mjs +667 -0
  97. package/scripts/io-explore-bench.mjs +424 -0
  98. package/scripts/io-guardrails-smoke.mjs +205 -0
  99. package/scripts/io-mini-bench-baseline.json +11 -0
  100. package/scripts/io-mini-bench.mjs +216 -0
  101. package/scripts/io-route-harness.mjs +933 -0
  102. package/scripts/io-telemetry-report.mjs +691 -0
  103. package/scripts/mutation-bench.mjs +564 -0
  104. package/scripts/mutation-io-smoke.mjs +1081 -0
  105. package/scripts/native-patch-bridge-smoke.mjs +288 -0
  106. package/scripts/native-patch-smoke.mjs +304 -0
  107. package/scripts/patch-interior-context-smoke.mjs +49 -0
  108. package/scripts/patch-newline-utf8-smoke.mjs +157 -0
  109. package/scripts/perf-hook-smoke.mjs +71 -0
  110. package/scripts/permission-eval-smoke.mjs +426 -0
  111. package/scripts/prep-patch.mjs +53 -0
  112. package/scripts/prep-shim.mjs +96 -0
  113. package/scripts/provider-cache-smoke.mjs +687 -0
  114. package/scripts/report-runtime-health.mjs +132 -0
  115. package/scripts/run-mcp.mjs +1547 -0
  116. package/scripts/salvage-v4a-shatter.test.mjs +58 -0
  117. package/scripts/scoped-cache-io-smoke.mjs +103 -0
  118. package/scripts/shell-policy-round3-smoke.mjs +46 -0
  119. package/scripts/smoke-runtime-negative.ps1 +100 -0
  120. package/scripts/smoke-runtime-negative.sh +95 -0
  121. package/scripts/stall-policy-smoke.mjs +50 -0
  122. package/scripts/start-memory-worker.mjs +23 -0
  123. package/scripts/statusline-launcher-smoke.mjs +82 -0
  124. package/scripts/stress-atomic-write.mjs +1028 -0
  125. package/scripts/test-config-rmw-restore.mjs +122 -0
  126. package/scripts/test-fault-inject.mjs +164 -0
  127. package/scripts/test-large-file.mjs +174 -0
  128. package/scripts/tool-edge-smoke.mjs +209 -0
  129. package/scripts/uninstall.mjs +201 -0
  130. package/scripts/webhook-selfheal-smoke.mjs +29 -0
  131. package/scripts/write-overwrite-guard-smoke.mjs +56 -0
  132. package/server-main.mjs +3055 -0
  133. package/server.mjs +468 -0
  134. package/setup/config-merge.mjs +254 -0
  135. package/setup/install.mjs +120 -0
  136. package/setup/launch-core.mjs +507 -0
  137. package/setup/launch.mjs +101 -0
  138. package/setup/setup-server.mjs +3206 -0
  139. package/setup/setup.html +3693 -0
  140. package/skills/retro-skill-proposer/SKILL.md +92 -0
  141. package/skills/schedule-add/SKILL.md +77 -0
  142. package/skills/setup/SKILL.md +346 -0
  143. package/skills/webhook-add/SKILL.md +81 -0
  144. package/src/agent/bridge-stall-watchdog.mjs +337 -0
  145. package/src/agent/index.mjs +2138 -0
  146. package/src/agent/orchestrator/activity-bus.mjs +38 -0
  147. package/src/agent/orchestrator/ai-wrapped-dispatch.mjs +1010 -0
  148. package/src/agent/orchestrator/bridge-retry.mjs +220 -0
  149. package/src/agent/orchestrator/bridge-trace.mjs +583 -0
  150. package/src/agent/orchestrator/cache-mtime.mjs +58 -0
  151. package/src/agent/orchestrator/config.mjs +358 -0
  152. package/src/agent/orchestrator/context/collect.mjs +651 -0
  153. package/src/agent/orchestrator/dispatch-persist.mjs +549 -0
  154. package/src/agent/orchestrator/drain-registry.mjs +50 -0
  155. package/src/agent/orchestrator/explore-validator.mjs +8 -0
  156. package/src/agent/orchestrator/internal-roles.mjs +118 -0
  157. package/src/agent/orchestrator/internal-tools.mjs +88 -0
  158. package/src/agent/orchestrator/jobs.mjs +116 -0
  159. package/src/agent/orchestrator/mcp/client.mjs +364 -0
  160. package/src/agent/orchestrator/providers/anthropic-betas.mjs +21 -0
  161. package/src/agent/orchestrator/providers/anthropic-oauth.mjs +1745 -0
  162. package/src/agent/orchestrator/providers/anthropic.mjs +437 -0
  163. package/src/agent/orchestrator/providers/gemini.mjs +1175 -0
  164. package/src/agent/orchestrator/providers/grok-oauth.mjs +782 -0
  165. package/src/agent/orchestrator/providers/model-catalog.mjs +241 -0
  166. package/src/agent/orchestrator/providers/openai-compat.mjs +1467 -0
  167. package/src/agent/orchestrator/providers/openai-oauth-ws.mjs +1890 -0
  168. package/src/agent/orchestrator/providers/openai-oauth.mjs +1307 -0
  169. package/src/agent/orchestrator/providers/openai-ws.mjs +104 -0
  170. package/src/agent/orchestrator/providers/registry.mjs +192 -0
  171. package/src/agent/orchestrator/providers/retry-classifier.mjs +325 -0
  172. package/src/agent/orchestrator/session/abort-lookup.mjs +13 -0
  173. package/src/agent/orchestrator/session/cache/post-edit-marks.mjs +42 -0
  174. package/src/agent/orchestrator/session/cache/prefetch-cache.mjs +142 -0
  175. package/src/agent/orchestrator/session/cache/read-cache.mjs +319 -0
  176. package/src/agent/orchestrator/session/cache/scoped-cache-outcome.mjs +11 -0
  177. package/src/agent/orchestrator/session/cache/scoped-cache.mjs +361 -0
  178. package/src/agent/orchestrator/session/cache/util.mjs +49 -0
  179. package/src/agent/orchestrator/session/loop.mjs +1478 -0
  180. package/src/agent/orchestrator/session/manager.mjs +1975 -0
  181. package/src/agent/orchestrator/session/read-dedup.mjs +6 -0
  182. package/src/agent/orchestrator/session/result-classification.mjs +65 -0
  183. package/src/agent/orchestrator/session/save-session-worker.mjs +18 -0
  184. package/src/agent/orchestrator/session/store.mjs +624 -0
  185. package/src/agent/orchestrator/session/stream-watchdog.mjs +130 -0
  186. package/src/agent/orchestrator/session/tool-result-offload.mjs +166 -0
  187. package/src/agent/orchestrator/session/trim.mjs +491 -0
  188. package/src/agent/orchestrator/smart-bridge/CACHE-SHARD.md +115 -0
  189. package/src/agent/orchestrator/smart-bridge/bridge-llm.mjs +327 -0
  190. package/src/agent/orchestrator/smart-bridge/cache-obs.mjs +150 -0
  191. package/src/agent/orchestrator/smart-bridge/cache-strategy.mjs +228 -0
  192. package/src/agent/orchestrator/smart-bridge/index.mjs +215 -0
  193. package/src/agent/orchestrator/smart-bridge/profiles.mjs +37 -0
  194. package/src/agent/orchestrator/smart-bridge/registry.mjs +348 -0
  195. package/src/agent/orchestrator/smart-bridge/session-builder.mjs +116 -0
  196. package/src/agent/orchestrator/stall-policy.mjs +195 -0
  197. package/src/agent/orchestrator/tool-loop-guard.mjs +75 -0
  198. package/src/agent/orchestrator/tools/bash-policy-scan.mjs +77 -0
  199. package/src/agent/orchestrator/tools/bash-session.mjs +721 -0
  200. package/src/agent/orchestrator/tools/builtin/advisory-lock.mjs +171 -0
  201. package/src/agent/orchestrator/tools/builtin/arg-guard.mjs +455 -0
  202. package/src/agent/orchestrator/tools/builtin/atomic-write.mjs +236 -0
  203. package/src/agent/orchestrator/tools/builtin/bash-tool.mjs +480 -0
  204. package/src/agent/orchestrator/tools/builtin/binary-file.mjs +76 -0
  205. package/src/agent/orchestrator/tools/builtin/builtin-tools.mjs +256 -0
  206. package/src/agent/orchestrator/tools/builtin/cache-layers.mjs +386 -0
  207. package/src/agent/orchestrator/tools/builtin/cwd-utils.mjs +37 -0
  208. package/src/agent/orchestrator/tools/builtin/device-paths.mjs +154 -0
  209. package/src/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +292 -0
  210. package/src/agent/orchestrator/tools/builtin/diff-utils.mjs +109 -0
  211. package/src/agent/orchestrator/tools/builtin/edit-base-guard.mjs +58 -0
  212. package/src/agent/orchestrator/tools/builtin/edit-byte-plan.mjs +240 -0
  213. package/src/agent/orchestrator/tools/builtin/edit-byte-utils.mjs +113 -0
  214. package/src/agent/orchestrator/tools/builtin/edit-commit.mjs +74 -0
  215. package/src/agent/orchestrator/tools/builtin/edit-context-utils.mjs +242 -0
  216. package/src/agent/orchestrator/tools/builtin/edit-diagnostics.mjs +211 -0
  217. package/src/agent/orchestrator/tools/builtin/edit-engine.mjs +1364 -0
  218. package/src/agent/orchestrator/tools/builtin/edit-failure-context.mjs +126 -0
  219. package/src/agent/orchestrator/tools/builtin/edit-hint.mjs +141 -0
  220. package/src/agent/orchestrator/tools/builtin/edit-match-utils.mjs +194 -0
  221. package/src/agent/orchestrator/tools/builtin/edit-partial-write.mjs +60 -0
  222. package/src/agent/orchestrator/tools/builtin/edit-stale-refresh.mjs +168 -0
  223. package/src/agent/orchestrator/tools/builtin/edit-tool.mjs +173 -0
  224. package/src/agent/orchestrator/tools/builtin/edit-utf8-guard.mjs +48 -0
  225. package/src/agent/orchestrator/tools/builtin/fs-reachability.mjs +48 -0
  226. package/src/agent/orchestrator/tools/builtin/fuzzy-match.mjs +99 -0
  227. package/src/agent/orchestrator/tools/builtin/glob-walk.mjs +170 -0
  228. package/src/agent/orchestrator/tools/builtin/grep-formatting.mjs +113 -0
  229. package/src/agent/orchestrator/tools/builtin/hash-utils.mjs +6 -0
  230. package/src/agent/orchestrator/tools/builtin/list-formatting.mjs +7 -0
  231. package/src/agent/orchestrator/tools/builtin/list-tool.mjs +593 -0
  232. package/src/agent/orchestrator/tools/builtin/native-edit-runner.mjs +89 -0
  233. package/src/agent/orchestrator/tools/builtin/notebook-edit-tool.mjs +300 -0
  234. package/src/agent/orchestrator/tools/builtin/open-config-tool.mjs +26 -0
  235. package/src/agent/orchestrator/tools/builtin/path-diagnostics.mjs +152 -0
  236. package/src/agent/orchestrator/tools/builtin/path-locks.mjs +35 -0
  237. package/src/agent/orchestrator/tools/builtin/path-utils.mjs +201 -0
  238. package/src/agent/orchestrator/tools/builtin/read-args.mjs +103 -0
  239. package/src/agent/orchestrator/tools/builtin/read-batch.mjs +172 -0
  240. package/src/agent/orchestrator/tools/builtin/read-constants.mjs +40 -0
  241. package/src/agent/orchestrator/tools/builtin/read-formatting.mjs +118 -0
  242. package/src/agent/orchestrator/tools/builtin/read-image-resize.mjs +189 -0
  243. package/src/agent/orchestrator/tools/builtin/read-image.mjs +88 -0
  244. package/src/agent/orchestrator/tools/builtin/read-lines.mjs +12 -0
  245. package/src/agent/orchestrator/tools/builtin/read-mode-tool.mjs +455 -0
  246. package/src/agent/orchestrator/tools/builtin/read-open.mjs +190 -0
  247. package/src/agent/orchestrator/tools/builtin/read-range-index.mjs +271 -0
  248. package/src/agent/orchestrator/tools/builtin/read-ranges.mjs +26 -0
  249. package/src/agent/orchestrator/tools/builtin/read-single-tool.mjs +728 -0
  250. package/src/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +173 -0
  251. package/src/agent/orchestrator/tools/builtin/read-special-files.mjs +268 -0
  252. package/src/agent/orchestrator/tools/builtin/read-streaming.mjs +602 -0
  253. package/src/agent/orchestrator/tools/builtin/read-tool.mjs +530 -0
  254. package/src/agent/orchestrator/tools/builtin/read-windows.mjs +107 -0
  255. package/src/agent/orchestrator/tools/builtin/rename-tool.mjs +196 -0
  256. package/src/agent/orchestrator/tools/builtin/rg-runner.mjs +422 -0
  257. package/src/agent/orchestrator/tools/builtin/search-builders.mjs +158 -0
  258. package/src/agent/orchestrator/tools/builtin/search-tool.mjs +869 -0
  259. package/src/agent/orchestrator/tools/builtin/shell-analysis.mjs +653 -0
  260. package/src/agent/orchestrator/tools/builtin/shell-jobs.mjs +936 -0
  261. package/src/agent/orchestrator/tools/builtin/shell-output.mjs +36 -0
  262. package/src/agent/orchestrator/tools/builtin/shell-runtime.mjs +214 -0
  263. package/src/agent/orchestrator/tools/builtin/snapshot-helpers.mjs +143 -0
  264. package/src/agent/orchestrator/tools/builtin/snapshot-store.mjs +206 -0
  265. package/src/agent/orchestrator/tools/builtin/snapshot-validation.mjs +98 -0
  266. package/src/agent/orchestrator/tools/builtin/text-stats.mjs +69 -0
  267. package/src/agent/orchestrator/tools/builtin/windows-roots.mjs +23 -0
  268. package/src/agent/orchestrator/tools/builtin/write-tool.mjs +401 -0
  269. package/src/agent/orchestrator/tools/builtin.mjs +500 -0
  270. package/src/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +39 -0
  271. package/src/agent/orchestrator/tools/code-graph-tool-defs.mjs +24 -0
  272. package/src/agent/orchestrator/tools/code-graph.mjs +4095 -0
  273. package/src/agent/orchestrator/tools/cwd-tool.mjs +298 -0
  274. package/src/agent/orchestrator/tools/destructive-warning.mjs +323 -0
  275. package/src/agent/orchestrator/tools/edit-normalize.mjs +603 -0
  276. package/src/agent/orchestrator/tools/env-scrub.mjs +100 -0
  277. package/src/agent/orchestrator/tools/graph-binary-fetcher.mjs +144 -0
  278. package/src/agent/orchestrator/tools/graph-manifest.json +26 -0
  279. package/src/agent/orchestrator/tools/host-input.mjs +204 -0
  280. package/src/agent/orchestrator/tools/mutation-content-cache.mjs +67 -0
  281. package/src/agent/orchestrator/tools/mutation-planner.mjs +75 -0
  282. package/src/agent/orchestrator/tools/next-call-utils.mjs +48 -0
  283. package/src/agent/orchestrator/tools/patch-binary-fetcher.mjs +133 -0
  284. package/src/agent/orchestrator/tools/patch-manifest.json +26 -0
  285. package/src/agent/orchestrator/tools/patch-tool-defs.mjs +20 -0
  286. package/src/agent/orchestrator/tools/patch.mjs +2754 -0
  287. package/src/agent/orchestrator/tools/progress-message.mjs +118 -0
  288. package/src/agent/orchestrator/tools/result-compression.mjs +279 -0
  289. package/src/agent/orchestrator/tools/shell-command.mjs +865 -0
  290. package/src/agent/orchestrator/tools/shell-exec-policy.mjs +89 -0
  291. package/src/agent/orchestrator/tools/shell-policy-danger-target.mjs +27 -0
  292. package/src/agent/orchestrator/tools/shell-policy-imports.mjs +7 -0
  293. package/src/agent/orchestrator/tools/shell-policy.mjs +345 -0
  294. package/src/agent/orchestrator/tools/shell-snapshot.mjs +313 -0
  295. package/src/agent/orchestrator/workflow-store.mjs +93 -0
  296. package/src/agent/tool-defs.mjs +103 -0
  297. package/src/channels/backends/discord.mjs +784 -0
  298. package/src/channels/data/voice-runtime-manifest.json +138 -0
  299. package/src/channels/index.mjs +3229 -0
  300. package/src/channels/lib/cli-worker-host.mjs +12 -0
  301. package/src/channels/lib/config-lock.mjs +13 -0
  302. package/src/channels/lib/config.mjs +292 -0
  303. package/src/channels/lib/drop-trace.mjs +71 -0
  304. package/src/channels/lib/event-pipeline.mjs +81 -0
  305. package/src/channels/lib/event-queue.mjs +345 -0
  306. package/src/channels/lib/executor.mjs +168 -0
  307. package/src/channels/lib/format.mjs +188 -0
  308. package/src/channels/lib/holidays.mjs +138 -0
  309. package/src/channels/lib/hook-pipe-server.mjs +802 -0
  310. package/src/channels/lib/interaction-workflows.mjs +184 -0
  311. package/src/channels/lib/memory-client.mjs +149 -0
  312. package/src/channels/lib/output-forwarder.mjs +765 -0
  313. package/src/channels/lib/runtime-paths.mjs +479 -0
  314. package/src/channels/lib/scheduler.mjs +723 -0
  315. package/src/channels/lib/session-control.mjs +36 -0
  316. package/src/channels/lib/session-discovery.mjs +103 -0
  317. package/src/channels/lib/settings.mjs +11 -0
  318. package/src/channels/lib/state-file.mjs +68 -0
  319. package/src/channels/lib/status-snapshot.mjs +219 -0
  320. package/src/channels/lib/tool-format.mjs +140 -0
  321. package/src/channels/lib/transcript-discovery.mjs +195 -0
  322. package/src/channels/lib/voice-runtime-fetcher.mjs +734 -0
  323. package/src/channels/lib/webhook.mjs +1179 -0
  324. package/src/channels/lib/whisper-server.mjs +477 -0
  325. package/src/channels/tool-defs.mjs +170 -0
  326. package/src/daemon/host.mjs +118 -0
  327. package/src/daemon/mcp-transport.mjs +47 -0
  328. package/src/daemon/session.mjs +100 -0
  329. package/src/daemon/thin-client.mjs +71 -0
  330. package/src/daemon/transport.mjs +163 -0
  331. package/src/memory/data/runtime-manifest.json +40 -0
  332. package/src/memory/index.mjs +3305 -0
  333. package/src/memory/lib/agent-ipc.mjs +93 -0
  334. package/src/memory/lib/bridge-trace-queries.mjs +120 -0
  335. package/src/memory/lib/core-memory-store.mjs +330 -0
  336. package/src/memory/lib/embedding-provider.mjs +269 -0
  337. package/src/memory/lib/embedding-worker.mjs +323 -0
  338. package/src/memory/lib/llm-worker-host.mjs +17 -0
  339. package/src/memory/lib/memory-cycle.mjs +11 -0
  340. package/src/memory/lib/memory-cycle1.mjs +641 -0
  341. package/src/memory/lib/memory-cycle2.mjs +1284 -0
  342. package/src/memory/lib/memory-cycle3.mjs +540 -0
  343. package/src/memory/lib/memory-embed.mjs +299 -0
  344. package/src/memory/lib/memory-extraction.mjs +5 -0
  345. package/src/memory/lib/memory-maintenance-store.mjs +32 -0
  346. package/src/memory/lib/memory-ops-policy.mjs +190 -0
  347. package/src/memory/lib/memory-recall-id-patch.mjs +15 -0
  348. package/src/memory/lib/memory-recall-read-query.mjs +7 -0
  349. package/src/memory/lib/memory-recall-scope-filter.mjs +63 -0
  350. package/src/memory/lib/memory-recall-store.mjs +621 -0
  351. package/src/memory/lib/memory-retrievers.mjs +112 -0
  352. package/src/memory/lib/memory-score.mjs +71 -0
  353. package/src/memory/lib/memory-text-utils.mjs +58 -0
  354. package/src/memory/lib/memory.mjs +412 -0
  355. package/src/memory/lib/model-profile.mjs +85 -0
  356. package/src/memory/lib/pg/adapter.mjs +308 -0
  357. package/src/memory/lib/pg/process.mjs +360 -0
  358. package/src/memory/lib/pg/supervisor.mjs +396 -0
  359. package/src/memory/lib/project-id-resolver.mjs +86 -0
  360. package/src/memory/lib/runtime-fetcher.mjs +442 -0
  361. package/src/memory/lib/trace-store.mjs +728 -0
  362. package/src/memory/tool-defs.mjs +79 -0
  363. package/src/search/index.mjs +1173 -0
  364. package/src/search/lib/backends/anthropic-oauth.mjs +98 -0
  365. package/src/search/lib/backends/exa.mjs +50 -0
  366. package/src/search/lib/backends/firecrawl.mjs +61 -0
  367. package/src/search/lib/backends/gemini-api.mjs +83 -0
  368. package/src/search/lib/backends/grok-oauth.mjs +86 -0
  369. package/src/search/lib/backends/index.mjs +150 -0
  370. package/src/search/lib/backends/openai-api.mjs +144 -0
  371. package/src/search/lib/backends/openai-oauth.mjs +98 -0
  372. package/src/search/lib/backends/openai-web-search.mjs +76 -0
  373. package/src/search/lib/backends/tavily.mjs +55 -0
  374. package/src/search/lib/backends/xai-api.mjs +113 -0
  375. package/src/search/lib/cache.mjs +131 -0
  376. package/src/search/lib/config.mjs +192 -0
  377. package/src/search/lib/formatter.mjs +115 -0
  378. package/src/search/lib/provider-usage.mjs +67 -0
  379. package/src/search/lib/providers.mjs +47 -0
  380. package/src/search/lib/search-intent.mjs +109 -0
  381. package/src/search/lib/setup-handler.mjs +261 -0
  382. package/src/search/lib/state.mjs +201 -0
  383. package/src/search/lib/web-tools.mjs +1207 -0
  384. package/src/search/tool-defs.mjs +83 -0
  385. package/src/setup/defender-exclusion.mjs +183 -0
  386. package/src/shared/abort-controller.mjs +15 -0
  387. package/src/shared/atomic-file.mjs +420 -0
  388. package/src/shared/config.mjs +350 -0
  389. package/src/shared/daemon-recycle.mjs +108 -0
  390. package/src/shared/disable-claude-builtins.mjs +88 -0
  391. package/src/shared/err-text.mjs +12 -0
  392. package/src/shared/llm/cost.mjs +66 -0
  393. package/src/shared/llm/http-agent.mjs +123 -0
  394. package/src/shared/llm/index.mjs +41 -0
  395. package/src/shared/llm/pid-cleanup.mjs +27 -0
  396. package/src/shared/llm/usage-log.mjs +47 -0
  397. package/src/shared/plugin-paths.mjs +58 -0
  398. package/src/shared/schedules-store.mjs +70 -0
  399. package/src/shared/seed.mjs +119 -0
  400. package/src/shared/user-cwd.mjs +213 -0
  401. package/src/shared/user-data-guard.mjs +238 -0
  402. package/src/status/aggregator.mjs +584 -0
  403. package/src/status/server.mjs +413 -0
  404. package/tools.json +1653 -0
@@ -0,0 +1,3305 @@
1
+ #!/usr/bin/env bun
2
+ process.removeAllListeners('warning')
3
+ process.on('warning', () => {})
4
+
5
+ import http from 'node:http'
6
+ import crypto from 'node:crypto'
7
+ import os from 'node:os'
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+ import { fileURLToPath, pathToFileURL } from 'node:url'
11
+
12
+ const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
13
+
14
+ function readPluginVersion() {
15
+ try {
16
+ const manifestPath = path.join(PLUGIN_ROOT, '.claude-plugin', 'plugin.json')
17
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version || '0.0.1'
18
+ } catch { return '0.0.1' }
19
+ }
20
+ const PLUGIN_VERSION = readPluginVersion()
21
+ const PROMOTION_FINGERPRINT_ROOTS = ['src/memory']
22
+ function collectPromotionFingerprintFiles() {
23
+ const out = []
24
+ const walk = (relDir) => {
25
+ let entries = []
26
+ try { entries = fs.readdirSync(path.join(PLUGIN_ROOT, relDir), { withFileTypes: true }) }
27
+ catch { return }
28
+ for (const ent of entries) {
29
+ const rel = `${relDir}/${ent.name}`.replace(/\\/g, '/')
30
+ if (ent.isDirectory()) {
31
+ walk(rel)
32
+ } else if (ent.isFile() && rel.endsWith('.mjs')) {
33
+ out.push(rel)
34
+ }
35
+ }
36
+ }
37
+ for (const root of PROMOTION_FINGERPRINT_ROOTS) walk(root)
38
+ return out.sort()
39
+ }
40
+ function readPromotionCodeFingerprint() {
41
+ const hash = crypto.createHash('sha256')
42
+ const files = collectPromotionFingerprintFiles()
43
+ for (const rel of files) {
44
+ hash.update(rel)
45
+ hash.update('\0')
46
+ try {
47
+ hash.update(fs.readFileSync(path.join(PLUGIN_ROOT, rel)))
48
+ } catch {
49
+ hash.update('missing')
50
+ }
51
+ hash.update('\0')
52
+ }
53
+ return `src/memory:${files.length}:${hash.digest('hex').slice(0, 16)}`
54
+ }
55
+ const BOOT_PROMOTION_CODE_FINGERPRINT = readPromotionCodeFingerprint()
56
+ function promotionCodeChangedOnDisk() {
57
+ return readPromotionCodeFingerprint() !== BOOT_PROMOTION_CODE_FINGERPRINT
58
+ }
59
+
60
+ try { os.setPriority(os.constants.priority.PRIORITY_BELOW_NORMAL) } catch {}
61
+ try {
62
+ const { env } = await import('@huggingface/transformers')
63
+ env.backends.onnx.wasm.numThreads = 1
64
+ } catch {}
65
+
66
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
67
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
68
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
69
+ import {
70
+ ListToolsRequestSchema,
71
+ CallToolRequestSchema,
72
+ } from '@modelcontextprotocol/sdk/types.js'
73
+
74
+ import { TOOL_DEFS } from './tool-defs.mjs'
75
+
76
+ import {
77
+ openDatabase,
78
+ closeDatabase,
79
+ isBootstrapComplete,
80
+ getMetaValue,
81
+ setMetaValue,
82
+ cleanMemoryText,
83
+ } from './lib/memory.mjs'
84
+ import { configureEmbedding, embedText, embedTexts, getEmbeddingDims, getEmbeddingModelId, getKnownDimsForCurrentModel, primeEmbeddingDims, warmupEmbeddingProvider } from './lib/embedding-provider.mjs'
85
+ import { startLlmWorker, stopLlmWorker } from './lib/llm-worker-host.mjs'
86
+ import { runCycle1, runCycle2, runCycle3, runUnifiedGate, parseInterval, syncRootEmbedding, applySimpleStatus, applyUpdate, applyMerge, CYCLE2_ACTIVE_TARGET_CAP } from './lib/memory-cycle.mjs'
87
+ import { getInFlightCycle1 } from './lib/memory-cycle1.mjs'
88
+ import { searchRelevantHybrid } from './lib/memory-recall-store.mjs'
89
+ import { fetchEntriesByIdsScoped } from './lib/memory-recall-id-patch.mjs'
90
+ import { retrieveEntries } from './lib/memory-retrievers.mjs'
91
+ import { pruneOldEntries } from './lib/memory-maintenance-store.mjs'
92
+ import { computeEntryScore } from './lib/memory-score.mjs'
93
+ import { runFullBackfill } from './lib/memory-ops-policy.mjs'
94
+ import { listCore, addCore, editCore, deleteCore, compactCoreIds, CORE_SUMMARY_MAX } from './lib/core-memory-store.mjs'
95
+ import { resolveProjectId, resolveProjectScope } from './lib/project-id-resolver.mjs'
96
+ import { openTraceDatabase, insertTraceEvents, enqueueTraceEvents, insertBridgeCalls, registerTraceExitDrain } from './lib/trace-store.mjs'
97
+ import { withFileLockSync, writeJsonAtomicSync } from '../shared/atomic-file.mjs'
98
+ const DATA_DIR = process.env.CLAUDE_PLUGIN_DATA || process.argv[2] || null
99
+ if (!DATA_DIR) {
100
+ process.stderr.write('[memory-service] CLAUDE_PLUGIN_DATA not set and no explicit data dir provided\n')
101
+ process.exit(1)
102
+ }
103
+ process.stderr.write(`[memory-service] DATA_DIR=${DATA_DIR}\n`)
104
+
105
+ import { execFileSync } from 'child_process'
106
+
107
+ const RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
108
+ ? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
109
+ : path.join(os.tmpdir(), 'mixdog')
110
+
111
+ let _periodicAdvertiseInstalled = false
112
+ // Track the most recently advertised port so the periodic tick re-reads it
113
+ // every interval. Without this the setInterval closure binds the FIRST port
114
+ // (the upstream we proxied to) and keeps re-advertising the dead upstream
115
+ // port after fork-proxy promotion swaps in our own locally-bound port.
116
+ let _currentAdvertisedPort = null
117
+
118
+ function parsePositivePid(value) {
119
+ const pid = Number(value)
120
+ return Number.isFinite(pid) && pid > 0 ? pid : null
121
+ }
122
+
123
+ const MEMORY_SERVER_PID = parsePositivePid(process.env.MIXDOG_SERVER_PID)
124
+
125
+ function advertiseMemoryPort(boundPort, attempt = 0) {
126
+ if (!Number.isFinite(boundPort) || boundPort <= 0) return
127
+ _currentAdvertisedPort = boundPort
128
+ const dir = RUNTIME_ROOT
129
+ const file = path.join(dir, 'active-instance.json')
130
+ try {
131
+ fs.mkdirSync(dir, { recursive: true })
132
+ withFileLockSync(`${file}.lock`, () => {
133
+ let cur = {}
134
+ try { cur = JSON.parse(fs.readFileSync(file, 'utf8')) } catch {}
135
+ const curMemPort = Number(cur?.memory_port)
136
+ const curMemPid = parsePositivePid(cur?.memory_server_pid)
137
+ const portConflict = Number.isFinite(curMemPort) && curMemPort > 0 && curMemPort !== boundPort
138
+ const otherOwnerAlive =
139
+ curMemPid != null &&
140
+ curMemPid !== MEMORY_SERVER_PID &&
141
+ _isPidAliveLocal(curMemPid)
142
+ if (portConflict && otherOwnerAlive) {
143
+ process.stderr.write(`[memory-service] skip memory_port advertise port=${boundPort} curMemPort=${curMemPort} curMemPid=${curMemPid} memoryServerPid=${MEMORY_SERVER_PID}\n`)
144
+ return
145
+ }
146
+ const next = {
147
+ ...cur,
148
+ memory_port: boundPort,
149
+ ...(MEMORY_SERVER_PID ? { memory_server_pid: MEMORY_SERVER_PID } : {}),
150
+ updatedAt: Date.now(),
151
+ }
152
+ writeJsonAtomicSync(file, next, { compact: true, fsyncDir: true })
153
+ })
154
+ if (!_periodicAdvertiseInstalled) {
155
+ _periodicAdvertiseInstalled = true
156
+ setInterval(() => {
157
+ try {
158
+ if (_currentAdvertisedPort != null) {
159
+ advertiseMemoryPort(_currentAdvertisedPort)
160
+ }
161
+ } catch {}
162
+ }, 30_000).unref()
163
+ }
164
+ } catch (e) {
165
+ const transient = e?.code === 'EPERM' || e?.code === 'EBUSY' || e?.code === 'EACCES'
166
+ if (transient && attempt < 3) {
167
+ setTimeout(() => advertiseMemoryPort(boundPort, attempt + 1), 50 * (attempt + 1))
168
+ return
169
+ }
170
+ process.stderr.write(`[memory-service] active-instance memory_port advertise failed: ${e?.message || e}\n`)
171
+ }
172
+ }
173
+
174
+ const LOCK_FILE = path.join(DATA_DIR, '.memory-service.lock')
175
+ // Owner-election lock. Separate from LOCK_FILE so single-instance mode keeps
176
+ // its kill-the-previous protocol while multi-instance fork-proxy workers use
177
+ // atomic CAS for takeover. Created via fs.openSync(path,'wx') — node guarantees
178
+ // EEXIST when another process won the race.
179
+ const OWNER_LOCK_FILE = path.join(DATA_DIR, '.memory-owner.lock')
180
+
181
+ function _isPidAliveLocal(pid) {
182
+ if (!Number.isFinite(pid) || pid <= 0) return false
183
+ try { process.kill(pid, 0); return true }
184
+ catch (e) { return e.code !== 'ESRCH' }
185
+ }
186
+
187
+ function tryAcquireMemoryOwnerLock() {
188
+ // Returns true on success (this process now owns memory worker for the data
189
+ // dir), false when a live peer holds the lock. Stale locks (dead PID) are
190
+ // unlinked and retried atomically. Throws on unexpected fs errors so callers
191
+ // surface lock-system corruption rather than silently downgrading.
192
+ //
193
+ // EPERM/EBUSY/EACCES at openSync are transient — AV scanners (SignKorea /
194
+ // SKCert / ezPDFWS etc) briefly lock newly-created files during inspection.
195
+ // The 0.1.x baseline threw immediately and the worker promoted to
196
+ // permanentlyDegraded, killing memory tools for the rest of the session.
197
+ // Treat the AV error codes as retryable with bounded backoff (~750ms total)
198
+ // before giving up and rethrowing.
199
+ for (let attempt = 0; attempt < 5; attempt++) {
200
+ try {
201
+ const fd = fs.openSync(OWNER_LOCK_FILE, 'wx')
202
+ fs.writeSync(fd, String(process.pid))
203
+ fs.closeSync(fd)
204
+ return true
205
+ } catch (e) {
206
+ if (e.code === 'EEXIST') {
207
+ let ownerPid = NaN
208
+ try { ownerPid = Number(fs.readFileSync(OWNER_LOCK_FILE, 'utf8').trim()) } catch {}
209
+ if (_isPidAliveLocal(ownerPid)) return false
210
+ // Stale lock: dead owner — unlink and retry exclusive create.
211
+ try { fs.unlinkSync(OWNER_LOCK_FILE) } catch {}
212
+ continue
213
+ }
214
+ const transient = e.code === 'EPERM' || e.code === 'EBUSY' || e.code === 'EACCES'
215
+ if (transient && attempt < 4) {
216
+ // Sync busy-wait acceptable here: this runs on memory worker boot
217
+ // path, once per process; the parent handler is not blocked.
218
+ const end = Date.now() + 50 * (attempt + 1)
219
+ while (Date.now() < end) {}
220
+ continue
221
+ }
222
+ throw e
223
+ }
224
+ }
225
+ return false
226
+ }
227
+
228
+ function releaseMemoryOwnerLock() {
229
+ try {
230
+ const ownerPid = Number(fs.readFileSync(OWNER_LOCK_FILE, 'utf8').trim())
231
+ if (ownerPid === process.pid) fs.unlinkSync(OWNER_LOCK_FILE)
232
+ } catch {}
233
+ }
234
+
235
+ const ACTIVE_INSTANCE_FILE = path.join(RUNTIME_ROOT, 'active-instance.json')
236
+ const BASE_PORT = 3350
237
+ const MAX_PORT = 3357
238
+
239
+ let _traceDb = null
240
+
241
+ const MEMORY_INSTRUCTIONS_TEXT = ''
242
+
243
+ function killPreviousServer(pid) {
244
+ if (pid <= 0 || pid === process.pid) return false
245
+ if (process.platform === 'win32') {
246
+ try {
247
+ execFileSync('taskkill', ['/F', '/T', '/PID', String(pid)], { encoding: 'utf8', timeout: 5000, windowsHide: true })
248
+ process.stderr.write(`[memory-service] Killed previous server PID ${pid}\n`)
249
+ return true
250
+ } catch (e) {
251
+ // Exit code 128 = process not found; treat stale lock as already-dead = success.
252
+ // Status 128 reliably means "process not found" regardless of locale; no text match needed.
253
+ // Status 1 with English text match handles edge cases on some Windows versions.
254
+ const notFoundText = /not found|no running instance/i.test(e.stdout || '')
255
+ || /not found|no running instance/i.test(e.stderr || '')
256
+ || /not found|no running instance/i.test(e.message || '')
257
+ const alreadyDead = e.status === 128 || (e.status === 1 && notFoundText)
258
+ if (alreadyDead) {
259
+ process.stderr.write(`[memory-service] PID ${pid} already dead (stale lock), proceeding\n`)
260
+ return true
261
+ }
262
+ process.stderr.write(`[memory-service] taskkill failed for PID ${pid}: ${e.message}\n`)
263
+ return false
264
+ }
265
+ } else {
266
+ // Pre-flight: if the process is already gone, treat stale lock as success.
267
+ try {
268
+ process.kill(pid, 0)
269
+ } catch (e) {
270
+ if (e.code === 'ESRCH') {
271
+ process.stderr.write(`[memory-service] PID ${pid} already dead (stale lock), proceeding\n`)
272
+ return true
273
+ }
274
+ }
275
+ try { process.kill(pid, 'SIGTERM') } catch {}
276
+ try { process.kill(pid, 'SIGKILL') } catch {}
277
+ // Poll for death up to 2s
278
+ const deadline = Date.now() + 2000
279
+ while (Date.now() < deadline) {
280
+ try {
281
+ process.kill(pid, 0)
282
+ } catch (e) {
283
+ if (e.code === 'ESRCH') {
284
+ process.stderr.write(`[memory-service] Killed previous server PID ${pid}\n`)
285
+ return true
286
+ }
287
+ }
288
+ // Synchronous 50ms sleep via shared buffer spin
289
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50)
290
+ }
291
+ process.stderr.write(`[memory-service] PID ${pid} still alive after SIGKILL\n`)
292
+ return false
293
+ }
294
+ }
295
+
296
+ function acquireLock() {
297
+ // Multi-instance guard. In multi-terminal mode the lock owner is a *peer*
298
+ // memory worker serving recall for another CC session. killPreviousServer
299
+ // would taskkill /F that healthy peer mid-flight, then this fork-proxy
300
+ // mode wouldn't even need a lock anyway. Skip the entire kill-the-previous
301
+ // protocol; fork-proxy detection in init() takes priority. If neither
302
+ // proxy nor lock-owner path applies (race window during simultaneous
303
+ // boot), the worker simply continues without the lock — server-main /
304
+ // PG / port-listen handle the actual conflict cases.
305
+ if (process.env.MIXDOG_MULTI_INSTANCE === '1') return
306
+ try {
307
+ if (fs.existsSync(LOCK_FILE)) {
308
+ const lockedPid = Number(fs.readFileSync(LOCK_FILE, 'utf8').trim())
309
+ if (lockedPid > 0 && lockedPid !== process.pid) {
310
+ const killed = killPreviousServer(lockedPid)
311
+ if (!killed) {
312
+ process.stderr.write(`[memory-service] Could not kill previous server PID ${lockedPid}, aborting\n`)
313
+ process.exit(1)
314
+ }
315
+ try { fs.unlinkSync(LOCK_FILE) } catch {}
316
+ }
317
+ }
318
+ const fd = fs.openSync(LOCK_FILE, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600)
319
+ try {
320
+ fs.writeSync(fd, String(process.pid))
321
+ } finally {
322
+ fs.closeSync(fd)
323
+ }
324
+ } catch (e) {
325
+ if (e.code === 'EEXIST') {
326
+ process.stderr.write(`[memory-service] Lock file exists (EEXIST) — another instance is already running, exiting\n`)
327
+ process.exit(0)
328
+ }
329
+ process.stderr.write(`[memory-service] Lock acquisition failed: ${e.message}\n`)
330
+ process.exit(1)
331
+ }
332
+ }
333
+
334
+ function releaseLock() {
335
+ try {
336
+ const content = fs.readFileSync(LOCK_FILE, 'utf8').trim()
337
+ if (Number(content) === process.pid) fs.unlinkSync(LOCK_FILE)
338
+ } catch {}
339
+ }
340
+
341
+ import { readSection } from '../shared/config.mjs'
342
+
343
+ function readMainConfig() {
344
+ return readSection('memory')
345
+ }
346
+
347
+ let db = null
348
+ let mainConfig = null
349
+ let _cycleInterval = null
350
+ let _startupTimeout = null
351
+ // Outer-layer cycle1 in-flight tracker (MCP-server scope).
352
+ //
353
+ // The AUTHORITATIVE guard lives in memory-cycle.mjs:runCycle1 itself — that
354
+ // one catches every caller, including direct imports (setup-server backfill,
355
+ // policy-layer backfill). This outer tracker is kept as a defense-in-depth
356
+ // layer local to the MCP server process: it coalesces simultaneous
357
+ // _awaitCycle1Run callers (MCP action, scheduler, flush) onto a shared
358
+ // promise so they all observe the SAME result object rather than some
359
+ // getting the real stats and others getting `skippedInFlight: true` from
360
+ // the inner guard.
361
+ let _cycle1InFlight = null // shared cycle1 promise (outer coalesce layer)
362
+ let _initialized = false
363
+ let _initPromise = null
364
+ let _bootTimestamp = null
365
+ let _transcriptOffsets = new Map()
366
+ // Boot-edge background warmup. ONNX session creation on the embedding worker
367
+ // thread is CPU-heavy, so it must not overlap the worker's own init (DB open,
368
+ // schema, cycle wiring). Previously this was gated behind a fixed setTimeout —
369
+ // a wall-clock guess at "boot settled". Now the warmup is queued during
370
+ // _initStore and fired at the _initRuntime completion edge (see _initRuntime),
371
+ // so it starts the instant boot's CPU-heavy work is done — no magic-number
372
+ // delay. MIXDOG_EMBED_WARMUP=0 disables it (model loads lazily on first use).
373
+ let _pendingEmbeddingWarmup = null
374
+
375
+ const TRANSCRIPT_OFFSETS_KEY = 'state.transcript_offsets'
376
+ const CYCLE_LAST_RUN_KEY = 'state.cycle_last_run'
377
+
378
+ function embeddingWarmupEnabled() {
379
+ const raw = String(process.env.MIXDOG_EMBED_WARMUP ?? '1').trim().toLowerCase()
380
+ return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')
381
+ }
382
+
383
+ function scheduleBackgroundEmbeddingWarmup(metaPath, metaKey) {
384
+ if (!embeddingWarmupEnabled()) return
385
+ // Queue the warmup; _initRuntime fires it once boot completes.
386
+ _pendingEmbeddingWarmup = () => {
387
+ warmupEmbeddingProvider()
388
+ .then(() => {
389
+ const measured = Number(getEmbeddingDims())
390
+ try {
391
+ writeJsonAtomicSync(metaPath, { ...metaKey, dims: measured }, { lock: true })
392
+ } catch (e) {
393
+ process.stderr.write(`[memory-service] could not persist embedding-meta: ${e?.message || e}\n`)
394
+ }
395
+ })
396
+ .catch(err => {
397
+ process.stderr.write(`[memory-service] background warmup failed: ${err?.message || err}\n`)
398
+ process.exit(1)
399
+ })
400
+ }
401
+ }
402
+
403
+ function fireDeferredEmbeddingWarmup() {
404
+ const fire = _pendingEmbeddingWarmup
405
+ if (!fire) return
406
+ _pendingEmbeddingWarmup = null
407
+ fire()
408
+ }
409
+
410
+ async function _initStore() {
411
+ mainConfig = readMainConfig()
412
+ const embeddingConfig = mainConfig?.embedding
413
+ if (embeddingConfig?.provider || embeddingConfig?.ollamaModel || embeddingConfig?.dtype) {
414
+ configureEmbedding({
415
+ provider: embeddingConfig.provider,
416
+ ollamaModel: embeddingConfig.ollamaModel,
417
+ dtype: embeddingConfig.dtype,
418
+ })
419
+ }
420
+
421
+ // Persist embedding dims so warmup is off the boot critical path.
422
+ // On a cache hit (provider+model+dtype match) open the DB immediately,
423
+ // prime the known dimensions, then run the model warmup later in the
424
+ // background. If cycle1/recall needs embeddings first, that on-demand
425
+ // call owns the same worker queue and the delayed warmup becomes a no-op.
426
+ const EMBEDDING_META_PATH = path.join(DATA_DIR, 'embedding-meta.json')
427
+ const metaKey = {
428
+ provider: embeddingConfig?.provider ?? null,
429
+ model: getEmbeddingModelId(),
430
+ dtype: embeddingConfig?.dtype ?? null,
431
+ }
432
+ let dimsResolved = null
433
+ try {
434
+ const saved = JSON.parse(fs.readFileSync(EMBEDDING_META_PATH, 'utf8'))
435
+ if (saved.provider === metaKey.provider && saved.model === metaKey.model && saved.dtype === metaKey.dtype) {
436
+ dimsResolved = Number(saved.dims)
437
+ }
438
+ } catch { /* miss or missing — fall through */ }
439
+
440
+ // Registry fallback: model with statically known dims bypasses measurement.
441
+ // Delayed background warmup invariant-checks measured vs registry value;
442
+ // mismatch throws and crashes the worker for fail-fast parity with the cold
443
+ // path's boot-time degraded signal.
444
+ if (dimsResolved == null) {
445
+ const known = getKnownDimsForCurrentModel()
446
+ if (known != null) dimsResolved = known
447
+ }
448
+
449
+ if (dimsResolved) {
450
+ primeEmbeddingDims(dimsResolved)
451
+ db = await openDatabase(DATA_DIR, dimsResolved)
452
+ scheduleBackgroundEmbeddingWarmup(EMBEDDING_META_PATH, metaKey)
453
+ } else {
454
+ // Cold path: meta missed AND model not registered. Sequential.
455
+ await warmupEmbeddingProvider()
456
+ dimsResolved = Number(getEmbeddingDims())
457
+ db = await openDatabase(DATA_DIR, dimsResolved)
458
+ try {
459
+ writeJsonAtomicSync(EMBEDDING_META_PATH, { ...metaKey, dims: dimsResolved }, { lock: true })
460
+ } catch (e) {
461
+ process.stderr.write(`[memory-service] could not persist embedding-meta: ${e?.message || e}\n`)
462
+ }
463
+ }
464
+
465
+ if (!await isBootstrapComplete(db)) {
466
+ throw new Error('memory-service: bootstrap not complete after openDatabase')
467
+ }
468
+ startLlmWorker()
469
+ _bootTimestamp = Date.now()
470
+ await loadTranscriptOffsets()
471
+ }
472
+
473
+ async function loadTranscriptOffsets() {
474
+ try {
475
+ const raw = await getMetaValue(db, TRANSCRIPT_OFFSETS_KEY, '{}')
476
+ const obj = JSON.parse(raw)
477
+ _transcriptOffsets = new Map(Object.entries(obj))
478
+ } catch {
479
+ _transcriptOffsets = new Map()
480
+ }
481
+ }
482
+
483
+ async function persistTranscriptOffsets() {
484
+ try {
485
+ const obj = Object.fromEntries(_transcriptOffsets)
486
+ await setMetaValue(db, TRANSCRIPT_OFFSETS_KEY, JSON.stringify(obj))
487
+ } catch (e) {
488
+ process.stderr.write(`[memory] persist transcript offsets failed: ${e.message}\n`)
489
+ }
490
+ }
491
+
492
+ async function getCycleLastRun() {
493
+ try {
494
+ const raw = await getMetaValue(db, CYCLE_LAST_RUN_KEY, '{}')
495
+ const obj = JSON.parse(raw)
496
+ return {
497
+ cycle1: Number(obj.cycle1) || 0,
498
+ cycle2: Number(obj.cycle2) || 0,
499
+ cycle3: Number(obj.cycle3) || 0,
500
+ // Phase B §2.4 auto-restart book-keeping — last time an overdue cycle1
501
+ // triggered an unscheduled run, rate-limited separately from the
502
+ // normal cycle timestamp so a long chain of failures cannot tight-loop.
503
+ cycle1_autoRestart: Number(obj.cycle1_autoRestart) || 0,
504
+ // #13/#14: heartbeat (every attempt, success or skip) and the auto-
505
+ // restart attempt timestamp (committed BEFORE the call) are tracked
506
+ // separately from the success timestamps above so a long string of
507
+ // failed/skipped runs cannot disguise itself as a healthy keeper.
508
+ cycle1_heartbeat: Number(obj.cycle1_heartbeat) || 0,
509
+ cycle1_autoRestart_attempt: Number(obj.cycle1_autoRestart_attempt) || 0,
510
+ // Last cycle2 failure message; cleared to '' on success.
511
+ cycle2_last_error: typeof obj.cycle2_last_error === 'string' ? obj.cycle2_last_error : '',
512
+ }
513
+ } catch {
514
+ return {
515
+ cycle1: 0, cycle2: 0, cycle3: 0, cycle1_autoRestart: 0,
516
+ cycle1_heartbeat: 0, cycle1_autoRestart_attempt: 0,
517
+ cycle2_last_error: '',
518
+ }
519
+ }
520
+ }
521
+
522
+ async function setCycleLastRun(kind, ts) {
523
+ const cur = await getCycleLastRun()
524
+ cur[kind] = ts
525
+ await setMetaValue(db, CYCLE_LAST_RUN_KEY, JSON.stringify(cur))
526
+ }
527
+
528
+ // Raw-row priority lookup for narrow-window queries. Raw rows (is_root=0,
529
+ // chunk_root IS NULL) are inserted immediately by ingestTranscriptFile before
530
+ // cycle1 runs, so they always carry the freshest turns in the DB.
531
+ async function readRawRowsInWindow(db, tsFromMs, tsToMs, hardLimit = 10, { projectScope } = {}) {
532
+ try {
533
+ let sql, params
534
+ if (projectScope === 'common') {
535
+ sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
536
+ element, category, summary, status, score, last_seen_at, project_id
537
+ FROM entries
538
+ WHERE chunk_root IS NULL AND is_root = 0
539
+ AND ts >= $1 AND ts <= $2
540
+ AND project_id IS NULL
541
+ ORDER BY ts DESC
542
+ LIMIT $3`
543
+ params = [tsFromMs ?? 0, tsToMs ?? Date.now(), hardLimit]
544
+ } else if (projectScope && projectScope !== 'all') {
545
+ sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
546
+ element, category, summary, status, score, last_seen_at, project_id
547
+ FROM entries
548
+ WHERE chunk_root IS NULL AND is_root = 0
549
+ AND ts >= $1 AND ts <= $2
550
+ AND (project_id IS NULL OR project_id = $3)
551
+ ORDER BY ts DESC
552
+ LIMIT $4`
553
+ params = [tsFromMs ?? 0, tsToMs ?? Date.now(), projectScope, hardLimit]
554
+ } else {
555
+ sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
556
+ element, category, summary, status, score, last_seen_at, project_id
557
+ FROM entries
558
+ WHERE chunk_root IS NULL AND is_root = 0
559
+ AND ts >= $1 AND ts <= $2
560
+ ORDER BY ts DESC
561
+ LIMIT $3`
562
+ params = [tsFromMs ?? 0, tsToMs ?? Date.now(), hardLimit]
563
+ }
564
+ const rows = (await db.query(sql, params)).rows
565
+ return rows.map(r => ({ ...r, retrievalScore: 0, rrf: 0 }))
566
+ } catch { return [] }
567
+ }
568
+
569
+ async function ingestTranscriptFile(transcriptPath, { cwd } = {}) {
570
+ let stat
571
+ try { stat = await fs.promises.stat(transcriptPath) } catch { return 0 }
572
+ const sessionUuid = path.basename(transcriptPath, '.jsonl')
573
+ const prev = _transcriptOffsets.get(transcriptPath) ?? { bytes: 0, lineIndex: 0 }
574
+ if (stat.size < prev.bytes) {
575
+ prev.bytes = 0
576
+ prev.lineIndex = 0
577
+ }
578
+ if (stat.size <= prev.bytes) return 0
579
+
580
+ const fh = await fs.promises.open(transcriptPath, 'r')
581
+ const buf = Buffer.alloc(stat.size - prev.bytes)
582
+ try {
583
+ await fh.read(buf, 0, buf.length, prev.bytes)
584
+ } finally {
585
+ await fh.close()
586
+ }
587
+ const text = buf.toString('utf8')
588
+
589
+ const resolvedCwd = typeof cwd === 'string' && cwd ? cwd : cwdFromTranscriptPath(transcriptPath)
590
+ // No cwd resolved -> classify as COMMON (project_id NULL). Falling back to
591
+ // process.cwd() would misclassify rows under the service/plugin cwd.
592
+ const projectId = resolvedCwd ? resolveProjectId(resolvedCwd) : null
593
+
594
+ let count = 0
595
+ let index = prev.lineIndex
596
+ // Track the byte boundary of the LAST line we fully consumed (parsed +
597
+ // either inserted or intentionally skipped). On parse failure or
598
+ // transient insert error we stop and leave the boundary untouched so the
599
+ // next sweep retries from the same position. This prevents malformed
600
+ // trailing JSONL (mid-write partial lines) and DB hiccups from being
601
+ // silently consumed forever.
602
+ let lastGoodBytes = prev.bytes
603
+ let lastGoodLineIndex = prev.lineIndex
604
+ let cursor = 0
605
+ while (cursor < text.length) {
606
+ const nl = text.indexOf('\n', cursor)
607
+ // No trailing newline -> partial line still being written; stop here
608
+ // without advancing so the rest is re-read once the writer flushes.
609
+ if (nl === -1) break
610
+ const rawLine = text.slice(cursor, nl)
611
+ const consumedBytes = Buffer.byteLength(rawLine, 'utf8') + 1
612
+ cursor = nl + 1
613
+ const line = rawLine.replace(/\r$/, '')
614
+ if (!line) {
615
+ lastGoodBytes += consumedBytes
616
+ continue
617
+ }
618
+ index += 1
619
+ let parsed
620
+ try { parsed = JSON.parse(line) } catch {
621
+ // Malformed line: do not advance past it; retry on next sweep.
622
+ index -= 1
623
+ break
624
+ }
625
+ const role = parsed.message?.role
626
+ if (role !== 'user' && role !== 'assistant') {
627
+ lastGoodBytes += consumedBytes
628
+ lastGoodLineIndex = index
629
+ continue
630
+ }
631
+ const content = firstTextContent(parsed.message?.content)
632
+ if (!content || !content.trim()) {
633
+ lastGoodBytes += consumedBytes
634
+ lastGoodLineIndex = index
635
+ continue
636
+ }
637
+ const cleaned = cleanMemoryText(content)
638
+ if (!cleaned) {
639
+ lastGoodBytes += consumedBytes
640
+ lastGoodLineIndex = index
641
+ continue
642
+ }
643
+ const tsMs = parseTsToMs(parsed.timestamp ?? parsed.ts ?? Date.now())
644
+ const sourceRef = `transcript:${sessionUuid}#${index}`
645
+ try {
646
+ const result = await db.query(
647
+ `INSERT INTO entries(ts, role, content, source_ref, session_id, source_turn, project_id)
648
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
649
+ ON CONFLICT DO NOTHING`,
650
+ [tsMs, role, cleaned, sourceRef, sessionUuid, index, projectId]
651
+ )
652
+ if (Number(result.rowCount ?? result.affectedRows ?? 0) > 0) count += 1
653
+ lastGoodBytes += consumedBytes
654
+ lastGoodLineIndex = index
655
+ } catch (e) {
656
+ process.stderr.write(`[transcript-watch] insert error (${sourceRef}): ${e.message}\n`)
657
+ // Transient insert failure: leave the boundary before this line so
658
+ // the next sweep retries it. Roll back the line counter too.
659
+ index -= 1
660
+ break
661
+ }
662
+ }
663
+ prev.bytes = lastGoodBytes
664
+ prev.lineIndex = lastGoodLineIndex
665
+ _transcriptOffsets.set(transcriptPath, prev)
666
+ await persistTranscriptOffsets()
667
+ return count
668
+ }
669
+
670
+ function firstTextContent(content) {
671
+ if (typeof content === 'string') return content
672
+ if (!Array.isArray(content)) return ''
673
+ for (const item of content) {
674
+ if (typeof item === 'string') return item
675
+ if (item?.type === 'text' && typeof item.text === 'string') return item.text
676
+ }
677
+ return ''
678
+ }
679
+
680
+ function parseTsToMs(value) {
681
+ if (typeof value === 'number' && Number.isFinite(value)) return value < 1e12 ? value * 1000 : value
682
+ const parsed = Date.parse(String(value))
683
+ return Number.isFinite(parsed) ? parsed : Date.now()
684
+ }
685
+
686
+ // Extract cwd from the transcript file's JSONL rows. Claude Code embeds
687
+ // the session cwd as a top-level `cwd` field on every message row, so
688
+ // scanning the first few lines is reliable on all platforms (Windows/Linux)
689
+ // without slug-decoding ambiguity. Returns undefined when no cwd is found
690
+ // or the extracted path does not exist on disk (falls back to COMMON).
691
+ function cwdFromTranscriptPath(fp) {
692
+ let fd
693
+ try {
694
+ fd = fs.openSync(fp, 'r')
695
+ const buf = Buffer.alloc(Math.min(fs.fstatSync(fd).size, 100 * 1024))
696
+ fs.readSync(fd, buf, 0, buf.length, 0)
697
+ fs.closeSync(fd)
698
+ fd = undefined
699
+ const lines = buf.toString('utf8').split('\n')
700
+ for (let i = 0; i < Math.min(lines.length, 5); i++) {
701
+ const line = lines[i].trim()
702
+ if (!line) continue
703
+ try {
704
+ const obj = JSON.parse(line)
705
+ if (typeof obj.cwd === 'string' && obj.cwd) {
706
+ const candidate = obj.cwd
707
+ try { if (fs.statSync(candidate).isDirectory()) return candidate } catch {}
708
+ }
709
+ } catch {}
710
+ }
711
+ } catch {} finally {
712
+ if (fd != null) { try { fs.closeSync(fd) } catch {} }
713
+ }
714
+ return undefined
715
+ }
716
+
717
+ function _initTranscriptWatcher() {
718
+ const projectsRoot = path.join(os.homedir(), '.claude', 'projects')
719
+ const SAFETY_POLL_MS = 5 * 60_000
720
+ const DEBOUNCE_MS = 500
721
+ const watchedFiles = new Map()
722
+ const pendingByFile = new Map()
723
+ const watchers = []
724
+ const intervals = []
725
+ const polledFiles = new Set()
726
+ let safetySweepTimeout = null
727
+
728
+ function isWatchable(relOrBase) {
729
+ const base = path.basename(relOrBase)
730
+ if (!base.endsWith('.jsonl') || base.startsWith('agent-')) return false
731
+ if (relOrBase.includes('tmp') || relOrBase.includes('cache') || relOrBase.includes('plugins')) return false
732
+ return true
733
+ }
734
+
735
+ async function ingestOne(fp) {
736
+ try {
737
+ if (!fs.existsSync(fp)) return
738
+ const stat = fs.statSync(fp)
739
+ const mtime = stat.mtimeMs
740
+ const prev = watchedFiles.get(fp)
741
+ if (prev && prev >= mtime) return
742
+ const n = await ingestTranscriptFile(fp, { cwd: cwdFromTranscriptPath(fp) })
743
+ // Only mark this mtime as 'consumed' once the persisted offset has
744
+ // fully advanced past the observed file size. On a transient insert
745
+ // error (or a malformed trailing line) ingestTranscriptFile leaves
746
+ // the persisted offset before the failed line for retry; caching
747
+ // the new mtime unconditionally would suppress the next sweep until
748
+ // the file mutated again, losing the retry. Leave the cache
749
+ // untouched on partial advance so the next sweep re-ingests.
750
+ const off = _transcriptOffsets.get(fp)
751
+ if (off && off.bytes >= stat.size) {
752
+ watchedFiles.set(fp, mtime)
753
+ }
754
+ if (n > 0) {
755
+ process.stderr.write(`[transcript-watch] ingested ${n} entries from ${path.basename(fp)}\n`)
756
+ }
757
+ } catch (e) {
758
+ process.stderr.write(`[transcript-watch] ingest error: ${e.message}\n`)
759
+ }
760
+ }
761
+
762
+ function scheduleIngest(fp) {
763
+ const existing = pendingByFile.get(fp)
764
+ if (existing) clearTimeout(existing)
765
+ const timer = setTimeout(() => {
766
+ pendingByFile.delete(fp)
767
+ ingestOne(fp)
768
+ }, DEBOUNCE_MS)
769
+ pendingByFile.set(fp, timer)
770
+ }
771
+
772
+ async function discoverActiveTranscripts() {
773
+ let topLevel
774
+ try { topLevel = await fs.promises.readdir(projectsRoot) }
775
+ catch { return [] }
776
+ const files = []
777
+ for (const d of topLevel) {
778
+ if (d.includes('tmp') || d.includes('cache') || d.includes('plugins')) continue
779
+ const full = path.join(projectsRoot, d)
780
+ let inner
781
+ try { inner = await fs.promises.readdir(full) } catch { continue }
782
+ for (const f of inner) {
783
+ if (!f.endsWith('.jsonl') || f.startsWith('agent-')) continue
784
+ const fp = path.join(full, f)
785
+ try {
786
+ const stat = await fs.promises.stat(fp)
787
+ files.push({ path: fp, mtime: stat.mtimeMs })
788
+ } catch {}
789
+ }
790
+ }
791
+ const cutoff = Date.now() - 30 * 60_000
792
+ return files.filter(f => f.mtime > cutoff)
793
+ }
794
+
795
+ async function safetySweep() {
796
+ try {
797
+ const active = await discoverActiveTranscripts()
798
+ for (const { path: fp } of active) ingestOne(fp)
799
+ } catch (e) {
800
+ process.stderr.write(`[transcript-watch] safety sweep error: ${e.message}\n`)
801
+ }
802
+ }
803
+
804
+ safetySweepTimeout = setTimeout(safetySweep, 3_000)
805
+
806
+ // fs.watch({recursive}) is only reliable on win32.
807
+ // darwin: recursive option unreliable — use flat watch per-entry (glob dirs at start).
808
+ // linux/WSL: recursive not supported — use fs.watchFile polling per file found via
809
+ // the safety sweep, or fall back entirely to safety sweep.
810
+ if (process.platform === 'win32') {
811
+ try {
812
+ const watcher = fs.watch(projectsRoot, { recursive: true, persistent: true }, (_event, filename) => {
813
+ if (!filename) return
814
+ if (!isWatchable(filename)) return
815
+ const fp = path.join(projectsRoot, filename)
816
+ scheduleIngest(fp)
817
+ })
818
+ watcher.on('error', (err) => {
819
+ process.stderr.write(`[transcript-watch] fs.watch error: ${err.message}\n`)
820
+ })
821
+ watchers.push(watcher)
822
+ process.stderr.write(`[transcript-watch] fs.watch(recursive) active on ${projectsRoot}\n`)
823
+ } catch (e) {
824
+ process.stderr.write(`[transcript-watch] fs.watch setup failed: ${e.message} — relying on safety sweep only\n`)
825
+ }
826
+ intervals.push(setInterval(safetySweep, SAFETY_POLL_MS))
827
+ } else if (process.platform === 'darwin') {
828
+ // Flat watch: register a non-recursive watcher on each immediate subdirectory.
829
+ // New subdirs are picked up on the next safety sweep cycle.
830
+ try {
831
+ const registerFlat = (dir) => {
832
+ try {
833
+ const w = fs.watch(dir, { persistent: true }, (_event, filename) => {
834
+ if (!filename) return
835
+ const fp = path.join(dir, filename)
836
+ if (!isWatchable(fp)) return
837
+ scheduleIngest(fp)
838
+ })
839
+ w.on('error', () => { /* ignore individual dir errors */ })
840
+ watchers.push(w)
841
+ } catch { /* dir may not exist yet */ }
842
+ }
843
+ registerFlat(projectsRoot)
844
+ try {
845
+ for (const entry of fs.readdirSync(projectsRoot, { withFileTypes: true })) {
846
+ if (entry.isDirectory()) registerFlat(path.join(projectsRoot, entry.name))
847
+ }
848
+ } catch { /* best effort */ }
849
+ process.stderr.write(`[transcript-watch] flat fs.watch active on ${projectsRoot} (darwin)\n`)
850
+ } catch (e) {
851
+ process.stderr.write(`[transcript-watch] flat watch setup failed: ${e.message} — relying on safety sweep only\n`)
852
+ }
853
+ intervals.push(setInterval(safetySweep, SAFETY_POLL_MS))
854
+ } else {
855
+ // linux/WSL: fs.watch recursive is unsupported. Use fs.watchFile polling for
856
+ // individual files surfaced by the safety sweep, in addition to the sweep itself.
857
+ process.stderr.write(`[transcript-watch] linux/WSL — using safety sweep + fs.watchFile polling (no recursive watch)\n`)
858
+ // Wrap by reassigning the closure-captured reference is not possible here;
859
+ // instead, register watchFile inside the safety sweep callback by intercepting
860
+ // active file list after each sweep. The interval already calls safetySweep
861
+ // which calls ingestOne; watchFile additions happen as a side-effect of the sweep.
862
+ const _patchedSweep = async () => {
863
+ try {
864
+ const active = await discoverActiveTranscripts()
865
+ for (const { path: fp } of active) {
866
+ if (!polledFiles.has(fp)) {
867
+ polledFiles.add(fp)
868
+ fs.watchFile(fp, { persistent: false, interval: 2000 }, () => {
869
+ if (isWatchable(fp)) scheduleIngest(fp)
870
+ })
871
+ }
872
+ ingestOne(fp)
873
+ }
874
+ } catch (e) {
875
+ process.stderr.write(`[transcript-watch] linux sweep error: ${e.message}\n`)
876
+ }
877
+ }
878
+ // Replace the safety sweep interval with the patched version.
879
+ intervals.push(setInterval(_patchedSweep, SAFETY_POLL_MS))
880
+ }
881
+
882
+ return {
883
+ stop() {
884
+ if (safetySweepTimeout) { clearTimeout(safetySweepTimeout); safetySweepTimeout = null }
885
+ for (const t of pendingByFile.values()) { try { clearTimeout(t) } catch {} }
886
+ pendingByFile.clear()
887
+ for (const i of intervals) { try { clearInterval(i) } catch {} }
888
+ intervals.length = 0
889
+ for (const w of watchers) { try { w.close() } catch {} }
890
+ watchers.length = 0
891
+ for (const fp of polledFiles) { try { fs.unwatchFile(fp) } catch {} }
892
+ polledFiles.clear()
893
+ },
894
+ }
895
+ }
896
+
897
+ // Phase B §2.4 — cache-keeper health thresholds.
898
+ // warning fires when cycle1 is overdue past HEALTH_OVERDUE_MS; an auto-
899
+ // restart attempt fires when the warning has been emitted AND the most
900
+ // recent unscheduled restart was more than AUTO_RESTART_COOLDOWN_MS ago.
901
+ // Both default to 5 min per spec; caller overrides are not exposed yet.
902
+ const CYCLE1_HEALTH_OVERDUE_MS = 5 * 60_000
903
+ const CYCLE1_AUTO_RESTART_COOLDOWN_MS = 5 * 60_000
904
+
905
+ function _startCycle1Run(config = {}, options = {}) {
906
+ _cycle1InFlight = (async () => {
907
+ try {
908
+ const result = await runCycle1(db, config, options, DATA_DIR)
909
+ // #13: heartbeat (attempt) is always recorded so the overdue check
910
+ // can tell the keeper is alive; success timestamp only advances when
911
+ // the run actually did work. Skipped/in-flight runs do NOT count as
912
+ // success because the next overdue check would otherwise see a fake
913
+ // green and stop forcing auto-restarts.
914
+ const now = Date.now()
915
+ await setCycleLastRun('cycle1_heartbeat', now)
916
+ const skipped = result?.skippedInFlight === true
917
+ const allFailed = !skipped
918
+ && Number(result?.chunks ?? 0) === 0
919
+ && Number(result?.processed ?? 0) === 0
920
+ && Number(result?.skipped ?? 0) > 0
921
+ if (!skipped && !allFailed) {
922
+ await setCycleLastRun('cycle1', now)
923
+ }
924
+ return result
925
+ } finally {
926
+ if (_cycle1InFlight === promise) _cycle1InFlight = null
927
+ }
928
+ })()
929
+ const promise = _cycle1InFlight
930
+ return _cycle1InFlight
931
+ }
932
+
933
+ async function _awaitCycle1Run(config = {}, options = {}) {
934
+ const target = _cycle1InFlight || _startCycle1Run(config, options)
935
+ const callerDeadlineMs = Number(options.callerDeadlineMs) || 0
936
+ if (callerDeadlineMs <= 0) return await target
937
+ // Caller-deadline race. When the channels-side timeout fires, we
938
+ // (a) graceful-return a skippedInFlight envelope so the calling
939
+ // SessionStart slot stops blocking with a 200 OK + flags instead of a
940
+ // 503-class throw, and (b) release the outer in-flight handle. The
941
+ // underlying LLM run keeps progressing in the background — it still
942
+ // owns the inner dedup guard (memory-cycle.mjs _runCycle1InFlight).
943
+ // Releasing the outer handle is what breaks the cascade: any later
944
+ // _awaitCycle1Run call now re-enters _startCycle1Run, whose inner
945
+ // runCycle1 short-circuits with skippedInFlight:true the moment it
946
+ // sees the same db still busy. Returning a graceful object (vs the
947
+ // pre-0.1.198 throw) keeps the channel route response shape stable
948
+ // and lets pollers read inFlight=true rather than parse an error.
949
+ let timer
950
+ const deadlinePromise = new Promise((resolve) => {
951
+ timer = setTimeout(() => {
952
+ if (_cycle1InFlight === target) _cycle1InFlight = null
953
+ resolve({
954
+ processed: 0,
955
+ chunks: 0,
956
+ skipped: 0,
957
+ sessions: 0,
958
+ skippedInFlight: true,
959
+ timedOutWaiting: true,
960
+ callerDeadlineMs,
961
+ })
962
+ }, callerDeadlineMs)
963
+ })
964
+ try {
965
+ return await Promise.race([target, deadlinePromise])
966
+ } finally {
967
+ clearTimeout(timer)
968
+ }
969
+ }
970
+
971
+ // Periodic cycle1 sizing: only enter when ≥ 20 pending rows have built up,
972
+ // then split into 2 windows of 50 rows each (≤100 rows per tick) and process
973
+ // both windows in parallel. The on-demand path used by SessionStart hooks runs
974
+ // with a 1-row threshold and 5×20 windows instead — see hooks/session-start.cjs
975
+ // ON_DEMAND_CYCLE1_ARGS.
976
+ // mainConfig.cycle1 values still win, so users can override any of these in
977
+ // config.json.
978
+ function periodicCycle1Config() {
979
+ return {
980
+ min_batch: 20,
981
+ session_cap: 2,
982
+ batch_size: 50,
983
+ concurrency: 2,
984
+ ...(mainConfig?.cycle1 || {}),
985
+ }
986
+ }
987
+
988
+ async function _finalizeCycle2Run(result) {
989
+ if (result.ok) {
990
+ await setCycleLastRun('cycle2', Date.now())
991
+ await setCycleLastRun('cycle2_last_error', '')
992
+ process.stderr.write('[cycle2] completed\n')
993
+ } else {
994
+ await setCycleLastRun('cycle2_last_error', result.error || 'unknown error')
995
+ process.stderr.write(`[cycle2] failed: ${result.error}\n`)
996
+ }
997
+ }
998
+
999
+ async function checkCycles() {
1000
+ if (mainConfig?.enabled === false) return
1001
+
1002
+ const cycle1Ms = parseInterval(mainConfig?.cycle1?.interval || '10m')
1003
+ const cycle2Ms = parseInterval(mainConfig?.cycle2?.interval || '1h')
1004
+ const cycle3Ms = parseInterval(mainConfig?.cycle3?.interval || '24h')
1005
+
1006
+ const now = Date.now()
1007
+ const last = await getCycleLastRun()
1008
+
1009
+ // Phase B §2.4 — cache-keeper health check + auto-restart.
1010
+ //
1011
+ // `last.cycle1 + cycle1Ms` is the next scheduled run time; anything beyond
1012
+ // that by > HEALTH_OVERDUE_MS means the keeper missed its window and the
1013
+ // Anthropic shard is drifting cold. Emit a warning, and — if we haven't
1014
+ // retried in the last cooldown window — force an unscheduled run so the
1015
+ // shard gets re-touched before the next Worker / Sub call pays the 2×
1016
+ // write premium. Cooldown prevents a tight retry loop when the underlying
1017
+ // cause (network, provider outage) is still broken.
1018
+ //
1019
+ // Cold-start guard: a fresh DB has last.cycle1 = 0, which would make
1020
+ // (now - 0 - cycle1Ms) blow past HEALTH_OVERDUE_MS on every first boot
1021
+ // and force-trigger the auto-restart branch even though the shard never
1022
+ // existed in the first place. The "drifting cold" concept doesn't apply
1023
+ // until at least one successful run has anchored a baseline.
1024
+ const cycle1OverdueMs = last.cycle1 > 0
1025
+ ? Math.max(0, now - last.cycle1 - cycle1Ms)
1026
+ : 0
1027
+ if (cycle1OverdueMs > CYCLE1_HEALTH_OVERDUE_MS) {
1028
+ const lastSeen = last.cycle1 ? new Date(last.cycle1).toISOString() : 'never'
1029
+ process.stderr.write(
1030
+ `[cycle1] overdue by ${Math.floor(cycle1OverdueMs / 60_000)}min `
1031
+ + `(last=${lastSeen}). Pool B Anthropic shard may be cold.\n`
1032
+ )
1033
+ const lastAutoRestart = last.cycle1_autoRestart || 0
1034
+ if (now - lastAutoRestart >= CYCLE1_AUTO_RESTART_COOLDOWN_MS) {
1035
+ // #14: record the attempt timestamp BEFORE the call (so a hung run
1036
+ // cannot tight-loop) and the result timestamp only on success. On
1037
+ // failure we return immediately instead of falling through into the
1038
+ // due branch — falling through would silently re-enter the same
1039
+ // failing path within the same tick.
1040
+ await setCycleLastRun('cycle1_autoRestart_attempt', now)
1041
+ try {
1042
+ const result = await _awaitCycle1Run(periodicCycle1Config())
1043
+ await setCycleLastRun('cycle1_autoRestart', Date.now())
1044
+ process.stderr.write(
1045
+ `[cycle1] auto-restart completed chunks=${result?.chunks ?? 0} processed=${result?.processed ?? 0}\n`
1046
+ )
1047
+ return
1048
+ } catch (e) {
1049
+ process.stderr.write(`[cycle1] auto-restart error: ${e.message}\n`)
1050
+ // Cooldown attempt timestamp is committed; do NOT fall through
1051
+ // to the due branch — next tick will retry after cooldown.
1052
+ return
1053
+ }
1054
+ }
1055
+ }
1056
+
1057
+ if (now - last.cycle1 >= cycle1Ms) {
1058
+ const result = await _awaitCycle1Run(periodicCycle1Config())
1059
+ process.stderr.write(`[cycle1] completed chunks=${result?.chunks ?? 0} processed=${result?.processed ?? 0}\n`)
1060
+ }
1061
+
1062
+ if (now - last.cycle2 >= cycle2Ms) {
1063
+ if (!_cycle2InFlight) {
1064
+ _cycle2InFlight = true
1065
+ // Detached: cycle2 can take minutes; awaiting here would delay the
1066
+ // next periodic checkCycles() tick and block sibling IPC (search,
1067
+ // append) on the memory worker. The in-flight guard prevents
1068
+ // concurrent runs; rejection is logged but does not propagate.
1069
+ runCycle2(db, mainConfig?.cycle2 || {}, {}, DATA_DIR)
1070
+ .then(_finalizeCycle2Run)
1071
+ .catch(err => process.stderr.write(`[cycle2] detached run failed: ${err?.message || err}\n`))
1072
+ .finally(() => { _cycle2InFlight = false })
1073
+ }
1074
+ }
1075
+
1076
+ if (now - last.cycle3 >= cycle3Ms) {
1077
+ if (!_cycle3InFlight) {
1078
+ _cycle3InFlight = true
1079
+ // Detached like cycle2 — core review walks every core_entries row with a
1080
+ // recall + LLM call per row, so it can take a while. 24h cadence default.
1081
+ runCycle3(db, mainConfig || {}, DATA_DIR)
1082
+ .then(() => setCycleLastRun('cycle3', Date.now()))
1083
+ .catch(err => process.stderr.write(`[cycle3] detached run failed: ${err?.message || err}\n`))
1084
+ .finally(() => { _cycle3InFlight = false })
1085
+ }
1086
+ }
1087
+ }
1088
+ let _cycle2InFlight = false
1089
+ let _cycle3InFlight = false
1090
+
1091
+ // #12: self-rescheduling timer. setInterval would fire ticks regardless of
1092
+ // whether the previous checkCycles() call had finished; with cycle1/cycle2
1093
+ // each potentially taking minutes, that races. Use setTimeout that re-arms
1094
+ // itself only after the prior tick resolves, plus an in-flight guard so a
1095
+ // stray manual call cannot stack ticks.
1096
+ let _checkCyclesInFlight = false
1097
+ async function _runCheckCyclesGuarded() {
1098
+ if (_checkCyclesInFlight) return
1099
+ _checkCyclesInFlight = true
1100
+ try { await checkCycles() }
1101
+ catch (e) { process.stderr.write(`[cycle-tick] error: ${e.message}\n`) }
1102
+ finally { _checkCyclesInFlight = false }
1103
+ }
1104
+ function _scheduleNextCheck() {
1105
+ _cycleInterval = setTimeout(async () => {
1106
+ _cycleInterval = null
1107
+ try {
1108
+ await _runCheckCyclesGuarded()
1109
+ } catch (e) {
1110
+ process.stderr.write(`[cycle-tick] re-arm guard caught: ${e?.message || e}\n`)
1111
+ } finally {
1112
+ // Re-arm regardless of inner outcome — _runCheckCyclesGuarded already
1113
+ // swallows its own errors, but defensive try/finally guarantees the
1114
+ // periodic tick continues even if a synchronous throw escapes.
1115
+ if (_cyclesActive) _scheduleNextCheck()
1116
+ }
1117
+ }, 60_000)
1118
+ }
1119
+ let _cyclesActive = false
1120
+ let _transcriptWatcher = null
1121
+ function _startCycles() {
1122
+ if (_cyclesActive) return
1123
+ _cyclesActive = true
1124
+ _scheduleNextCheck()
1125
+ _startupTimeout = setTimeout(() => { void _runCheckCyclesGuarded() }, 30_000)
1126
+ }
1127
+
1128
+ function _stopCycles() {
1129
+ _cyclesActive = false
1130
+ if (_cycleInterval) { clearTimeout(_cycleInterval); _cycleInterval = null }
1131
+ if (_startupTimeout) { clearTimeout(_startupTimeout); _startupTimeout = null }
1132
+ if (_transcriptWatcher) { try { _transcriptWatcher.stop() } catch {} _transcriptWatcher = null }
1133
+ }
1134
+
1135
+ async function _initRuntime() {
1136
+ if (_initialized) return
1137
+ await _initStore()
1138
+ // Restore the core_entries.id == 1..N invariant once per boot: SERIAL only
1139
+ // increments, so deleted rows leave permanent gaps. Fast no-op when already
1140
+ // contiguous (or empty). Runs only here — never in cycle2/addCore/deleteCore.
1141
+ await compactCoreIds(DATA_DIR)
1142
+ _transcriptWatcher = _initTranscriptWatcher()
1143
+ _startCycles()
1144
+ _initialized = true
1145
+ // Boot complete — continue straight into the deferred embedding warmup.
1146
+ // Fire-and-forget on the embedding worker thread; never awaited so it does
1147
+ // not delay init() returning or the memory-ready signal.
1148
+ fireDeferredEmbeddingWarmup()
1149
+ }
1150
+
1151
+ function _beginRuntimeInit() {
1152
+ if (_initialized) return Promise.resolve()
1153
+ if (!_initPromise) {
1154
+ _initPromise = _initRuntime().catch((e) => {
1155
+ process.stderr.write(`[memory-service] runtime init failed: ${e?.stack || e?.message || e}\n`)
1156
+ throw e
1157
+ })
1158
+ }
1159
+ return _initPromise
1160
+ }
1161
+
1162
+ function fmtDateOnly(d) {
1163
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
1164
+ }
1165
+
1166
+ function parsePeriod(period, hasQuery) {
1167
+ if (!period && hasQuery) period = '30d'
1168
+ if (!period) return null
1169
+ if (period === 'all') return null
1170
+ if (period === 'last') return { mode: 'last' }
1171
+ // Calendar-day windows: 'today' anchors at local midnight rather than
1172
+ // rolling 24h. Without this, a query asking 'today' at 01:30 would silently
1173
+ // include yesterday's last 22.5h of activity, mislabelling them as
1174
+ // 'today's work'. 'yesterday' is the previous calendar day.
1175
+ if (period === 'today') {
1176
+ const start = new Date()
1177
+ start.setHours(0, 0, 0, 0)
1178
+ return { startMs: start.getTime(), endMs: Date.now() }
1179
+ }
1180
+ if (period === 'yesterday') {
1181
+ const start = new Date()
1182
+ start.setDate(start.getDate() - 1)
1183
+ start.setHours(0, 0, 0, 0)
1184
+ const end = new Date(start)
1185
+ end.setHours(23, 59, 59, 999)
1186
+ return { startMs: start.getTime(), endMs: end.getTime() }
1187
+ }
1188
+ if (period === 'this_week' || period === 'last_week') {
1189
+ // R6 P9: calendar Mon-Sun previous/current week. Mon-start ISO
1190
+ // convention. Replaces R5 rolling 7-14d range which was empty for
1191
+ // sessions where "last week" decisions actually fell on Mon (4/27) of
1192
+ // this week. Precise calendar bounds match natural-language intuition.
1193
+ const d = new Date()
1194
+ d.setHours(0, 0, 0, 0)
1195
+ const dayOfWeek = d.getDay()
1196
+ const daysSinceMon = (dayOfWeek + 6) % 7
1197
+ const thisWeekMon = new Date(d)
1198
+ thisWeekMon.setDate(d.getDate() - daysSinceMon)
1199
+ if (period === 'this_week') {
1200
+ return { startMs: thisWeekMon.getTime(), endMs: Date.now() }
1201
+ }
1202
+ const lastWeekMon = new Date(thisWeekMon)
1203
+ lastWeekMon.setDate(thisWeekMon.getDate() - 7)
1204
+ const lastWeekSunEnd = new Date(thisWeekMon.getTime() - 1)
1205
+ return { startMs: lastWeekMon.getTime(), endMs: lastWeekSunEnd.getTime() }
1206
+ }
1207
+ const relMatch = period.match(/^(\d+)(m|h|d)$/)
1208
+ if (relMatch) {
1209
+ const n = parseInt(relMatch[1])
1210
+ const unit = relMatch[2]
1211
+ const now = new Date()
1212
+ if (unit === 'm') {
1213
+ // Minute granularity is for "resume from the previous turn / pick
1214
+ // up where we left off" style recall — sub-hour windows where 1h
1215
+ // is too coarse. n=0 is invalid (the regex requires \d+ which
1216
+ // matches "0" but a zero-width window returns no rows; leave that
1217
+ // as caller-supplied no-op).
1218
+ const start = new Date(now.getTime() - n * 60_000)
1219
+ return { startMs: start.getTime(), endMs: now.getTime() }
1220
+ }
1221
+ if (unit === 'h') {
1222
+ const start = new Date(now.getTime() - n * 3600_000)
1223
+ return { startMs: start.getTime(), endMs: now.getTime() }
1224
+ }
1225
+ const start = new Date(now)
1226
+ start.setDate(start.getDate() - n)
1227
+ return { startMs: start.getTime(), endMs: now.getTime() }
1228
+ }
1229
+ const rangeMatch = period.match(/^(\d{4}-\d{2}-\d{2})~(\d{4}-\d{2}-\d{2})$/)
1230
+ if (rangeMatch) {
1231
+ return {
1232
+ startMs: Date.parse(rangeMatch[1] + 'T00:00:00'),
1233
+ endMs: Date.parse(rangeMatch[2] + 'T23:59:59.999'),
1234
+ }
1235
+ }
1236
+ const dateMatch = period.match(/^(\d{4}-\d{2}-\d{2})$/)
1237
+ if (dateMatch) {
1238
+ return {
1239
+ startMs: Date.parse(dateMatch[1] + 'T00:00:00'),
1240
+ endMs: Date.parse(dateMatch[1] + 'T23:59:59.999'),
1241
+ exact: true,
1242
+ }
1243
+ }
1244
+ return null
1245
+ }
1246
+
1247
+ function formatTs(tsMs) {
1248
+ const n = Number(tsMs)
1249
+ if (Number.isFinite(n) && n > 1e12) {
1250
+ return new Date(n).toLocaleString('sv-SE').slice(0, 16)
1251
+ }
1252
+ return String(tsMs ?? '').slice(0, 16)
1253
+ }
1254
+
1255
+ async function handleSearch(args, signal) {
1256
+ // Cooperative abort check: throw early if the caller already aborted
1257
+ // (IPC cancel handler signals the AbortController before re-entry).
1258
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1259
+ // id mode (follow-up lookup): caller passed `#N` markers from a prior
1260
+ // recall result. Fetch those rows directly + their chunk members,
1261
+ // bypassing hybrid search entirely. Output reuses renderEntryLines so
1262
+ // the shape stays identical to the search path (chunk members first,
1263
+ // root summary fallback).
1264
+ if (Array.isArray(args.ids) && args.ids.length > 0) {
1265
+ const ids = args.ids
1266
+ .map(v => Number(v))
1267
+ .filter(v => Number.isFinite(v) && v > 0)
1268
+ if (ids.length === 0) return { text: '(no valid ids)' }
1269
+ const includeArchived = args.includeArchived !== false
1270
+ const category = args.category
1271
+ const period = String(args.period ?? '').trim() || undefined
1272
+ const temporal = parsePeriod(period, false)
1273
+ let projectScope
1274
+ if (typeof args.projectScope === 'string' && args.projectScope) {
1275
+ projectScope = args.projectScope
1276
+ } else {
1277
+ const projectId = resolveProjectScope(typeof args.cwd === 'string' && args.cwd ? args.cwd : null)
1278
+ projectScope = projectId !== null ? projectId : 'common'
1279
+ }
1280
+ const excludeStatuses = includeArchived ? [] : ['archived']
1281
+ const rows = await fetchEntriesByIdsScoped(db, ids, {
1282
+ ts_from: temporal?.startMs,
1283
+ ts_to: temporal?.endMs,
1284
+ excludeStatuses,
1285
+ category,
1286
+ projectScope,
1287
+ })
1288
+ if (rows.length === 0) return { text: '(no results)' }
1289
+ // Members for any root rows in the result set.
1290
+ const rootIds = rows.filter(r => r.is_root === 1).map(r => Number(r.id))
1291
+ const memberLeafIds = new Set()
1292
+ if (rootIds.length > 0) {
1293
+ const { rows: memberRows } = await db.query(
1294
+ `SELECT id, ts, role, content, chunk_root
1295
+ FROM entries WHERE chunk_root = ANY($1::bigint[]) AND is_root = 0
1296
+ ORDER BY ts ASC, id ASC`,
1297
+ [rootIds],
1298
+ )
1299
+ const membersByRoot = new Map()
1300
+ for (const m of memberRows) {
1301
+ const k = Number(m.chunk_root)
1302
+ if (!membersByRoot.has(k)) membersByRoot.set(k, [])
1303
+ membersByRoot.get(k).push(m)
1304
+ memberLeafIds.add(Number(m.id))
1305
+ }
1306
+ for (const r of rows) {
1307
+ if (r.is_root === 1) r.members = membersByRoot.get(Number(r.id)) ?? []
1308
+ }
1309
+ }
1310
+ // Preserve caller-supplied id order; drop leaves already inlined as a
1311
+ // root's chunk member to prevent double emission when the caller names
1312
+ // a root and one of its leaves in the same batch.
1313
+ const byId = new Map(rows.map(r => [Number(r.id), r]))
1314
+ const ordered = ids
1315
+ .map(id => byId.get(id))
1316
+ .filter(Boolean)
1317
+ .filter(r => !(r.is_root === 0 && memberLeafIds.has(Number(r.id))))
1318
+ return { text: renderEntryLines(ordered) }
1319
+ }
1320
+ // Array query — fan out in parallel, each query runs its own hybrid search
1321
+ // path, and results are grouped in the response so the caller sees one
1322
+ // ranked list per angle. Collapses what would otherwise be N sequential
1323
+ // tool calls into a single invocation.
1324
+ if (Array.isArray(args.query)) {
1325
+ // Dedup + fan-out cap. The cap protects the result envelope from
1326
+ // over-eager callers (20+ near-duplicate queries N× the IO) without
1327
+ // silently swallowing the caller's intent: when the input exceeds
1328
+ // QUERIES_CAP, prepend a one-line note so the caller can see the
1329
+ // truncation and re-shape their query list.
1330
+ const QUERIES_CAP = 5
1331
+ const dedup = [...new Set(args.query.map(q => String(q || '').trim()).filter(Boolean))]
1332
+ if (dedup.length === 0) return { text: '' }
1333
+ const queries = dedup.slice(0, QUERIES_CAP)
1334
+ const dropped = dedup.length - queries.length
1335
+ const rest = { ...args }
1336
+ delete rest.query
1337
+ const deadlineSec = Math.max(1, Number(process.env.MEMORY_FANOUT_DEADLINE_S) || 180)
1338
+ const deadlineMs = deadlineSec * 1000
1339
+ const fanOutAbort = new AbortController()
1340
+ let deadlineTimer
1341
+ const deadlineRace = new Promise((_res, rej) => {
1342
+ deadlineTimer = setTimeout(() => {
1343
+ fanOutAbort.abort(new Error(`memory fan-out deadline exceeded (${deadlineSec}s)`))
1344
+ rej(Object.assign(new Error(`memory fan-out deadline exceeded (${deadlineSec}s)`), { _deadline: true }))
1345
+ }, deadlineMs)
1346
+ })
1347
+ let settled
1348
+ try {
1349
+ // Pre-warm the per-query embedding cache with one batched ONNX run so
1350
+ // each sub-search lands an embedText cache hit (~0ms) instead of
1351
+ // serially queueing through the worker's single-flight inference lock.
1352
+ // Replaces N sequential ~130ms inferences with one ~150-200ms batch.
1353
+ //
1354
+ // Race against the same deadline as the fan-out itself: a stuck
1355
+ // embedding worker would previously park here indefinitely because
1356
+ // the timer hadn't been started yet from the fan-out's perspective.
1357
+ await Promise.race([embedTexts(queries), deadlineRace])
1358
+ settled = await Promise.race([
1359
+ Promise.all(queries.map(async (q) => {
1360
+ if (fanOutAbort.signal.aborted) throw fanOutAbort.signal.reason
1361
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1362
+ const sub = await handleSearch({ ...rest, query: q }, signal)
1363
+ return `[${q}]\n${sub.text || '(no results)'}`
1364
+ })),
1365
+ deadlineRace,
1366
+ ])
1367
+ } catch (err) {
1368
+ throw err
1369
+ } finally {
1370
+ clearTimeout(deadlineTimer)
1371
+ }
1372
+ const parts = settled
1373
+ const header = dropped > 0
1374
+ ? `note: ${dedup.length} queries received, ${queries.length} processed, ${dropped} dropped (cap ${QUERIES_CAP})\n\n`
1375
+ : ''
1376
+ return { text: header + parts.join('\n\n') }
1377
+ }
1378
+ const query = String(args.query ?? '').trim()
1379
+ let period = String(args.period ?? '').trim() || undefined
1380
+ // Period and sort are caller-supplied only. Lead is responsible for
1381
+ // mapping vague time phrases / chronological intent into the period
1382
+ // argument before calling; the engine does not infer them from query
1383
+ // text.
1384
+ const RECALL_LIMIT_CAP = 100
1385
+ const RECALL_OFFSET_CAP = 500
1386
+ const requestedLimit = Number(args.limit)
1387
+ const requestedOffset = Number(args.offset)
1388
+ let limit = Math.max(1, Number.isFinite(requestedLimit) ? requestedLimit : 10)
1389
+ let offset = Math.max(0, Number.isFinite(requestedOffset) ? requestedOffset : 0)
1390
+ const recallCapNotes = []
1391
+ if (Number.isFinite(requestedLimit) && requestedLimit > RECALL_LIMIT_CAP) {
1392
+ limit = RECALL_LIMIT_CAP
1393
+ recallCapNotes.push(`limit capped to ${RECALL_LIMIT_CAP} (requested ${requestedLimit})`)
1394
+ } else {
1395
+ limit = Math.min(RECALL_LIMIT_CAP, limit)
1396
+ }
1397
+ if (Number.isFinite(requestedOffset) && requestedOffset > RECALL_OFFSET_CAP) {
1398
+ offset = RECALL_OFFSET_CAP
1399
+ recallCapNotes.push(`offset capped to ${RECALL_OFFSET_CAP} (requested ${requestedOffset})`)
1400
+ } else {
1401
+ offset = Math.min(RECALL_OFFSET_CAP, offset)
1402
+ }
1403
+ const recallCapPrefix = recallCapNotes.length ? `${recallCapNotes.join('; ')}\n` : ''
1404
+ const sort = args.sort != null ? String(args.sort) : 'importance'
1405
+ // Chunk content is the primary recall output. Members default to true so
1406
+ // callers receive the raw chunk leaves (the cycle1-produced semantic
1407
+ // chunks) rather than just the root's cycle2-compressed summary line.
1408
+ // Explicit `includeMembers:false` keeps the legacy summary-only mode.
1409
+ const includeMembers = args.includeMembers !== false
1410
+ const includeRaw = Boolean(args.includeRaw)
1411
+ const includeArchived = args.includeArchived !== false
1412
+ const category = args.category
1413
+ const temporal = parsePeriod(period, Boolean(query))
1414
+
1415
+ // Derive projectScope from caller cwd (falls back to process.cwd()).
1416
+ // Explicit args.projectScope (string) takes priority so callers can
1417
+ // override to 'all', 'common', or a specific slug.
1418
+ let projectScope
1419
+ if (typeof args.projectScope === 'string' && args.projectScope) {
1420
+ projectScope = args.projectScope
1421
+ } else {
1422
+ const projectId = resolveProjectScope(typeof args.cwd === 'string' && args.cwd ? args.cwd : null)
1423
+ projectScope = projectId !== null ? projectId : 'common'
1424
+ }
1425
+
1426
+ // R11 reviewer M4: calendar-bounded periods disable freshness decay
1427
+ // so within-period ranking doesn't downgrade Mon entries vs Sun.
1428
+ const CALENDAR_PERIODS = new Set(['yesterday', 'today', 'this_week', 'last_week'])
1429
+ const isCalendarPeriod = period != null
1430
+ && (CALENDAR_PERIODS.has(period) || /^\d{4}-\d{2}-\d{2}/.test(period))
1431
+ const applyFreshness = !isCalendarPeriod
1432
+
1433
+ if (query) {
1434
+ const _t0 = Date.now()
1435
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1436
+ const queryVector = await embedText(query)
1437
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1438
+ const _t1 = Date.now()
1439
+ if (process.env.MIXDOG_DEBUG_MEMORY) {
1440
+ process.stderr.write(`[search-time] embed=${_t1 - _t0}ms query="${query.slice(0, 60)}"\n`)
1441
+ }
1442
+ // Push ts and status filters into the hybrid candidate query so FTS / vec
1443
+ // rank inside the requested window, not the whole tree. The previous post-
1444
+ // filter approach silently emptied results when relevant matches sat
1445
+ // outside `period` (default 30d) and could not bubble through.
1446
+ // Recall is history-first: archived roots hold most prior work. Callers
1447
+ // that need only live invariants can pass includeArchived:false.
1448
+ const excludeStatuses = includeArchived ? [] : ['archived']
1449
+ const results = await searchRelevantHybrid(db, query, {
1450
+ limit: limit + offset,
1451
+ queryVector: Array.isArray(queryVector) ? queryVector : null,
1452
+ includeMembers,
1453
+ ts_from: temporal?.startMs,
1454
+ ts_to: temporal?.endMs,
1455
+ applyFreshness,
1456
+ projectScope,
1457
+ category,
1458
+ excludeStatuses,
1459
+ // useHotActive was set to true here so default (no-period) calls
1460
+ // routed through the mv_hot_active materialized view — a narrow
1461
+ // active-roots-only pool. Live usage is dominated by vague-time
1462
+ // queries ("recent / lately") where Lead callers omit the period
1463
+ // filter, leaving the MV as the sole source. That hid every
1464
+ // orphan leaf and every pending root — fresh work from the last 1-60
1465
+ // minutes never surfaced. Now that the entries-table CTE legs run
1466
+ // against broaden HNSW + GIN trgm partial indexes (the
1467
+ // is_root=1 predicate was dropped in the same revision), the
1468
+ // entries path is fast enough (1-2 ms ANN on ~10K rows, O(log N)
1469
+ // through 1M+) to be the single source of truth. The MV is left in
1470
+ // place for now but no longer routed to from search; cycle2 may stop
1471
+ // refreshing it in a follow-up commit once nothing else reads it.
1472
+ useHotActive: false,
1473
+ })
1474
+ let filtered = results
1475
+ if (sort === 'date') {
1476
+ // R11 reviewer L5: NaN guard — entries with null/undefined ts default
1477
+ // to 0 so the comparator stays numeric and stable.
1478
+ filtered.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0))
1479
+ } else {
1480
+ filtered.sort((a, b) => {
1481
+ const sa = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0 }
1482
+ return (sa(b.retrievalScore ?? b.rrf ?? 0) - sa(a.retrievalScore ?? a.rrf ?? 0))
1483
+ || (sa(b.score ?? 0) - sa(a.score ?? 0))
1484
+ || (sa(b.ts ?? 0) - sa(a.ts ?? 0))
1485
+ || (Number(a.id ?? 0) - Number(b.id ?? 0))
1486
+ })
1487
+ }
1488
+ if (includeRaw) {
1489
+ // Reserve slots for raw rows under sort=importance: hybrid rows are
1490
+ // already score-sorted descending, so a full hybrid page (limit rows)
1491
+ // would shut out raw rows entirely after slice(offset, offset+limit).
1492
+ // Reserve up to RAW_RESERVE slots near the top of the post-slice
1493
+ // window by trimming the hybrid prefix before merging, then re-sort
1494
+ // for sort=date or otherwise append (already ranked) for importance.
1495
+ const RAW_FETCH = 20
1496
+ const rawRows = await readRawRowsInWindow(
1497
+ db,
1498
+ temporal?.startMs ?? null,
1499
+ temporal?.endMs ?? Date.now(),
1500
+ RAW_FETCH,
1501
+ { projectScope },
1502
+ )
1503
+ const seenIds = new Set(filtered.map(r => r.id))
1504
+ const newRaw = rawRows.filter(r => !seenIds.has(r.id))
1505
+ if (sort === 'date') {
1506
+ for (const r of newRaw) filtered.push(r)
1507
+ filtered.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0))
1508
+ } else {
1509
+ // sort=importance: append raw rows after the hybrid page (mostly
1510
+ // ineffective — slice(offset, offset+limit) typically shuts them
1511
+ // out). Proper includeRaw paging fix deferred (needs fetching extra rows / paging redesign).
1512
+ for (const r of newRaw) filtered.push(r)
1513
+ }
1514
+ }
1515
+ const sliced = filtered.slice(offset, offset + limit)
1516
+ const _t2 = Date.now()
1517
+ if (process.env.MIXDOG_DEBUG_MEMORY) {
1518
+ process.stderr.write(`[search-time] hybrid+sort+raw=${_t2 - _t1}ms rows=${filtered.length} sliced=${sliced.length}\n`)
1519
+ }
1520
+ // Emit a recall trace event so getTraceWithEntries() can correlate
1521
+ // this search with the top-ranked memory entry. One event per
1522
+ // handleSearch call (not per returned row) — cheapest meaningful link.
1523
+ // parent_span_id left null: the bridge-side span id is only known after
1524
+ // the DB insert of the loop/tool events, which happens async on the
1525
+ // client side and is not available here.
1526
+ if (_traceDb && filtered.length > 0) {
1527
+ const topHit = filtered[0]
1528
+ const topId = topHit?.id != null ? Number(topHit.id) : null
1529
+ if (topId !== null && Number.isFinite(topId)) {
1530
+ insertTraceEvents(_traceDb, [{
1531
+ ts: Date.now(),
1532
+ kind: 'recall',
1533
+ entry_id: topId,
1534
+ payload: { query: query.slice(0, 200), hit_count: filtered.length },
1535
+ }]).catch(e => process.stderr.write(`[trace] insertTraceEvents error: ${e?.message}\n`))
1536
+ }
1537
+ }
1538
+ const out = { text: recallCapPrefix + renderEntryLines(sliced) }
1539
+ if (process.env.MIXDOG_DEBUG_MEMORY) {
1540
+ process.stderr.write(`[search-time] render+trace=${Date.now() - _t2}ms total=${Date.now() - _t0}ms textLen=${out.text.length}\n`)
1541
+ }
1542
+ return out
1543
+ }
1544
+
1545
+ const filters = { limit: limit + offset }
1546
+ if (temporal?.startMs != null) { filters.ts_from = temporal.startMs; filters.ts_to = temporal.endMs }
1547
+ if (temporal?.mode === 'last' && _bootTimestamp) {
1548
+ filters.ts_to = _bootTimestamp - 1
1549
+ }
1550
+ filters.projectScope = projectScope
1551
+ if (category != null) filters.category = category
1552
+ filters.sort = sort
1553
+ if (!includeArchived) filters.excludeStatuses = ['archived']
1554
+ if (includeMembers) filters.includeMembers = true
1555
+ const rows = await retrieveEntries(db, filters)
1556
+ const sliced = rows.slice(offset, offset + limit)
1557
+ return { text: recallCapPrefix + renderEntryLines(sliced) }
1558
+ }
1559
+
1560
+ function renderEntryLines(rows) {
1561
+ if (!rows || rows.length === 0) return '(no results)'
1562
+ const lines = []
1563
+ // Bound total emitted lines (roots x members) so a many-member recall can't
1564
+ // inject unbounded output. Per-line content is already capped at 1000 chars;
1565
+ // this caps the line COUNT. Narrow the query (limit/period/projectScope) for more.
1566
+ const RECALL_LINE_CAP = 200
1567
+ let _capped = false
1568
+ outer:
1569
+ for (const r of rows) {
1570
+ const hasMembers = Array.isArray(r.members) && r.members.length > 0
1571
+ if (hasMembers) {
1572
+ // Chunks present: emit each member as its own line. Root row is a
1573
+ // grouping artifact for retrieval — the caller wants the chunk
1574
+ // content (cycle1 raw), not the cycle2-compressed summary.
1575
+ for (const m of r.members) {
1576
+ if (lines.length >= RECALL_LINE_CAP) { _capped = true; break outer }
1577
+ const mTs = formatTs(m.ts)
1578
+ const role = m.role === 'user' ? 'u' : m.role === 'assistant' ? 'a' : (m.role || '?')
1579
+ const content = cleanMemoryText(String(m.content ?? '')).slice(0, 1000)
1580
+ lines.push(`[${mTs}] ${role}: ${content} #${m.id}`)
1581
+ }
1582
+ } else {
1583
+ if (lines.length >= RECALL_LINE_CAP) { _capped = true; break }
1584
+ // No chunks (root not yet chunked by cycle1, or orphan leaf): emit
1585
+ // the row itself in the same shape. element/summary fall back to
1586
+ // raw content when both are absent.
1587
+ const ts = formatTs(r.ts)
1588
+ const element = r.element ?? ''
1589
+ const summary = r.summary ?? ''
1590
+ // Standalone leaf rows (is_root=0, no parent chunks_root resolved
1591
+ // into a `members` list) carry their u/a role just like inline
1592
+ // chunk members — surface it so the format stays consistent across
1593
+ // the two emission paths.
1594
+ const rolePrefix = r.is_root === 0 && r.role
1595
+ ? (r.role === 'user' ? 'u: ' : r.role === 'assistant' ? 'a: ' : `${r.role}: `)
1596
+ : ''
1597
+ const body = element || summary
1598
+ ? `${element}${summary ? ' — ' + summary : ''}`
1599
+ : cleanMemoryText(String(r.content ?? '')).slice(0, 1000)
1600
+ lines.push(`[${ts}] ${rolePrefix}${body.slice(0, 1000)} #${r.id}`)
1601
+ }
1602
+ }
1603
+ if (_capped) lines.push(`[recall truncated — showing first ${RECALL_LINE_CAP} lines; narrow the query (limit/period/projectScope) for the rest]`)
1604
+ return lines.join('\n')
1605
+ }
1606
+
1607
+ async function entryStats() {
1608
+ return await db.transaction(async (tx) => {
1609
+ const total = (await tx.query(`SELECT COUNT(*) c FROM entries`)).rows[0].c
1610
+ const roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1`)).rows[0].c
1611
+ const active_roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'active'`)).rows[0].c
1612
+ const archived_roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'archived'`)).rows[0].c
1613
+ const unchunked_leaves = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE chunk_root IS NULL`)).rows[0].c
1614
+ const cycle2_pending_roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'pending'`)).rows[0].c
1615
+ const core_entries = (await tx.query(`SELECT COUNT(*) c FROM core_entries`)).rows[0].c
1616
+ const core_embed_null = (await tx.query(`SELECT COUNT(*) c FROM core_entries WHERE embedding IS NULL`)).rows[0].c
1617
+ const active_core_summaries = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'active' AND core_summary IS NOT NULL`)).rows[0].c
1618
+ const active_core_summary_missing = (await tx.query(`
1619
+ SELECT COUNT(*) c
1620
+ FROM entries
1621
+ WHERE is_root = 1
1622
+ AND status = 'active'
1623
+ AND (core_summary IS NULL OR btrim(core_summary) = '')
1624
+ `)).rows[0].c
1625
+ const byStatus = (await tx.query(`SELECT status, COUNT(*) c FROM entries WHERE is_root = 1 GROUP BY status`)).rows
1626
+ const byCategory = (await tx.query(`SELECT category, COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'active' GROUP BY category ORDER BY c DESC`)).rows
1627
+ const mvRows = (await tx.query(`SELECT relispopulated FROM pg_class WHERE relname = 'mv_hot_active' LIMIT 1`)).rows
1628
+ const mv_hot_active_populated = mvRows.length ? Boolean(mvRows[0].relispopulated) : null
1629
+ return {
1630
+ total, roots, active_roots, archived_roots, unchunked_leaves, cycle2_pending_roots,
1631
+ core_entries, core_embed_null, active_core_summaries, active_core_summary_missing,
1632
+ mv_hot_active_populated,
1633
+ byStatus, byCategory,
1634
+ }
1635
+ })
1636
+ }
1637
+
1638
+ async function _handleMemCycle1(args, config, signal) {
1639
+ const minBatchOverride = Number(args?.min_batch)
1640
+ const sessionCapOverride = Number(args?.session_cap)
1641
+ const batchSizeOverride = Number(args?.batch_size)
1642
+ const concurrencyOverride = Number(args?.concurrency)
1643
+ const baseCycle1 = config?.cycle1 || {}
1644
+ let cycle1Config = baseCycle1
1645
+ // _runCycle1Impl reads `config?.min_batch ?? config?.cycle1?.min_batch ??
1646
+ // default` — top-level wins, so pin the override at top-level only.
1647
+ if (Number.isFinite(minBatchOverride) && minBatchOverride > 0) {
1648
+ cycle1Config = { ...cycle1Config, min_batch: minBatchOverride }
1649
+ }
1650
+ if (Number.isFinite(sessionCapOverride) && sessionCapOverride > 0) {
1651
+ cycle1Config = { ...cycle1Config, session_cap: sessionCapOverride }
1652
+ }
1653
+ if (Number.isFinite(batchSizeOverride) && batchSizeOverride > 0) {
1654
+ cycle1Config = { ...cycle1Config, batch_size: batchSizeOverride }
1655
+ }
1656
+ if (Number.isFinite(concurrencyOverride) && concurrencyOverride > 0) {
1657
+ cycle1Config = { ...cycle1Config, concurrency: Math.min(8, Math.floor(concurrencyOverride)) }
1658
+ }
1659
+ const callerDeadlineMs = Number(args?._callerDeadlineMs) || 0
1660
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1661
+ const cycle1Options = callerDeadlineMs > 0 ? { callerDeadlineMs, signal } : { signal }
1662
+ const result = await _awaitCycle1Run(
1663
+ cycle1Config,
1664
+ cycle1Options,
1665
+ )
1666
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1667
+ const pendingStr = result?.pendingRows != null ? result.pendingRows : 0
1668
+ const inFlightStr = result?.skippedInFlight === true ? 'true' : 'false'
1669
+ const timedOutPart = result?.timedOutWaiting === true ? ' timedOut=true' : ''
1670
+ const omitted = Array.isArray(result?.omitted_row_ids) ? result.omitted_row_ids.length : Number(result?.quality?.omitted_rows || 0)
1671
+ const prefiltered = Array.isArray(result?.prefiltered_row_ids) ? result.prefiltered_row_ids.length : Number(result?.quality?.prefiltered_rows || 0)
1672
+ const failedRows = Array.isArray(result?.failed_row_ids) ? result.failed_row_ids.length : Number(result?.quality?.failed_rows || 0)
1673
+ const invalidChunks = Array.isArray(result?.invalid_chunks) ? result.invalid_chunks.length : Number(result?.quality?.invalid_chunks || 0)
1674
+ return {
1675
+ text: `cycle1: chunks=${result.chunks} processed=${result.processed} skipped_chunks=${result.skipped}` +
1676
+ ` omitted=${omitted} prefiltered=${prefiltered} failed_rows=${failedRows} invalid_chunks=${invalidChunks}` +
1677
+ ` pending=${pendingStr} inFlight=${inFlightStr}${timedOutPart}`,
1678
+ }
1679
+ }
1680
+
1681
+ async function _handleMemCycle2(args, config, signal) {
1682
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1683
+ const result = await runCycle2(db, config?.cycle2 || {}, { signal }, DATA_DIR)
1684
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1685
+ await _finalizeCycle2Run(result)
1686
+ const counts = {
1687
+ promoted: result?.promoted || 0,
1688
+ archived: result?.archived || 0,
1689
+ merged: result?.merged || 0,
1690
+ updated: result?.updated || 0,
1691
+ kept: result?.kept || 0,
1692
+ rejected_verb: result?.rejected_verb || 0,
1693
+ merge_rejected: result?.merge_rejected || 0,
1694
+ missing_core: result?.missing_core_summary || 0,
1695
+ core_backfill: result?.core_embedding_backfill || 0,
1696
+ cascade_drop: result?.cascade?.dropped || 0,
1697
+ phase_merge: result?.phase_merge?.merged || 0,
1698
+ core_overlap: result?.phase_merge?.core_overlap || 0,
1699
+ }
1700
+ const parts = Object.entries(counts).filter(([, v]) => v > 0).map(([k, v]) => `${k}=${v}`)
1701
+ if (parts.length) return { text: `cycle2 ${parts.join(' ')}` }
1702
+ // No applied counts — disambiguate the "noop" so a broken gate is visible
1703
+ // instead of looking like a clean, nothing-to-do run.
1704
+ let cause = ''
1705
+ if (result?.skippedInFlight) cause = ' (skipped: in-flight)'
1706
+ else if (result?.ok === false) cause = ` (error: ${result.error || 'unknown'})`
1707
+ else if (result?.gate_failed) cause = ' (gate_failed)'
1708
+ return { text: `cycle2 noop${cause}` }
1709
+ }
1710
+
1711
+ async function _handleMemCycle3(args, config, signal) {
1712
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1713
+ const confirmed = args?.confirm === 'APPLY CYCLE3'
1714
+ const requestedMode = typeof args?.cycle3Mode === 'string' ? args.cycle3Mode : null
1715
+ const applyMode = confirmed
1716
+ ? 'confirmed'
1717
+ : (requestedMode === 'proposal' || requestedMode === 'dry-run' || requestedMode === 'dryrun')
1718
+ ? 'proposal'
1719
+ : 'conservative'
1720
+ const result = await runCycle3(db, config || {}, DATA_DIR, { signal, apply: confirmed ? true : undefined, applyMode })
1721
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1722
+ const parts = ['reviewed', 'kept', 'updated', 'merged', 'deleted']
1723
+ .map(k => `${k}=${result?.[k] || 0}`)
1724
+ if (result?.proposed) {
1725
+ parts.push(`proposal_update=${result.proposed.updated || 0}`)
1726
+ parts.push(`proposal_merge=${result.proposed.merged || 0}`)
1727
+ parts.push(`proposal_delete=${result.proposed.deleted || 0}`)
1728
+ }
1729
+ if (result?.held) {
1730
+ parts.push(`held_update=${result.held.updated || 0}`)
1731
+ parts.push(`held_merge=${result.held.merged || 0}`)
1732
+ parts.push(`held_delete=${result.held.deleted || 0}`)
1733
+ }
1734
+ parts.push(`mode=${result?.applyMode || applyMode}`)
1735
+ parts.push(`applied=${result?.applied === true ? 'true' : 'false'}`)
1736
+ if (result?.skippedInFlight) parts.push('inFlight=true')
1737
+ const errPart = result?.error ? ` error=${result.error}` : ''
1738
+ return { text: `cycle3 ${parts.join(' ')}${errPart}` }
1739
+ }
1740
+
1741
+ async function _handleMemFlush(args, config, signal) {
1742
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1743
+ const r1 = await _awaitCycle1Run(config?.cycle1 || {}, { signal })
1744
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1745
+ const r2 = await runCycle2(db, config?.cycle2 || {}, { signal }, DATA_DIR)
1746
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1747
+ await _finalizeCycle2Run(r2)
1748
+ return { text: `flush: cycle1 chunks=${r1.chunks} processed=${r1.processed}, cycle2 ${JSON.stringify(r2)}` }
1749
+ }
1750
+
1751
+ async function _handleMemStatus(args, config) {
1752
+ const stats = await entryStats()
1753
+ const last = await getCycleLastRun()
1754
+ let dims = 0
1755
+ let dimsErr = null
1756
+ try {
1757
+ const raw = await getMetaValue(db, 'embedding.current_dims', null)
1758
+ if (raw != null) dims = Number(JSON.parse(raw))
1759
+ if (!Number.isFinite(dims)) dims = 0
1760
+ } catch (e) {
1761
+ // Surface the error in the status line instead of masquerading a meta
1762
+ // read failure as dims=0 (which is indistinguishable from a fresh,
1763
+ // pre-bootstrap DB). Keep status callable so other lines still render.
1764
+ dims = 0
1765
+ dimsErr = e?.message || String(e)
1766
+ }
1767
+ const bootstrapComplete = await isBootstrapComplete(db)
1768
+ const lastCycle1Ago = last.cycle1 ? `${Math.round((Date.now() - last.cycle1) / 60000)}m ago` : 'never'
1769
+ const lastCycle2Ago = last.cycle2 ? `${Math.round((Date.now() - last.cycle2) / 60000)}m ago` : 'never'
1770
+ const activeTargetCap = Number.isFinite(Number(config?.cycle2?.active_target_cap))
1771
+ ? Number(config?.cycle2?.active_target_cap)
1772
+ : CYCLE2_ACTIVE_TARGET_CAP
1773
+ const mvState = stats.mv_hot_active_populated === null
1774
+ ? 'missing'
1775
+ : stats.mv_hot_active_populated ? 'populated' : 'unpopulated'
1776
+ const lines = [
1777
+ `entries: total=${stats.total} roots=${stats.roots} cycle1_raw=${stats.unchunked_leaves} (unchunked leaves) cycle2_pending=${stats.cycle2_pending_roots} (awaiting cycle2 review)`,
1778
+ `status: ${stats.byStatus.map(r => `${r.status ?? '?'}:${r.c}`).join(', ') || 'empty'}`,
1779
+ `categories(active): ${stats.byCategory.map(r => `${r.category ?? 'NULL'}:${r.c}`).join(', ') || 'empty'} active_target_cap=${activeTargetCap}`,
1780
+ `core_memory: user=${stats.core_entries} embed_null=${stats.core_embed_null} active_core=${stats.active_core_summaries} active_missing_core=${stats.active_core_summary_missing}`,
1781
+ `embedding_index: ready dims=${dims}${dimsErr ? ` (meta_read_error: ${dimsErr})` : ''}`,
1782
+ `recall_index: mv_hot_active=${mvState}`,
1783
+ `bootstrap: ${bootstrapComplete ? 'complete' : 'incomplete'}`,
1784
+ `last_cycle1: ${lastCycle1Ago}`,
1785
+ `last_cycle2: ${lastCycle2Ago}`,
1786
+ ...(last.cycle2_last_error ? [`last_cycle2_error: ${last.cycle2_last_error}`] : []),
1787
+ ]
1788
+ return { text: lines.join('\n') }
1789
+ }
1790
+
1791
+ async function _handleMemRebuild(args, config, signal) {
1792
+ if (args.confirm !== 'REBUILD MEMORY') {
1793
+ return { text: 'rebuild requires confirm: "REBUILD MEMORY" (truncates classification columns and re-runs cycles)', isError: true }
1794
+ }
1795
+ // Drain any pre-reset cycle1 BEFORE the destructive truncation so the
1796
+ // post-reset run is not started concurrently against the same DB.
1797
+ // _awaitCycle1Run() may release the outer handle on a caller deadline while
1798
+ // the inner runCycle1 promise still owns the DB writes. Drain both layers,
1799
+ // then loop once more if one layer exposed another promise while awaiting.
1800
+ const drainedCycle1Promises = new Set()
1801
+ for (;;) {
1802
+ const pendingCycle1Promises = [_cycle1InFlight, getInFlightCycle1(db)]
1803
+ .filter(p => p && !drainedCycle1Promises.has(p))
1804
+ if (pendingCycle1Promises.length === 0) break
1805
+ for (const pendingCycle1 of pendingCycle1Promises) {
1806
+ drainedCycle1Promises.add(pendingCycle1)
1807
+ try { await pendingCycle1 } catch {}
1808
+ }
1809
+ }
1810
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1811
+ // Cleanup must run BEFORE demotion: the original order demoted normal
1812
+ // roots (chunk_root = id) to is_root = 0 first, then ran the cleanup
1813
+ // WHERE is_root = 1 — which missed exactly those demoted rows, leaving
1814
+ // stale element/category/summary/score/embedding/summary_hash on rows that
1815
+ // had just become raw leaves. Reorder so all roots get their classification
1816
+ // columns cleared while is_root = 1 still selects them, then demote.
1817
+ // Wrap the whole destructive sequence in one transaction so a mid-step
1818
+ // failure rolls back rather than leaving a mixed state.
1819
+ await db.transaction(async (tx) => {
1820
+ await tx.query(`
1821
+ UPDATE entries
1822
+ SET element = NULL, category = NULL, summary = NULL,
1823
+ status = 'pending', score = NULL, last_seen_at = NULL,
1824
+ embedding = NULL, summary_hash = NULL,
1825
+ core_summary = NULL, reviewed_at = NULL, promoted_at = NULL,
1826
+ error_count = 0
1827
+ WHERE is_root = 1
1828
+ `)
1829
+ await tx.query(`UPDATE entries SET chunk_root = NULL, is_root = 0 WHERE chunk_root = id`)
1830
+ await tx.query(`UPDATE entries SET chunk_root = NULL WHERE is_root = 0`)
1831
+ await tx.query(`
1832
+ UPDATE entries
1833
+ SET status = NULL,
1834
+ element = NULL, category = NULL, summary = NULL,
1835
+ score = NULL, last_seen_at = NULL,
1836
+ embedding = NULL, summary_hash = NULL,
1837
+ core_summary = NULL, reviewed_at = NULL, promoted_at = NULL,
1838
+ error_count = 0
1839
+ WHERE is_root = 0
1840
+ `)
1841
+ })
1842
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1843
+ // Force a fresh post-reset cycle1: _cycle1InFlight is guaranteed null
1844
+ // here (we drained above and have not awaited any cycle1-starting call
1845
+ // since), so calling _startCycle1Run directly skips the coalesce branch
1846
+ // inside _awaitCycle1Run and guarantees the newly demoted rows are read.
1847
+ const r1 = await _startCycle1Run(config?.cycle1 || {}, { signal })
1848
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1849
+ const r2 = await runCycle2(db, config?.cycle2 || {}, { signal }, DATA_DIR)
1850
+ await _finalizeCycle2Run(r2)
1851
+ return { text: `rebuild: cycle1 chunks=${r1.chunks} processed=${r1.processed}, cycle2 ${JSON.stringify(r2)}` }
1852
+ }
1853
+
1854
+ async function _handleMemPrune(args, config) {
1855
+ if (args.confirm !== 'PRUNE OLD ENTRIES') {
1856
+ return { text: 'prune requires confirm: "PRUNE OLD ENTRIES" (permanently deletes unclassified entries older than maxDays)', isError: true }
1857
+ }
1858
+ const days = Math.max(1, Number(args.maxDays ?? 30))
1859
+ const result = await pruneOldEntries(db, days)
1860
+ return { text: `prune: deleted ${result.deleted} unclassified entries older than ${days} days` }
1861
+ }
1862
+
1863
+ async function _handleMemBackfill(args, config, signal) {
1864
+ // Whole-action mutex (transport-agnostic). _cycle1InFlight only protects
1865
+ // cycle1; ingest workers + cycle2 can still overlap if a second backfill
1866
+ // kicks in (timeout-retry, parallel callers, /api/tool vs /mcp vs
1867
+ // /admin/backfill). Sentinel is set synchronously before any await so a
1868
+ // burst of concurrent calls cannot all pass the check.
1869
+ if (_backfillInFlight) {
1870
+ return { text: 'backfill already in progress', isError: true }
1871
+ }
1872
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1873
+ const window = args.window != null ? String(args.window) : '7d'
1874
+ const scope = args.scope != null ? String(args.scope) : 'all'
1875
+ const limit = args.limit != null ? Math.max(1, Number(args.limit)) : null
1876
+ // Capture the cycle2 envelope so we can route through _finalizeCycle2Run
1877
+ // (which records cycle2_last_error and clears scheduler delay only on
1878
+ // ok:true) rather than stamping cycle2 unconditionally afterward.
1879
+ let _capturedCycle2
1880
+ const promise = runFullBackfill(db, {
1881
+ window,
1882
+ scope,
1883
+ limit,
1884
+ config,
1885
+ dataDir: DATA_DIR,
1886
+ ingestTranscriptFile,
1887
+ cwdFromTranscriptPath,
1888
+ // Re-check the IPC cancel signal at every cycle1/cycle2 iteration the
1889
+ // backfill driver dispatches. handleMemoryAction only checks once
1890
+ // before dispatch; without per-iteration checkpoints a long-running
1891
+ // backfill keeps spinning through ingest + cycle1 + cycle2 batches
1892
+ // after the proxy has already responded "cancelled" to the caller.
1893
+ runCycle1: (dbArg, cycle1Config = {}, options = {}, dir) => {
1894
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1895
+ return _awaitCycle1Run(cycle1Config, { ...options, signal })
1896
+ },
1897
+ runCycle2: async (dbArg, c2Config, c2Options, c2DataDir) => {
1898
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1899
+ const r2 = await runCycle2(dbArg, c2Config, { ...c2Options, signal }, c2DataDir)
1900
+ _capturedCycle2 = r2
1901
+ return r2
1902
+ },
1903
+ })
1904
+ _backfillInFlight = promise
1905
+ let result
1906
+ try {
1907
+ result = await promise
1908
+ } finally {
1909
+ if (_backfillInFlight === promise) _backfillInFlight = null
1910
+ }
1911
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1912
+ if (_capturedCycle2) {
1913
+ await _finalizeCycle2Run(_capturedCycle2)
1914
+ }
1915
+ return {
1916
+ text: `backfill: window=${result.window} scope=${result.scope} files=${result.files} ingested=${result.ingested} cycle1_iters=${result.cycle1_iters} promoted=${result.promoted} unclassified=${result.unclassified}`,
1917
+ }
1918
+ }
1919
+
1920
+ async function handleMemoryAction(args, signal) {
1921
+ // Cooperative abort check: surfaces caller-cancel (IPC cancel handler)
1922
+ // before any long DB work begins on the worker side.
1923
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1924
+ const action = String(args.action ?? '')
1925
+ const config = readMainConfig()
1926
+
1927
+ if (action === 'status') {
1928
+ return _handleMemStatus(args, config)
1929
+ }
1930
+
1931
+ if (action === 'cycle1') {
1932
+ return _handleMemCycle1(args, config, signal)
1933
+ }
1934
+
1935
+ if (action === 'cycle2' || action === 'sleep') {
1936
+ return _handleMemCycle2(args, config, signal)
1937
+ }
1938
+
1939
+ if (action === 'cycle3') {
1940
+ return _handleMemCycle3(args, config, signal)
1941
+ }
1942
+
1943
+ // Direct semantic-search surface for callers that want raw ranked rows
1944
+ // without going through the Lead-side recall synthesizer. The
1945
+ // handleSearch executor is exposed through the public `memory` tool action
1946
+ // `search` so callers can hit the hybrid CTE directly.
1947
+ if (action === 'search') {
1948
+ return handleSearch(args, signal)
1949
+ }
1950
+
1951
+ if (action === 'flush') {
1952
+ return _handleMemFlush(args, config, signal)
1953
+ }
1954
+
1955
+ if (action === 'rebuild') {
1956
+ return _handleMemRebuild(args, config, signal)
1957
+ }
1958
+
1959
+ if (action === 'prune') {
1960
+ return _handleMemPrune(args, config)
1961
+ }
1962
+
1963
+ if (action === 'backfill') {
1964
+ return _handleMemBackfill(args, config, signal)
1965
+ }
1966
+
1967
+ if (action === 'manage') {
1968
+ const op = String(args.op ?? '').trim().toLowerCase()
1969
+ if (!['add', 'edit', 'delete'].includes(op)) {
1970
+ return { text: 'manage requires op: "add" | "edit" | "delete"', isError: true }
1971
+ }
1972
+ const VALID_CAT = new Set(['rule', 'constraint', 'decision', 'fact', 'goal', 'preference', 'task', 'issue'])
1973
+ const VALID_STATUS = new Set(['pending', 'active', 'archived'])
1974
+
1975
+ if (op === 'add') {
1976
+ const element = String(args.element ?? '').trim()
1977
+ const summary = String(args.summary ?? args.element ?? '').trim()
1978
+ const category = String(args.category ?? 'fact').trim().toLowerCase()
1979
+ if (!element || !summary) {
1980
+ return { text: 'manage add requires element and summary', isError: true }
1981
+ }
1982
+ if (!VALID_CAT.has(category)) {
1983
+ return { text: `manage add: invalid category "${category}". Valid: ${[...VALID_CAT].join(', ')}`, isError: true }
1984
+ }
1985
+ const nowMs = Date.now()
1986
+ const sourceRef = `manual:${nowMs}-${process.pid}`
1987
+ const manageProjectId = resolveProjectScope(typeof args.cwd === 'string' && args.cwd ? args.cwd : null)
1988
+ try {
1989
+ let newId
1990
+ await db.transaction(async (tx) => {
1991
+ const result = await tx.query(`
1992
+ INSERT INTO entries(ts, role, content, source_ref, session_id, project_id)
1993
+ VALUES ($1, 'system', $2, $3, NULL, $4)
1994
+ RETURNING id
1995
+ `, [nowMs, element + ' — ' + summary, sourceRef, manageProjectId])
1996
+ newId = Number(result.rows[0].id)
1997
+ const score = computeEntryScore(category, nowMs, nowMs)
1998
+ await tx.query(`
1999
+ UPDATE entries
2000
+ SET chunk_root = $1, is_root = 1, element = $2, category = $3, summary = $4,
2001
+ status = 'active', score = $5, last_seen_at = $6
2002
+ WHERE id = $7
2003
+ `, [newId, element, category, summary, score, nowMs, newId])
2004
+ })
2005
+ await syncRootEmbedding(db, newId)
2006
+ return { text: `added (id=${newId}): [${category}] ${element} — ${summary.slice(0, 200)}` }
2007
+ } catch (e) {
2008
+ return { text: `manage add failed: ${e.message}`, isError: true }
2009
+ }
2010
+ }
2011
+
2012
+ if (op === 'edit') {
2013
+ const id = Number(args.id)
2014
+ if (!Number.isFinite(id) || id <= 0) {
2015
+ return { text: 'manage edit requires numeric id', isError: true }
2016
+ }
2017
+ const existing = (await db.query(
2018
+ `SELECT id, element, summary, category, status, ts, is_root FROM entries WHERE id = $1`,
2019
+ [id]
2020
+ )).rows[0]
2021
+ if (!existing) return { text: `manage edit: no entry with id=${id}`, isError: true }
2022
+ if (existing.is_root !== 1) return { text: `manage edit: id=${id} is not a root`, isError: true }
2023
+
2024
+ const trimOrNull = v => {
2025
+ if (v == null) return null
2026
+ const s = String(v).trim()
2027
+ return s === '' ? null : s
2028
+ }
2029
+ const newElement = trimOrNull(args.element)
2030
+ const newSummary = trimOrNull(args.summary)
2031
+ const newCategory = trimOrNull(args.category)?.toLowerCase() ?? null
2032
+ const newStatus = trimOrNull(args.status)?.toLowerCase() ?? null
2033
+
2034
+ if (!newElement && !newSummary && !newCategory && !newStatus) {
2035
+ return { text: 'manage edit requires at least one field: element, summary, category, status', isError: true }
2036
+ }
2037
+ if (newCategory && !VALID_CAT.has(newCategory)) {
2038
+ return { text: `manage edit: invalid category "${newCategory}". Valid: ${[...VALID_CAT].join(', ')}`, isError: true }
2039
+ }
2040
+ if (newStatus && !VALID_STATUS.has(newStatus)) {
2041
+ return { text: `manage edit: invalid status "${newStatus}". Valid: ${[...VALID_STATUS].join(', ')}`, isError: true }
2042
+ }
2043
+
2044
+ const finalElement = newElement ?? existing.element
2045
+ const finalSummary = newSummary ?? existing.summary
2046
+ const finalCategory = newCategory ?? existing.category
2047
+ const finalStatus = newStatus ?? existing.status
2048
+ const nowMs = Date.now()
2049
+ const score = computeEntryScore(finalCategory, nowMs, nowMs)
2050
+ const textChanged = newElement != null || newSummary != null
2051
+ // Guard null element/summary: a category/status-only edit on a root
2052
+ // whose element or summary is NULL would otherwise persist literal
2053
+ // 'null — null' content and explode on finalSummary.slice() below.
2054
+ // Use empty-string sentinels for the content composition + render so
2055
+ // the row stays consistent with what's actually stored.
2056
+ const elementStr = finalElement == null ? '' : String(finalElement)
2057
+ const summaryStr = finalSummary == null ? '' : String(finalSummary)
2058
+ const composedContent = elementStr || summaryStr
2059
+ ? `${elementStr}${summaryStr ? ' — ' + summaryStr : ''}`
2060
+ : ''
2061
+
2062
+ try {
2063
+ await db.query(`
2064
+ UPDATE entries
2065
+ SET element = $1, summary = $2, category = $3, status = $4, score = $5,
2066
+ last_seen_at = $6, content = $7
2067
+ WHERE id = $8
2068
+ `, [finalElement, finalSummary, finalCategory, finalStatus, score,
2069
+ nowMs, composedContent, id])
2070
+ } catch (e) {
2071
+ return { text: `manage edit failed: ${e.message}`, isError: true }
2072
+ }
2073
+ if (textChanged) {
2074
+ try { await syncRootEmbedding(db, id) } catch (e) {
2075
+ process.stderr.write(`[memory.manage] embedding resync failed (id=${id}): ${e.message}\n`)
2076
+ }
2077
+ }
2078
+ return { text: `edited (id=${id}): [${finalCategory}/${finalStatus}] ${elementStr}${summaryStr ? ' — ' + summaryStr.slice(0, 200) : ''}` }
2079
+ }
2080
+
2081
+ if (op === 'delete') {
2082
+ const id = Number(args.id)
2083
+ if (!Number.isFinite(id) || id <= 0) {
2084
+ return { text: 'manage delete requires numeric id', isError: true }
2085
+ }
2086
+ const info = (await db.query(
2087
+ `SELECT id, category, element, is_root FROM entries WHERE id = $1`,
2088
+ [id]
2089
+ )).rows[0]
2090
+ if (!info) return { text: `manage delete: no entry with id=${id}`, isError: true }
2091
+ try {
2092
+ const result = info.is_root === 1
2093
+ ? await db.query(`DELETE FROM entries WHERE id = $1 OR chunk_root = $2`, [id, id])
2094
+ : await db.query(`DELETE FROM entries WHERE id = $1`, [id])
2095
+ return { text: `deleted (id=${id}, rows=${Number(result.rowCount ?? result.affectedRows ?? 0)}): [${info.category ?? '-'}] ${info.element ?? ''}` }
2096
+ } catch (e) {
2097
+ return { text: `manage delete failed: ${e.message}`, isError: true }
2098
+ }
2099
+ }
2100
+
2101
+ return { text: `manage: unhandled op "${op}"`, isError: true }
2102
+ }
2103
+
2104
+ if (action === 'core') {
2105
+ const op = String(args.op ?? '').trim().toLowerCase()
2106
+ if (!['add', 'edit', 'delete', 'list'].includes(op)) {
2107
+ return { text: 'core requires op: "add" | "edit" | "delete" | "list"', isError: true }
2108
+ }
2109
+ const dataDir = process.env.CLAUDE_PLUGIN_DATA || (typeof DATA_DIR === 'string' ? DATA_DIR : null)
2110
+ if (!dataDir) return { text: 'core: CLAUDE_PLUGIN_DATA unset', isError: true }
2111
+ // Local trim helper — the manage-block trimOrNull at :1807 is scoped to
2112
+ // that branch and unreachable from here.
2113
+ // Normalize project_id: 'common' (case-insensitive) or null → null (COMMON pool); non-empty string → slug.
2114
+ const hasProjectIdKey = Object.prototype.hasOwnProperty.call(args, 'project_id')
2115
+ const projectId = (() => {
2116
+ if (!hasProjectIdKey || args.project_id == null) return null
2117
+ const s = String(args.project_id).trim()
2118
+ if (s === '' || s.toLowerCase() === 'common') return null
2119
+ if (s === '*') return '*'
2120
+ return s
2121
+ })()
2122
+ try {
2123
+ if (projectId === '*' && op !== 'list') {
2124
+ return { text: `core ${op}: project_id "*" only valid for op="list"`, isError: true }
2125
+ }
2126
+ if (op === 'list') {
2127
+ if (projectId !== '*') {
2128
+ const entries = await listCore(dataDir, projectId)
2129
+ if (entries.length === 0) return { text: 'core: empty' }
2130
+ return { text: entries.map(e => `id=${e.id} [${e.category}] ${e.element} — ${String(e.summary || '').slice(0, 200)}`).join('\n') }
2131
+ }
2132
+ // Cross-pool listing — group by project_id, COMMON first
2133
+ const entries = await listCore(dataDir, '*')
2134
+ if (entries.length === 0) return { text: 'core: empty' }
2135
+ const groups = new Map()
2136
+ for (const e of entries) {
2137
+ const key = e.project_id ?? null
2138
+ if (!groups.has(key)) groups.set(key, [])
2139
+ groups.get(key).push(e)
2140
+ }
2141
+ const lines = []
2142
+ for (const [key, rows] of groups) {
2143
+ lines.push(`${key === null ? 'COMMON' : key}:`)
2144
+ for (const e of rows) {
2145
+ lines.push(` id=${e.id} [${e.category}] ${e.element} — ${String(e.summary || '').slice(0, 200)}`)
2146
+ }
2147
+ }
2148
+ return { text: lines.join('\n') }
2149
+ }
2150
+ if (op === 'add') {
2151
+ if (!hasProjectIdKey) {
2152
+ return { text: 'core add: project_id required — pass "common" for COMMON pool, or project slug like "owner/repo" for scoped pool', isError: true }
2153
+ }
2154
+ const entry = await addCore(dataDir, args, projectId)
2155
+ return { text: `core added (id=${entry.id}): [${entry.category}] ${entry.element} — ${entry.summary.slice(0, 200)}` }
2156
+ }
2157
+ if (op === 'edit') {
2158
+ const entry = await editCore(dataDir, args.id, args)
2159
+ return { text: `core edited (id=${entry.id}): [${entry.category}] ${entry.element} — ${entry.summary.slice(0, 200)}` }
2160
+ }
2161
+ if (op === 'delete') {
2162
+ const removed = await deleteCore(dataDir, args.id)
2163
+ return { text: `core deleted (id=${removed.id}): [${removed.category}] ${removed.element}` }
2164
+ }
2165
+ } catch (e) {
2166
+ return { text: `core ${op} failed: ${e.message}`, isError: true }
2167
+ }
2168
+ return { text: `core: unhandled op "${op}"`, isError: true }
2169
+ }
2170
+
2171
+ if (action === 'purge') {
2172
+ if (args.confirm !== 'DELETE ALL MEMORY') {
2173
+ return { text: 'purge requires confirm: "DELETE ALL MEMORY"', isError: true }
2174
+ }
2175
+ const preCount = (await db.query(`SELECT COUNT(*) c FROM entries`)).rows[0].c
2176
+ const coreCount = (await db.query(`SELECT COUNT(*) c FROM core_entries`)).rows[0].c
2177
+ try {
2178
+ await db.query(`DELETE FROM entries`)
2179
+ } catch (e) {
2180
+ return { text: `purge failed: ${e.message}`, isError: true }
2181
+ }
2182
+ return { text: `purged generated memory entries (count=${preCount}); user core preserved (core_entries=${coreCount})` }
2183
+ }
2184
+
2185
+ if (action === 'retro_eval_active') {
2186
+ if (args.confirm !== 'REEVAL ACTIVE') {
2187
+ return { text: 'retro_eval_active requires confirm: "REEVAL ACTIVE" (heavy LLM batch op — reviews all active roots through the unified gate)', isError: true }
2188
+ }
2189
+ const RETRO_BATCH = 50
2190
+ const cycle2Config = config?.cycle2 || {}
2191
+ const allActive = (await db.query(
2192
+ `SELECT id, element, category, summary, score, last_seen_at, project_id, status
2193
+ FROM entries WHERE is_root = 1 AND status = 'active'
2194
+ ORDER BY reviewed_at ASC, id ASC`
2195
+ )).rows
2196
+ const total = allActive.length
2197
+ let archived = 0, kept = 0, updated = 0, merged = 0, errors = 0
2198
+ const nowMs = Date.now()
2199
+ for (let offset = 0; offset < total; offset += RETRO_BATCH) {
2200
+ const batch = allActive.slice(offset, offset + RETRO_BATCH)
2201
+ const batchIds = batch.map(r => Number(r.id))
2202
+ const activeContext = (await db.query(
2203
+ `SELECT id, element, category, summary, score, last_seen_at, project_id, status
2204
+ FROM entries WHERE is_root = 1 AND status = 'active'
2205
+ ORDER BY score DESC, last_seen_at DESC, id ASC LIMIT 200`
2206
+ )).rows
2207
+ let gateResult
2208
+ try {
2209
+ gateResult = await runUnifiedGate(db, batch, activeContext, cycle2Config, { activeCap: 200 })
2210
+ } catch (err) {
2211
+ process.stderr.write(`[retro_eval_active] runUnifiedGate failed (offset=${offset}): ${err.message}\n`)
2212
+ errors += batch.length
2213
+ continue
2214
+ }
2215
+ if (gateResult?.parseOk === false || gateResult?.actions === null) {
2216
+ errors += batch.length
2217
+ continue
2218
+ }
2219
+ const actions = gateResult?.actions ?? []
2220
+ // Separate explicit `core` summary lines from primary verbs so an
2221
+ // update/merge/active also refreshes the injected core_summary — mirrors
2222
+ // the cycle2 apply path (memory-cycle2.mjs). Without this, retro could
2223
+ // rewrite a root's summary while leaving its core_summary stale.
2224
+ const coreSummaryById = new Map()
2225
+ const primaryActions = []
2226
+ for (const a of actions) {
2227
+ if (a?.action === 'core') {
2228
+ const cid = Number(a.entry_id)
2229
+ const core = String(a.core_summary ?? '').replace(/\s+/g, ' ').trim().slice(0, CORE_SUMMARY_MAX)
2230
+ if (Number.isFinite(cid) && core) coreSummaryById.set(cid, core)
2231
+ } else {
2232
+ primaryActions.push(a)
2233
+ }
2234
+ }
2235
+ const allowed = new Set(batchIds)
2236
+ const rejected = gateResult?.rejected ?? new Set()
2237
+ // Partial-apply contract: rows the gate never returned a verdict for
2238
+ // (missingIds) must NOT be marked reviewed — they are left for a later
2239
+ // run. Exclude both rejected and missing ids from the reviewed set.
2240
+ const missing = new Set((gateResult?.missingIds ?? []).map(Number))
2241
+ const successIds = new Set(batchIds.filter(id => !rejected.has(id) && !missing.has(id)))
2242
+ for (const id of successIds) {
2243
+ try { await db.query(`UPDATE entries SET reviewed_at = $1 WHERE id = $2`, [nowMs, id]) } catch {}
2244
+ }
2245
+ const setCoreSummary = async (entryId, core) => {
2246
+ if (!core) return
2247
+ try { await db.query(`UPDATE entries SET core_summary = $1 WHERE id = $2 AND is_root = 1`, [core, Number(entryId)]) }
2248
+ catch (err) { process.stderr.write(`[retro_eval_active] core_summary update failed (id=${entryId}): ${err.message}\n`) }
2249
+ }
2250
+ if (!primaryActions.length) { kept += batch.filter(r => successIds.has(Number(r.id))).length; continue }
2251
+ const acted = new Set()
2252
+ for (const act of primaryActions) {
2253
+ try {
2254
+ const eid = Number(act?.entry_id)
2255
+ if (!Number.isFinite(eid) || !allowed.has(eid)) continue
2256
+ acted.add(eid)
2257
+ if (act.action === 'archived') {
2258
+ if (await applySimpleStatus(db, eid, 'archived')) archived += 1
2259
+ } else if (act.action === 'active') {
2260
+ // active → active is a keep verdict from the gate.
2261
+ kept += 1
2262
+ await setCoreSummary(eid, coreSummaryById.get(eid))
2263
+ } else if (act.action === 'update') {
2264
+ if (await applyUpdate(db, eid, act.element, act.summary)) updated += 1
2265
+ await setCoreSummary(eid, coreSummaryById.get(eid))
2266
+ } else if (act.action === 'merge') {
2267
+ const targetId = Number(act?.target_id)
2268
+ const sourceIds = Array.isArray(act?.source_ids) ? act.source_ids : []
2269
+ if (!Number.isFinite(targetId) || !allowed.has(targetId)) {
2270
+ process.stderr.write(`[retro_eval_active] merge target outside batch (id=${targetId})\n`)
2271
+ acted.delete(eid)
2272
+ continue
2273
+ }
2274
+ const filteredSources = sourceIds.filter(s => allowed.has(Number(s)))
2275
+ if (filteredSources.length !== sourceIds.length) {
2276
+ process.stderr.write(
2277
+ `[retro_eval_active] merge sources filtered: ${JSON.stringify(sourceIds)} -> ${JSON.stringify(filteredSources)}\n`,
2278
+ )
2279
+ }
2280
+ acted.add(targetId)
2281
+ filteredSources.forEach(s => acted.add(Number(s)))
2282
+ const moved = await applyMerge(db, targetId, filteredSources)
2283
+ if (moved > 0) {
2284
+ merged += moved
2285
+ if (typeof act.element === 'string' || typeof act.summary === 'string') {
2286
+ try {
2287
+ if (await applyUpdate(db, targetId, act.element, act.summary)) updated += 1
2288
+ } catch (err) {
2289
+ process.stderr.write(`[retro_eval_active] merge target update failed (target=${targetId}): ${err.message}\n`)
2290
+ }
2291
+ }
2292
+ await setCoreSummary(targetId, coreSummaryById.get(targetId) || coreSummaryById.get(eid))
2293
+ }
2294
+ }
2295
+ } catch (err) {
2296
+ process.stderr.write(`[retro_eval_active] action error (id=${act?.entry_id}): ${err.message}\n`)
2297
+ errors += 1
2298
+ }
2299
+ }
2300
+ // Entries in successIds but not acted-upon (omit / no-op) are kept.
2301
+ kept += batch.filter(r => successIds.has(Number(r.id)) && !acted.has(Number(r.id))).length
2302
+ }
2303
+ return { text: `retro_eval_active: total=${total} archived=${archived} kept=${kept} updated=${updated} merged=${merged} errors=${errors}` }
2304
+ }
2305
+
2306
+ return { text: `unknown memory action: ${action}`, isError: true }
2307
+ }
2308
+
2309
+ async function handleToolCall(name, args, signal) {
2310
+ try {
2311
+ if (name === 'search_memories') {
2312
+ const result = await handleSearch(args || {}, signal)
2313
+ return { content: [{ type: 'text', text: result.text }], isError: result.isError || false }
2314
+ }
2315
+ if (name === 'recall') {
2316
+ // recall is aiWrapped in the unified build; in standalone mode map it to
2317
+ // search_memories so the advertised tool name actually works. Forward
2318
+ // every advertised arg so id/limit/offset/sort/includeArchived/
2319
+ // includeMembers/includeRaw reach handleSearch instead of being dropped.
2320
+ const a = args || {}
2321
+ const searchArgs = {
2322
+ ...(a.query !== undefined ? { query: a.query } : {}),
2323
+ ...(a.id !== undefined ? { ids: Array.isArray(a.id) ? a.id : [a.id] } : {}),
2324
+ ...(a.period ? { period: a.period } : {}),
2325
+ ...(a.limit !== undefined ? { limit: a.limit } : {}),
2326
+ ...(a.offset !== undefined ? { offset: a.offset } : {}),
2327
+ ...(a.sort !== undefined ? { sort: a.sort } : {}),
2328
+ ...(a.category !== undefined ? { category: a.category } : {}),
2329
+ ...(a.includeArchived !== undefined ? { includeArchived: a.includeArchived } : {}),
2330
+ ...(a.includeMembers !== undefined ? { includeMembers: a.includeMembers } : {}),
2331
+ ...(a.includeRaw !== undefined ? { includeRaw: a.includeRaw } : {}),
2332
+ ...(a.cwd ? { cwd: a.cwd } : {}),
2333
+ ...(a.projectScope ? { projectScope: a.projectScope } : {}),
2334
+ }
2335
+ const result = await handleSearch(searchArgs, signal)
2336
+ return { content: [{ type: 'text', text: result.text }], isError: result.isError || false }
2337
+ }
2338
+ if (name === 'memory') {
2339
+ const result = await handleMemoryAction(args || {}, signal)
2340
+ return { content: [{ type: 'text', text: result.text }], isError: result.isError || false }
2341
+ }
2342
+ return { content: [{ type: 'text', text: `unknown tool: ${name}` }], isError: true }
2343
+ } catch (err) {
2344
+ const msg = err instanceof Error ? err.message : String(err)
2345
+ return { content: [{ type: 'text', text: `${name} failed: ${msg}` }], isError: true }
2346
+ }
2347
+ }
2348
+
2349
+ const mcp = new Server(
2350
+ { name: 'mixdog-memory', version: PLUGIN_VERSION },
2351
+ { capabilities: { tools: {} }, instructions: MEMORY_INSTRUCTIONS_TEXT },
2352
+ )
2353
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }))
2354
+ mcp.setRequestHandler(CallToolRequestSchema, (req) => handleToolCall(req.params.name, req.params.arguments ?? {}))
2355
+
2356
+ function createHttpMcpServer() {
2357
+ const s = new Server(
2358
+ { name: 'mixdog-memory', version: PLUGIN_VERSION },
2359
+ { capabilities: { tools: {} }, instructions: MEMORY_INSTRUCTIONS_TEXT },
2360
+ )
2361
+ s.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }))
2362
+ s.setRequestHandler(CallToolRequestSchema, (req) => handleToolCall(req.params.name, req.params.arguments ?? {}))
2363
+ return s
2364
+ }
2365
+
2366
+ function readBody(req) {
2367
+ return new Promise((resolve, reject) => {
2368
+ const chunks = []
2369
+ req.on('data', c => chunks.push(c))
2370
+ req.on('end', () => {
2371
+ const raw = Buffer.concat(chunks).toString('utf8').trim()
2372
+ if (!raw) { resolve({}); return }
2373
+ try { resolve(JSON.parse(raw)) }
2374
+ catch (error) {
2375
+ const e = new Error(`invalid JSON body: ${error.message}`)
2376
+ e.statusCode = 400
2377
+ reject(e)
2378
+ }
2379
+ })
2380
+ req.on('error', reject)
2381
+ })
2382
+ }
2383
+
2384
+ function sendJson(res, data, status = 200) {
2385
+ const body = JSON.stringify(data)
2386
+ res.writeHead(status, {
2387
+ 'Content-Type': 'application/json; charset=utf-8',
2388
+ 'Content-Length': Buffer.byteLength(body),
2389
+ })
2390
+ res.end(body)
2391
+ }
2392
+
2393
+ function sendError(res, msg, status = 500) {
2394
+ sendJson(res, { error: msg }, status)
2395
+ }
2396
+
2397
+ async function awaitRuntimeReadyForHttp(res) {
2398
+ if (_initialized) return true
2399
+ if (!_initPromise) {
2400
+ sendJson(res, { error: 'memory runtime is starting' }, 503)
2401
+ return false
2402
+ }
2403
+ try {
2404
+ await _initPromise
2405
+ return true
2406
+ } catch (e) {
2407
+ sendJson(res, { error: `memory runtime failed: ${e?.message || e}` }, 503)
2408
+ return false
2409
+ }
2410
+ }
2411
+
2412
+ // Origin/Referer guard for /admin/* mutation routes. Memory-service binds
2413
+ // 127.0.0.1, but browser DNS-rebinding or a stray cross-origin fetch could
2414
+ // still reach destructive endpoints (purge, backfill, entry mutations).
2415
+ // Server-to-server callers (setup-server, hooks) issue raw http.request
2416
+ // without a browser Origin/Referer, so absent headers pass; any non-loopback
2417
+ // Origin/Referer is rejected. Mirrors setup-server.mjs isAllowedOrigin.
2418
+ function isLocalOrigin(req) {
2419
+ const LOOP = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i
2420
+ const origin = req.headers.origin || ''
2421
+ const referer = req.headers.referer || ''
2422
+ if (origin && !LOOP.test(origin)) return false
2423
+ if (referer && !LOOP.test(referer)) return false
2424
+ return true
2425
+ }
2426
+
2427
+ function normalizeCoreProjectId(value, { allowStar = false } = {}) {
2428
+ if (value == null) return null
2429
+ const s = String(value).trim()
2430
+ if (!s || s.toLowerCase() === 'common') return null
2431
+ if (allowStar && s === '*') return '*'
2432
+ return s
2433
+ }
2434
+
2435
+ async function buildSessionCoreMemoryPayload(cwd) {
2436
+ const projectId = resolveProjectScope(typeof cwd === 'string' && cwd ? cwd : null)
2437
+ const generatedScopeClause = projectId !== null
2438
+ ? `project_id IS NULL OR project_id = $1`
2439
+ : `project_id IS NULL`
2440
+ const dbRows = (await db.query(`
2441
+ SELECT core_summary
2442
+ FROM entries
2443
+ WHERE is_root = 1
2444
+ AND status = 'active'
2445
+ AND core_summary IS NOT NULL
2446
+ AND (${generatedScopeClause})
2447
+ ORDER BY score DESC, last_seen_at DESC
2448
+ `, projectId !== null ? [projectId] : [])).rows
2449
+ const commonRows = (await db.query(
2450
+ `SELECT summary FROM core_entries WHERE project_id IS NULL ORDER BY id ASC`
2451
+ )).rows
2452
+ const scopedRows = projectId !== null
2453
+ ? (await db.query(
2454
+ `SELECT summary FROM core_entries WHERE project_id = $1 ORDER BY id ASC`,
2455
+ [projectId]
2456
+ )).rows
2457
+ : []
2458
+ return {
2459
+ projectId,
2460
+ dbLines: dbRows.map(r => String(r.core_summary || '').trim()).filter(Boolean),
2461
+ userLines: [
2462
+ ...commonRows.map(r => String(r.summary || '').trim()).filter(Boolean),
2463
+ ...scopedRows.map(r => String(r.summary || '').trim()).filter(Boolean),
2464
+ ],
2465
+ }
2466
+ }
2467
+
2468
+ // Whole-action backfill mutex. memory-cycle1's _cycle1InFlight only protects
2469
+ // cycle1; ingest workers (memory-ops-policy.mjs) and cycle2 can still overlap
2470
+ // if a second backfill kicks in (e.g. setup-server timeout + retry). Track the
2471
+ // in-flight promise here and reject overlaps with 409.
2472
+ let _backfillInFlight = null
2473
+
2474
+ // Owner-side /api/tool in-flight controllers keyed by caller-supplied
2475
+ // X-Mixdog-Call-Id. /api/cancel aborts the matching AbortSignal so the
2476
+ // upstream handleToolCall actually stops when the fork-proxy parent cancels.
2477
+ const _ownerInFlightHttpCalls = new Map()
2478
+
2479
+ const httpServer = http.createServer(async (req, res) => {
2480
+ if (req.method === 'POST' && req.url === '/session-reset') {
2481
+ _bootTimestamp = Date.now()
2482
+ sendJson(res, { ok: true, bootTimestamp: _bootTimestamp })
2483
+ return
2484
+ }
2485
+ if (req.method === 'POST' && req.url === '/rebind') {
2486
+ _bootTimestamp = Date.now()
2487
+ sendJson(res, { ok: true })
2488
+ return
2489
+ }
2490
+
2491
+ if (req.method === 'GET' && req.url === '/health') {
2492
+ if (!_initialized) {
2493
+ sendJson(res, { status: 'starting' }, 503)
2494
+ return
2495
+ }
2496
+ try {
2497
+ const stats = await entryStats()
2498
+ sendJson(res, {
2499
+ status: 'ok',
2500
+ worker_pid: process.pid,
2501
+ server_pid: Number(process.env.MIXDOG_SERVER_PID) || null,
2502
+ owner_lead_pid: Number(process.env.MIXDOG_OWNER_LEAD_PID) || null,
2503
+ code_fingerprint: BOOT_PROMOTION_CODE_FINGERPRINT,
2504
+ bootstrap: await isBootstrapComplete(db),
2505
+ entries: stats.total,
2506
+ roots: stats.roots,
2507
+ active_roots: stats.active_roots,
2508
+ archived_roots: stats.archived_roots,
2509
+ unchunked_leaves: stats.unchunked_leaves,
2510
+ cycle2_pending_roots: stats.cycle2_pending_roots,
2511
+ core_entries: stats.core_entries,
2512
+ core_embed_null: stats.core_embed_null,
2513
+ active_core_summaries: stats.active_core_summaries,
2514
+ active_core_summary_missing: stats.active_core_summary_missing,
2515
+ mv_hot_active_populated: stats.mv_hot_active_populated,
2516
+ })
2517
+ } catch (e) { sendError(res, e.message) }
2518
+ return
2519
+ }
2520
+
2521
+ if (!await awaitRuntimeReadyForHttp(res)) return
2522
+
2523
+ if (req.method === 'GET' && req.url === '/admin/entries/active') {
2524
+ try {
2525
+ const { rows } = await db.query(`
2526
+ SELECT id, element, category, summary, score, last_seen_at
2527
+ FROM entries
2528
+ WHERE is_root = 1 AND status = 'active'
2529
+ ORDER BY score DESC
2530
+ `)
2531
+ sendJson(res, { ok: true, items: rows })
2532
+ } catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
2533
+ return
2534
+ }
2535
+
2536
+ if (req.method === 'GET' && req.url === '/admin/core/entries') {
2537
+ try {
2538
+ const rows = await listCore(DATA_DIR, '*')
2539
+ sendJson(res, { ok: true, items: rows })
2540
+ } catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
2541
+ return
2542
+ }
2543
+
2544
+ if (req.method === 'POST' && req.url === '/admin/core/entries') {
2545
+ if (!isLocalOrigin(req)) {
2546
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
2547
+ return
2548
+ }
2549
+ try {
2550
+ const body = await readBody(req)
2551
+ const projectId = normalizeCoreProjectId(body.project_id)
2552
+ const entry = await addCore(DATA_DIR, body, projectId)
2553
+ sendJson(res, { ok: true, item: entry })
2554
+ } catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
2555
+ return
2556
+ }
2557
+
2558
+ if (req.method === 'POST' && req.url === '/admin/core/entries/delete') {
2559
+ if (!isLocalOrigin(req)) {
2560
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
2561
+ return
2562
+ }
2563
+ try {
2564
+ const body = await readBody(req)
2565
+ const removed = await deleteCore(DATA_DIR, body.id)
2566
+ sendJson(res, { ok: true, item: removed })
2567
+ } catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
2568
+ return
2569
+ }
2570
+
2571
+ if (req.method === 'POST' && req.url === '/admin/entries/status') {
2572
+ if (!isLocalOrigin(req)) {
2573
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
2574
+ return
2575
+ }
2576
+ try {
2577
+ const body = await readBody(req)
2578
+ const id = Number(body.id)
2579
+ const status = String(body.status ?? '').trim().toLowerCase()
2580
+ const VALID = ['pending', 'active', 'archived']
2581
+ if (!Number.isInteger(id) || id <= 0 || !VALID.includes(status)) {
2582
+ sendJson(res, { ok: false, error: 'valid id and status required' }, 400)
2583
+ return
2584
+ }
2585
+ const result = await db.query(
2586
+ `UPDATE entries SET status = $1 WHERE id = $2 AND is_root = 1`,
2587
+ [status, id]
2588
+ )
2589
+ sendJson(res, { ok: true, changes: Number(result.rowCount ?? result.affectedRows ?? 0) })
2590
+ } catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
2591
+ return
2592
+ }
2593
+
2594
+ if (req.method === 'POST' && req.url === '/admin/entries/add') {
2595
+ if (!isLocalOrigin(req)) {
2596
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
2597
+ return
2598
+ }
2599
+ try {
2600
+ const body = await readBody(req)
2601
+ const result = await handleMemoryAction({
2602
+ action: 'manage',
2603
+ op: 'add',
2604
+ element: body.element,
2605
+ summary: body.summary,
2606
+ category: body.category,
2607
+ cwd: body.cwd,
2608
+ })
2609
+ if (result.isError) {
2610
+ sendJson(res, { ok: false, error: result.text }, 400)
2611
+ return
2612
+ }
2613
+ const idMatch = String(result.text || '').match(/id=(\d+)/)
2614
+ const newId = idMatch ? Number(idMatch[1]) : null
2615
+ sendJson(res, { ok: true, id: newId, text: result.text })
2616
+ } catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
2617
+ return
2618
+ }
2619
+
2620
+ if (req.method === 'POST' && req.url === '/admin/backfill') {
2621
+ if (!isLocalOrigin(req)) {
2622
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
2623
+ return
2624
+ }
2625
+ let body
2626
+ try { body = await readBody(req) }
2627
+ catch (e) { sendJson(res, { ok: false, error: e.message }, Number(e?.statusCode) || 500); return }
2628
+ try {
2629
+ const result = await handleMemoryAction({
2630
+ action: 'backfill',
2631
+ window: body.window,
2632
+ scope: body.scope,
2633
+ limit: body.limit,
2634
+ })
2635
+ if (result.isError) {
2636
+ // 'backfill already in progress' → 409, other failures → 500
2637
+ const status = result.text === 'backfill already in progress' ? 409 : 500
2638
+ sendJson(res, { ok: false, error: result.text }, status)
2639
+ return
2640
+ }
2641
+ sendJson(res, { ok: true, text: result.text })
2642
+ } catch (e) {
2643
+ sendJson(res, { ok: false, error: e.message }, 500)
2644
+ }
2645
+ return
2646
+ }
2647
+
2648
+ if (req.method === 'POST' && req.url === '/admin/purge') {
2649
+ if (!isLocalOrigin(req)) {
2650
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
2651
+ return
2652
+ }
2653
+ try {
2654
+ const body = await readBody(req)
2655
+ if (body?.confirm !== 'DELETE ALL MEMORY') {
2656
+ sendJson(res, { ok: false, error: 'confirm must be exactly "DELETE ALL MEMORY"' }, 400)
2657
+ return
2658
+ }
2659
+ const { rows: countRows } = await db.query(`SELECT COUNT(*) AS c FROM entries`)
2660
+ const preCount = Number(countRows[0].c)
2661
+ const { rows: coreCountRows } = await db.query(`SELECT COUNT(*) AS c FROM core_entries`)
2662
+ const coreCount = Number(coreCountRows[0].c)
2663
+ await db.transaction(async (tx) => {
2664
+ await tx.query(`DELETE FROM entries`)
2665
+ })
2666
+ sendJson(res, { ok: true, deleted: preCount, core_preserved: coreCount })
2667
+ } catch (e) { sendJson(res, { ok: false, error: e.message }, 500) }
2668
+ return
2669
+ }
2670
+
2671
+ if (req.method === 'POST' && req.url === '/admin/trace-record') {
2672
+ if (!isLocalOrigin(req)) {
2673
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
2674
+ return
2675
+ }
2676
+ let body
2677
+ try { body = await readBody(req) }
2678
+ catch (e) { sendJson(res, { ok: false, error: e.message }, 400); return }
2679
+ if (!Array.isArray(body?.events)) {
2680
+ sendJson(res, { ok: false, error: 'body.events must be an array' }, 400)
2681
+ return
2682
+ }
2683
+ if (body.events.length > 500) {
2684
+ sendJson(res, { ok: false, error: 'too many events (max 500)' }, 413)
2685
+ return
2686
+ }
2687
+ if (!_traceDb) {
2688
+ try {
2689
+ _traceDb = await openTraceDatabase(DATA_DIR)
2690
+ registerTraceExitDrain(_traceDb)
2691
+ } catch (e) {
2692
+ sendJson(res, { ok: false, error: `trace DB unavailable: ${e.message}` }, 503)
2693
+ return
2694
+ }
2695
+ }
2696
+ try {
2697
+ // Enqueue for async batched flush (100ms / 500-row window).
2698
+ enqueueTraceEvents(_traceDb, body.events)
2699
+ // Use `queued` — events are async; `inserted` would imply durability.
2700
+ sendJson(res, { ok: true, queued: body.events.length })
2701
+ // Fire-and-forget into focused bridge analytic tables.
2702
+ insertBridgeCalls(_traceDb, body.events).catch(e =>
2703
+ process.stderr.write(`[trace] insertBridgeCalls error: ${e?.message}\n`)
2704
+ )
2705
+ } catch (e) {
2706
+ sendJson(res, { ok: false, error: e.message }, 500)
2707
+ }
2708
+ return
2709
+ }
2710
+
2711
+ if (req.method === 'POST' && req.url === '/session-start/core-memory') {
2712
+ try {
2713
+ const body = await readBody(req)
2714
+ const { projectId, dbLines, userLines } = await buildSessionCoreMemoryPayload(body.cwd)
2715
+ sendJson(res, { ok: true, projectId, dbLines, userLines })
2716
+ } catch (e) { sendError(res, e.message) }
2717
+ return
2718
+ }
2719
+
2720
+ if (req.method === 'POST' && req.url === '/admin/shutdown') {
2721
+ if (!isLocalOrigin(req)) {
2722
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
2723
+ return
2724
+ }
2725
+ sendJson(res, { shutting_down: true }, 202)
2726
+ setImmediate(() => {
2727
+ const watchdog = setTimeout(() => {
2728
+ process.stderr.write('[shutdown] watchdog fired — forcing exit after 8s\n')
2729
+ process.exit(1)
2730
+ }, 8000)
2731
+ watchdog.unref?.()
2732
+ stop()
2733
+ .then(() => { clearTimeout(watchdog); process.exit(0) })
2734
+ .catch(e => {
2735
+ process.stderr.write(`[shutdown] error ${e.message}\n`)
2736
+ clearTimeout(watchdog)
2737
+ process.exit(1)
2738
+ })
2739
+ })
2740
+ return
2741
+ }
2742
+
2743
+ // DEV-ONLY cycle1 chunking bench. Gated by env MIXDOG_DEV_BENCH=1 so
2744
+ // production is untouched (route returns 404 when unset). Mirrors cycle1's
2745
+ // exact fetch query + per-session windowing, then runs each window through
2746
+ // buildCycle1ChunkPrompt + callBridgeLlm + parseCycle1LineFormat. STRICT
2747
+ // read-only — no UPDATE, no transaction, no commit.
2748
+ if (req.method === 'POST' && req.url === '/dev/cycle1-bench') {
2749
+ // Gate: env MIXDOG_DEV_BENCH=1 OR a runtime flag file, so it can be
2750
+ // toggled without restarting Claude Code (env only reaches the worker
2751
+ // on a full CC restart, not via dev-sync full-restart).
2752
+ const _devBenchOn = process.env.MIXDOG_DEV_BENCH === '1'
2753
+ || (DATA_DIR && fs.existsSync(path.join(DATA_DIR, '.dev-bench-enabled')))
2754
+ if (!_devBenchOn) {
2755
+ sendJson(res, { error: 'not found' }, 404)
2756
+ return
2757
+ }
2758
+ if (!isLocalOrigin(req)) {
2759
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
2760
+ return
2761
+ }
2762
+ try {
2763
+ const body = await readBody(req)
2764
+ const sets = Math.max(1, Number(body?.sets ?? 5))
2765
+ const repeat = Math.max(1, Number(body?.repeat ?? 1))
2766
+ // Optional variant matrix. Each variant: {name, rules}. rules=null → default prompt.
2767
+ const rawVariants = Array.isArray(body?.variants) ? body.variants : null
2768
+ const variants = rawVariants && rawVariants.length > 0
2769
+ ? rawVariants.map((v, i) => ({
2770
+ name: typeof v?.name === 'string' && v.name ? v.name : `variant-${i + 1}`,
2771
+ rules: Array.isArray(v?.rules) ? v.rules : null,
2772
+ }))
2773
+ : null
2774
+
2775
+ // Lazy-load LLM + chunking helpers so production boot pays nothing.
2776
+ const [{ buildCycle1ChunkPrompt, parseCycle1LineFormat }, { callBridgeLlm }, { resolveMaintenancePreset }] = await Promise.all([
2777
+ import('./lib/memory-cycle1.mjs'),
2778
+ import('./lib/agent-ipc.mjs'),
2779
+ import('../shared/llm/index.mjs'),
2780
+ ])
2781
+
2782
+ const CYCLE1_MIN_BATCH = 3
2783
+ const CYCLE1_SESSION_CAP = 10
2784
+ const BATCH_SIZE = 100
2785
+ const TIMEOUT_MS = 180_000
2786
+ const fetchLimit = CYCLE1_SESSION_CAP * BATCH_SIZE
2787
+
2788
+ const fetchResult = await db.query(
2789
+ `SELECT id, ts, role, content, session_id, source_ref, project_id
2790
+ FROM entries
2791
+ WHERE chunk_root IS NULL AND session_id IS NOT NULL
2792
+ ORDER BY ts DESC, id DESC
2793
+ LIMIT $1`,
2794
+ [fetchLimit],
2795
+ )
2796
+ const rowsDesc = fetchResult.rows
2797
+
2798
+ if (rowsDesc.length < CYCLE1_MIN_BATCH) {
2799
+ sendJson(res, {
2800
+ ok: true,
2801
+ sets, repeat,
2802
+ windowsAvailable: 0,
2803
+ note: `not enough pending rows (need >= ${CYCLE1_MIN_BATCH}, got ${rowsDesc.length})`,
2804
+ results: [],
2805
+ })
2806
+ return
2807
+ }
2808
+
2809
+ // Partition by session_id — same as memory-cycle1.mjs _runCycle1Impl L207-233.
2810
+ const sessionMap = new Map()
2811
+ for (const row of rowsDesc.slice().reverse()) {
2812
+ const sid = row.session_id
2813
+ if (!sessionMap.has(sid)) sessionMap.set(sid, [])
2814
+ sessionMap.get(sid).push(row)
2815
+ }
2816
+ const windows = []
2817
+ for (const [sid, sessionRows] of sessionMap) {
2818
+ if (sessionRows.length < CYCLE1_MIN_BATCH) continue
2819
+ const windowCount = Math.max(1, Math.ceil(sessionRows.length / BATCH_SIZE))
2820
+ const baseSize = Math.floor(sessionRows.length / windowCount)
2821
+ const remainder = sessionRows.length % windowCount
2822
+ let _offset = 0
2823
+ for (let i = 0; i < windowCount; i++) {
2824
+ const size = baseSize + (i < remainder ? 1 : 0)
2825
+ windows.push({ sid, rows: sessionRows.slice(_offset, _offset + size) })
2826
+ _offset += size
2827
+ }
2828
+ }
2829
+ const chosen = windows.slice(0, sets)
2830
+
2831
+ const preset = resolveMaintenancePreset('cycle1')
2832
+
2833
+ function summariseChunks(chunks, totalEntries) {
2834
+ const usedIdx = new Set()
2835
+ for (const c of chunks) for (const i of (c._idxList || [])) usedIdx.add(i)
2836
+ const omitted = []
2837
+ for (let i = 1; i <= totalEntries; i++) if (!usedIdx.has(i)) omitted.push(i)
2838
+ return { covered: usedIdx.size, omitted }
2839
+ }
2840
+
2841
+ // When variants are absent, fall back to a single implicit baseline so the
2842
+ // pre-variant call shape (single rows × repeat) keeps producing the same
2843
+ // {runs:[…]} payload the trigger already knows how to print.
2844
+ const variantList = variants ?? [{ name: 'baseline', rules: null }]
2845
+
2846
+ async function runOnce(rows, customRules) {
2847
+ const userMessage = buildCycle1ChunkPrompt(rows, customRules)
2848
+ const t0 = Date.now()
2849
+ let raw, error
2850
+ try {
2851
+ raw = await callBridgeLlm({
2852
+ role: 'cycle1-agent',
2853
+ taskType: 'maintenance',
2854
+ mode: 'cycle1',
2855
+ preset,
2856
+ timeout: TIMEOUT_MS,
2857
+ cwd: null,
2858
+ }, userMessage)
2859
+ } catch (e) {
2860
+ error = e?.message ?? String(e)
2861
+ }
2862
+ const llmMs = Date.now() - t0
2863
+ if (error) return { ok: false, llmMs, error }
2864
+ const parsed = parseCycle1LineFormat(raw)
2865
+ const chunks = Array.isArray(parsed?.chunks) ? parsed.chunks : []
2866
+ const { covered, omitted } = summariseChunks(chunks, rows.length)
2867
+ const ratio = chunks.length > 0
2868
+ ? parseFloat((rows.length / chunks.length).toFixed(2))
2869
+ : null
2870
+ return {
2871
+ ok: true,
2872
+ llmMs,
2873
+ entries: rows.length,
2874
+ chunks: chunks.length,
2875
+ ratio,
2876
+ covered,
2877
+ omitted,
2878
+ chunkList: chunks.map(c => ({
2879
+ idx: c._idxList,
2880
+ element: c.element,
2881
+ category: c.category,
2882
+ summary: c.summary,
2883
+ })),
2884
+ }
2885
+ }
2886
+
2887
+ const results = []
2888
+ for (let s = 0; s < chosen.length; s++) {
2889
+ const { sid, rows } = chosen[s]
2890
+ const sidShort = String(sid).slice(0, 8)
2891
+ if (variants) {
2892
+ // Variant mode: same rows, one run per variant per repeat.
2893
+ const variantResults = []
2894
+ for (const v of variantList) {
2895
+ const runs = []
2896
+ for (let r = 0; r < repeat; r++) {
2897
+ const run = await runOnce(rows, v.rules)
2898
+ runs.push({ repIdx: r + 1, ...run })
2899
+ }
2900
+ variantResults.push({ name: v.name, runs })
2901
+ }
2902
+ results.push({
2903
+ setIdx: s + 1,
2904
+ sessionIdShort: sidShort,
2905
+ entries: rows.length,
2906
+ variants: variantResults,
2907
+ })
2908
+ } else {
2909
+ // Legacy single-baseline payload shape.
2910
+ const runs = []
2911
+ for (let r = 0; r < repeat; r++) {
2912
+ const run = await runOnce(rows, null)
2913
+ runs.push({ repIdx: r + 1, ...run })
2914
+ }
2915
+ results.push({
2916
+ setIdx: s + 1,
2917
+ sessionIdShort: sidShort,
2918
+ entries: rows.length,
2919
+ runs,
2920
+ })
2921
+ }
2922
+ }
2923
+ sendJson(res, {
2924
+ ok: true,
2925
+ sets, repeat,
2926
+ windowsAvailable: windows.length,
2927
+ variants: variants ? variantList.map(v => v.name) : null,
2928
+ results,
2929
+ })
2930
+ } catch (e) {
2931
+ sendError(res, e?.message || String(e))
2932
+ }
2933
+ return
2934
+ }
2935
+
2936
+ if (req.method === 'POST' && req.url === '/session-start/recap') {
2937
+ try {
2938
+ const body = await readBody(req)
2939
+ const projectId = resolveProjectScope(typeof body.cwd === 'string' && body.cwd ? body.cwd : null)
2940
+ const rows = projectId !== null
2941
+ ? (await db.query(`
2942
+ SELECT id, ts, summary FROM entries
2943
+ WHERE is_root = 1 AND (project_id IS NULL OR project_id = $1)
2944
+ ORDER BY ts DESC, id DESC LIMIT 20
2945
+ `, [projectId])).rows
2946
+ : (await db.query(`
2947
+ SELECT id, ts, summary FROM entries
2948
+ WHERE is_root = 1
2949
+ ORDER BY ts DESC, id DESC LIMIT 20
2950
+ `)).rows
2951
+ sendJson(res, { ok: true, projectId, rows })
2952
+ } catch (e) { sendError(res, e.message) }
2953
+ return
2954
+ }
2955
+
2956
+ if (req.method === 'POST' && req.url === '/api/tool') {
2957
+ if (!isLocalOrigin(req)) {
2958
+ sendJson(res, { content: [{ type: 'text', text: 'forbidden: cross-origin' }], isError: true }, 403)
2959
+ return
2960
+ }
2961
+ // Owner-side cancel plumbing: the fork-proxy worker forwards parent
2962
+ // 'cancel' IPC by issuing POST /api/cancel with the same callId. Track
2963
+ // each in-flight /api/tool by its caller-supplied X-Mixdog-Call-Id so
2964
+ // the cancel endpoint can abort the AbortSignal threaded into
2965
+ // handleToolCall. Without this the proxy-side fetch aborts but the
2966
+ // owner keeps running the upstream tool to completion.
2967
+ const callId = String(req.headers['x-mixdog-call-id'] || '').trim() || null
2968
+ const ac = new AbortController()
2969
+ // Abort only on a genuine mid-flight client disconnect. The req 'close'
2970
+ // event fires on every normal request once the request body is consumed
2971
+ // (before handleToolCall resolves), so gating on it would mark normal
2972
+ // completions as aborted. Use the response side instead: when the
2973
+ // socket closes, res.writableFinished is true iff the response was
2974
+ // fully written — a real client disconnect closes the socket before
2975
+ // the response finishes, leaving writableFinished===false.
2976
+ res.on('close', () => {
2977
+ if (res.writableFinished) return
2978
+ try { ac.abort() } catch {}
2979
+ })
2980
+ if (callId) _ownerInFlightHttpCalls.set(callId, ac)
2981
+ try {
2982
+ const body = await readBody(req)
2983
+ const result = await handleToolCall(body.name, body.arguments ?? {}, ac.signal)
2984
+ sendJson(res, result)
2985
+ } catch (e) {
2986
+ sendJson(res, { content: [{ type: 'text', text: `api/tool error: ${e.message}` }], isError: true }, Number(e?.statusCode) || 500)
2987
+ } finally {
2988
+ if (callId) _ownerInFlightHttpCalls.delete(callId)
2989
+ }
2990
+ return
2991
+ }
2992
+
2993
+ if (req.method === 'POST' && req.url === '/api/cancel') {
2994
+ if (!isLocalOrigin(req)) {
2995
+ sendJson(res, { ok: false, error: 'forbidden: cross-origin' }, 403)
2996
+ return
2997
+ }
2998
+ try {
2999
+ const body = await readBody(req)
3000
+ const id = String(body.callId || '').trim()
3001
+ if (!id) { sendJson(res, { ok: false, error: 'callId required' }, 400); return }
3002
+ const ac = _ownerInFlightHttpCalls.get(id)
3003
+ if (ac) {
3004
+ try { ac.abort() } catch {}
3005
+ _ownerInFlightHttpCalls.delete(id)
3006
+ sendJson(res, { ok: true, cancelled: true })
3007
+ } else {
3008
+ sendJson(res, { ok: true, cancelled: false })
3009
+ }
3010
+ } catch (e) {
3011
+ sendJson(res, { ok: false, error: e.message }, Number(e?.statusCode) || 500)
3012
+ }
3013
+ return
3014
+ }
3015
+
3016
+ if (req.url === '/mcp') {
3017
+ if (!isLocalOrigin(req)) {
3018
+ sendJson(res, { error: 'forbidden: cross-origin' }, 403)
3019
+ return
3020
+ }
3021
+ try {
3022
+ if (req.method === 'POST') {
3023
+ const httpMcp = createHttpMcpServer()
3024
+ const httpTransport = new StreamableHTTPServerTransport({
3025
+ sessionIdGenerator: undefined,
3026
+ enableJsonResponse: true,
3027
+ })
3028
+ res.on('close', () => {
3029
+ httpTransport.close()
3030
+ void httpMcp.close()
3031
+ })
3032
+ await httpMcp.connect(httpTransport)
3033
+ const body = await readBody(req)
3034
+ await httpTransport.handleRequest(req, res, body)
3035
+ } else {
3036
+ sendJson(res, { error: 'Method not allowed' }, 405)
3037
+ }
3038
+ } catch (e) {
3039
+ process.stderr.write(`[memory-service] /mcp error: ${e.stack || e.message}\n`)
3040
+ if (!res.headersSent) sendError(res, e.message, Number(e?.statusCode) || 500)
3041
+ }
3042
+ return
3043
+ }
3044
+
3045
+ if (req.method !== 'POST') {
3046
+ sendJson(res, { error: 'Method not allowed' }, 405)
3047
+ return
3048
+ }
3049
+
3050
+ // Tail block handles /entry and /ingest-transcript — both mutate the DB,
3051
+ // so apply the same cross-origin guard as /admin/* routes.
3052
+ if (!isLocalOrigin(req)) {
3053
+ sendError(res, 'forbidden: cross-origin', 403)
3054
+ return
3055
+ }
3056
+
3057
+ let body
3058
+ try { body = await readBody(req) }
3059
+ catch (e) { sendError(res, e.message, Number(e?.statusCode) || 500); return }
3060
+
3061
+ try {
3062
+ if (req.url === '/entry') {
3063
+ const role = String(body.role ?? 'user')
3064
+ const content = String(body.content ?? '')
3065
+ const sourceRef = String(body.sourceRef ?? `manual:${Date.now()}-${process.pid}`)
3066
+ const sessionId = body.sessionId ?? null
3067
+ const tsMs = parseTsToMs(body.ts ?? Date.now())
3068
+ if (!content) { sendJson(res, { error: 'content required' }, 400); return }
3069
+ // Run the same scrubber used by ingestTranscriptFile so noise markers
3070
+ // like "[Request interrupted by user]" and whitespace-only payloads
3071
+ // are rejected before they reach the entries table. Match the
3072
+ // existing 400 / { error } convention for invalid payloads.
3073
+ const cleaned = cleanMemoryText(content)
3074
+ if (!cleaned || !cleaned.trim()) {
3075
+ sendJson(res, { error: 'empty after clean' }, 400)
3076
+ return
3077
+ }
3078
+ const entryProjectId = resolveProjectScope(typeof body.cwd === 'string' && body.cwd ? body.cwd : null)
3079
+ try {
3080
+ const result = await db.query(`
3081
+ INSERT INTO entries(ts, role, content, source_ref, session_id, project_id)
3082
+ VALUES ($1, $2, $3, $4, $5, $6)
3083
+ ON CONFLICT DO NOTHING
3084
+ RETURNING id
3085
+ `, [tsMs, role, cleaned, sourceRef, sessionId, entryProjectId])
3086
+ const insertedId = result.rows[0]?.id ?? null
3087
+ sendJson(res, { ok: true, id: insertedId !== null ? Number(insertedId) : null, changes: Number(result.rowCount ?? result.affectedRows ?? 0) })
3088
+ } catch (e) {
3089
+ sendJson(res, { error: e.message }, 500)
3090
+ }
3091
+ return
3092
+ }
3093
+
3094
+ if (req.url === '/ingest-transcript') {
3095
+ const filePath = body.filePath
3096
+ if (!filePath) { sendJson(res, { error: 'filePath required' }, 400); return }
3097
+ try {
3098
+ const n = await ingestTranscriptFile(filePath, { cwd: body.cwd })
3099
+ sendJson(res, { ok: true, ingested: n })
3100
+ } catch (e) {
3101
+ sendJson(res, { error: e.message }, 500)
3102
+ }
3103
+ return
3104
+ }
3105
+
3106
+ sendJson(res, { error: 'Not found' }, 404)
3107
+ } catch (e) {
3108
+ process.stderr.write(`[memory-service] ${req.url} error: ${e.stack || e.message}\n`)
3109
+ sendError(res, e.message)
3110
+ }
3111
+ })
3112
+
3113
+ export { TOOL_DEFS, handleToolCall }
3114
+ export { MEMORY_INSTRUCTIONS_TEXT as instructions }
3115
+ export { acquireLock, releaseLock }
3116
+ export { cwdFromTranscriptPath }
3117
+ export async function init() {
3118
+ if (_initialized) return
3119
+ process.stderr.write(`[boot-time] tag=memory-init-start tMs=${Date.now()}\n`)
3120
+ if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
3121
+ // Single-worker daemon: acquire the owner lock (which reclaims a crashed
3122
+ // predecessor's stale, dead-PID lock). If a LIVE peer still holds it — an
3123
+ // anomaly, since server-main forks exactly one memory worker — exit so
3124
+ // server-main respawns us instead of running a second owner.
3125
+ if (!tryAcquireMemoryOwnerLock()) {
3126
+ process.stderr.write('[memory-service] live peer holds owner lock — exiting for respawn\n')
3127
+ process.exit(0)
3128
+ }
3129
+ process.on('exit', releaseMemoryOwnerLock)
3130
+ }
3131
+ const runtimeReady = _beginRuntimeInit()
3132
+ const boundPort = await _startHttpServer()
3133
+ await runtimeReady
3134
+ advertiseMemoryPort(boundPort)
3135
+ if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
3136
+ process.stderr.write(`[boot-time] tag=memory-ready tMs=${Date.now()}\n`)
3137
+ process.send({ type: 'ready', port: boundPort })
3138
+ }
3139
+ process.stderr.write(`[memory-service] init() complete (entries unified mode, version=${PLUGIN_VERSION})\n`)
3140
+ }
3141
+
3142
+ export async function stop() {
3143
+ _stopCycles()
3144
+ await stopLlmWorker()
3145
+ if (httpServer) await new Promise(resolve => httpServer.close(resolve))
3146
+ await closeDatabase(DATA_DIR)
3147
+ // Stop the PG postmaster after the connection pool has been drained.
3148
+ // closeDatabase() only ends the client pool; without this the child
3149
+ // postmaster keeps running after the memory service exits.
3150
+ try {
3151
+ const { stopPgForShutdown } = await import('./lib/pg/supervisor.mjs')
3152
+ await stopPgForShutdown()
3153
+ } catch {}
3154
+ releaseLock()
3155
+ }
3156
+
3157
+ let activePort = BASE_PORT
3158
+ let _httpReadyPromise = null
3159
+ let _httpBoundPort = null
3160
+
3161
+ function _startHttpServer() {
3162
+ if (_httpBoundPort != null) return Promise.resolve(_httpBoundPort)
3163
+ if (_httpReadyPromise) return _httpReadyPromise
3164
+ _httpReadyPromise = new Promise((resolve, reject) => {
3165
+ function tryListen() {
3166
+ httpServer.listen(activePort, '127.0.0.1', () => {
3167
+ // Use actual bound port (important when activePort=0, OS assigns a free port).
3168
+ const boundPort = httpServer.address().port
3169
+ _httpBoundPort = boundPort
3170
+ process.stderr.write(`[memory-service] HTTP listening on 127.0.0.1:${boundPort}\n`)
3171
+ resolve(boundPort)
3172
+ })
3173
+ }
3174
+ httpServer.on('error', (err) => {
3175
+ if (err.code === 'EADDRINUSE' && activePort < MAX_PORT) {
3176
+ activePort++
3177
+ tryListen()
3178
+ } else if (err.code === 'EADDRINUSE') {
3179
+ // All fixed ports exhausted; let OS pick a free port.
3180
+ activePort = 0
3181
+ tryListen()
3182
+ } else {
3183
+ process.stderr.write(`[memory-service] HTTP fatal: ${err.message}\n`)
3184
+ reject(err)
3185
+ }
3186
+ })
3187
+ tryListen()
3188
+ })
3189
+ return _httpReadyPromise
3190
+ }
3191
+
3192
+ if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
3193
+ // SIGTERM/SIGINT handler for worker mode: call stop() (fsyncs,
3194
+ // removes port file) then exit(0). Prevents taskkill /F from bypassing
3195
+ // graceful shutdown and leaving pgdata in an inconsistent checkpoint state.
3196
+ let _stopInFlight = false
3197
+ const _workerSignalHandler = (sig) => {
3198
+ if (_stopInFlight) {
3199
+ process.stderr.write(`[memory-worker] ${sig} — stop already in flight, ignoring\n`)
3200
+ return
3201
+ }
3202
+ _stopInFlight = true
3203
+ process.stderr.write(`[memory-worker] received ${sig} — calling stop() for clean shutdown\n`)
3204
+ const _exitTimer = setTimeout(() => {
3205
+ process.stderr.write(`[memory-worker] stop() timed out after 6000ms — forcing exit(2)\n`)
3206
+ process.exit(2)
3207
+ }, 6000)
3208
+ stop().then(() => {
3209
+ clearTimeout(_exitTimer)
3210
+ process.stderr.write(`[memory-worker] stop() complete — exiting cleanly\n`)
3211
+ process.exit(0)
3212
+ }).catch((e) => {
3213
+ clearTimeout(_exitTimer)
3214
+ process.stderr.write(`[memory-worker] stop() error on ${sig}: ${e && (e.message || e)}\n`)
3215
+ process.exit(1)
3216
+ })
3217
+ }
3218
+ process.on('SIGTERM', () => _workerSignalHandler('SIGTERM'))
3219
+ process.on('SIGINT', () => _workerSignalHandler('SIGINT'))
3220
+
3221
+ // callId → AbortController for in-flight IPC calls (cancel handler uses this).
3222
+ const _inFlightCalls = new Map()
3223
+
3224
+ process.on('message', async (msg) => {
3225
+ // Handle parent-initiated graceful shutdown IPC message.
3226
+ if (msg.type === 'shutdown') {
3227
+ process.stderr.write('[memory-worker] received IPC shutdown — calling stop()\n')
3228
+ _workerSignalHandler('IPC:shutdown')
3229
+ return
3230
+ }
3231
+ if (msg.type === 'cancel' && msg.callId) {
3232
+ const entry = _inFlightCalls.get(msg.callId)
3233
+ if (entry) {
3234
+ // Mark cancelled so the in-flight call's result/error branch below
3235
+ // does not double-respond after the AbortController fires.
3236
+ entry.cancelled = true
3237
+ entry.ac.abort()
3238
+ _inFlightCalls.delete(msg.callId)
3239
+ process.send({ type: 'result', callId: msg.callId, error: 'cancelled' })
3240
+ }
3241
+ return
3242
+ }
3243
+ if (msg.type !== 'call' || !msg.callId) return
3244
+ const entry = { ac: new AbortController(), cancelled: false }
3245
+ _inFlightCalls.set(msg.callId, entry)
3246
+ try {
3247
+ let result
3248
+ try {
3249
+ result = await handleToolCall(msg.name, msg.args || {}, entry.ac.signal)
3250
+ } finally {
3251
+ _inFlightCalls.delete(msg.callId)
3252
+ }
3253
+ if (!entry.cancelled) process.send({ type: 'result', callId: msg.callId, result })
3254
+ } catch (e) {
3255
+ if (!entry.cancelled) process.send({ type: 'result', callId: msg.callId, error: e.message })
3256
+ }
3257
+ })
3258
+ init().catch(e => {
3259
+ let detail
3260
+ try {
3261
+ const parts = []
3262
+ if (e?.name) parts.push(`name=${e.name}`)
3263
+ if (e?.code) parts.push(`code=${e.code}`)
3264
+ if (e?.errno) parts.push(`errno=${e.errno}`)
3265
+ if (e?.syscall) parts.push(`syscall=${e.syscall}`)
3266
+ if (e?.path) parts.push(`path=${e.path}`)
3267
+ if (e?.message) parts.push(`message=${e.message}`)
3268
+ let stringified = null
3269
+ try { stringified = JSON.stringify(e, Object.getOwnPropertyNames(e || {})) } catch {}
3270
+ if (stringified && stringified !== '{}' && stringified !== '"{}"') parts.push(`json=${stringified}`)
3271
+ if (e?.stack) parts.push(`\nstack=\n${e.stack}`)
3272
+ if (parts.length === 0) parts.push(`raw=${typeof e}:${String(e)}`)
3273
+ detail = parts.join(' | ')
3274
+ } catch (logErr) {
3275
+ detail = `(error formatting failed: ${logErr?.message}) raw=${String(e)}`
3276
+ }
3277
+ process.stderr.write(`[memory-worker] init failed: ${detail}\n`)
3278
+ // Signal degraded state to parent before exiting so it records the failure
3279
+ // rather than treating this as a normal pre-ready crash.
3280
+ try { process.send({ type: 'ready', degraded: true, error: detail.slice(0, 800) }) } catch {}
3281
+ process.exit(1)
3282
+ })
3283
+ }
3284
+
3285
+ // Standalone MCP launcher path. When this module is the entry script AND no
3286
+ // MIXDOG_WORKER_MODE flag is set, we own stdio and bring up the full MCP
3287
+ // server with acquireLock + StdioServerTransport. Server-main spawnWorker
3288
+ // also forks this file with MIXDOG_WORKER_MODE='1'; that path uses the IPC
3289
+ // handler block above and acquireLock/init() as the single memory owner.
3290
+ if (import.meta.url === pathToFileURL(process.argv[1] || '').href && process.env.MIXDOG_WORKER_MODE !== '1') {
3291
+ ;(async () => {
3292
+ acquireLock()
3293
+ process.on('exit', releaseLock)
3294
+ process.on('SIGINT', () => { stop().finally(() => process.exit(0)) })
3295
+ process.on('SIGTERM', () => { stop().finally(() => process.exit(0)) })
3296
+ await init()
3297
+ const transport = new StdioServerTransport()
3298
+ await mcp.connect(transport)
3299
+ await new Promise((resolve) => { mcp.onclose = resolve })
3300
+ await stop()
3301
+ })().catch((err) => {
3302
+ process.stderr.write(`[memory-service] startup failed: ${err.stack || err.message}\n`)
3303
+ process.exit(1)
3304
+ })
3305
+ }