mixdog 0.7.18 → 0.8.0

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