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