mixdog 0.9.2 → 0.9.4

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 (417) hide show
  1. package/package.json +8 -3
  2. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  3. package/scripts/bench/lead-review-tasks-r3.json +20 -0
  4. package/scripts/bench/lead-review-tasks.json +20 -0
  5. package/scripts/bench/r4-mixed-tasks.json +20 -0
  6. package/scripts/bench/review-tasks.json +20 -0
  7. package/scripts/bench/round-codex.json +114 -0
  8. package/scripts/bench/round-mixdog-lead-r3.json +269 -0
  9. package/scripts/bench/round-mixdog-lead.json +269 -0
  10. package/scripts/bench/round-mixdog.json +126 -0
  11. package/scripts/bench/round-r10-bigsample.json +679 -0
  12. package/scripts/bench/round-r11-codexalign.json +257 -0
  13. package/scripts/bench/round-r4-codex.json +114 -0
  14. package/scripts/bench/round-r4-mixed.json +225 -0
  15. package/scripts/bench/round-r5-gpt-lead.json +259 -0
  16. package/scripts/bench/round-r6-codex.json +114 -0
  17. package/scripts/bench/round-r6-solo.json +257 -0
  18. package/scripts/bench/round-r7-full.json +254 -0
  19. package/scripts/bench/round-r8-fulldefault.json +255 -0
  20. package/scripts/bench-run.mjs +215 -29
  21. package/scripts/build-tui.mjs +13 -1
  22. package/scripts/explore-bench.mjs +124 -0
  23. package/scripts/freevar-smoke.mjs +95 -0
  24. package/scripts/hook-bus-test.mjs +191 -0
  25. package/scripts/internal-comms-bench.mjs +1 -0
  26. package/scripts/internal-comms-smoke.mjs +10 -9
  27. package/scripts/mouse-probe.mjs +45 -0
  28. package/scripts/output-style-bench.mjs +13 -6
  29. package/scripts/output-style-smoke.mjs +4 -4
  30. package/scripts/path-suffix-test.mjs +57 -0
  31. package/scripts/provider-toolcall-test.mjs +7 -3
  32. package/scripts/recall-bench.mjs +207 -0
  33. package/scripts/recall-usecase-cases.json +18 -0
  34. package/scripts/recall-usecase-probe.json +6 -0
  35. package/scripts/session-bench.mjs +152 -6
  36. package/scripts/tool-smoke.mjs +30 -67
  37. package/scripts/tui-render-smoke.mjs +90 -0
  38. package/scripts/webhook-smoke.mjs +208 -0
  39. package/src/agents/debugger/AGENT.md +5 -2
  40. package/src/agents/heavy-worker/AGENT.md +21 -11
  41. package/src/agents/maintainer/AGENT.md +4 -0
  42. package/src/agents/reviewer/AGENT.md +3 -2
  43. package/src/agents/worker/AGENT.md +21 -11
  44. package/src/lib/rules-builder.cjs +4 -0
  45. package/src/mixdog-session-runtime.mjs +933 -3731
  46. package/src/output-styles/default.md +34 -9
  47. package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
  48. package/src/output-styles/minimal.md +4 -1
  49. package/src/output-styles/simple.md +22 -7
  50. package/src/repl.mjs +5 -5
  51. package/src/rules/agent/00-common.md +2 -0
  52. package/src/rules/agent/30-explorer.md +8 -11
  53. package/src/rules/lead/lead-brief.md +12 -0
  54. package/src/rules/lead/lead-tool.md +2 -9
  55. package/src/rules/shared/01-tool.md +11 -5
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
  59. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
  60. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
  61. package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
  62. package/src/runtime/agent/orchestrator/config.mjs +69 -2
  63. package/src/runtime/agent/orchestrator/context/collect.mjs +51 -0
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  65. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +63 -21
  66. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +97 -1343
  70. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
  71. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +78 -10
  72. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +1 -13
  73. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
  74. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
  75. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
  76. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
  77. package/src/runtime/agent/orchestrator/providers/gemini.mjs +44 -1014
  78. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +18 -4
  79. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  80. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +86 -11
  81. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +348 -0
  82. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  83. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
  84. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -12
  85. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
  86. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
  87. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
  88. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +41 -1142
  89. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +732 -0
  90. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
  91. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +303 -2119
  92. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +140 -995
  93. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +227 -0
  94. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
  95. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +436 -0
  96. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
  97. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +38 -12
  99. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +7 -8
  100. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
  101. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
  102. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
  103. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
  104. package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
  105. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
  106. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
  107. package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
  108. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  109. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +274 -0
  110. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
  111. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  112. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  113. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  114. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +47 -0
  115. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +182 -0
  116. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
  117. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  118. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  119. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
  120. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  121. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
  122. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  123. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  124. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  125. package/src/runtime/agent/orchestrator/session/loop.mjs +409 -1304
  126. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
  127. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +230 -0
  128. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  129. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +149 -0
  130. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  131. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
  132. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
  133. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  134. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
  135. package/src/runtime/agent/orchestrator/session/manager.mjs +226 -2114
  136. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
  137. package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
  138. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -3
  139. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
  140. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
  141. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +33 -42
  142. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  143. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  144. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
  145. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  146. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +1 -1
  147. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  148. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
  149. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
  150. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
  151. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -87
  152. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
  153. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
  154. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +78 -304
  155. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -6
  156. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  157. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  158. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  159. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +551 -0
  160. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  161. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  162. package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
  163. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  164. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  165. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  166. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1080 -0
  167. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  168. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  169. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  170. package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
  171. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  172. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
  173. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +36 -4277
  174. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
  175. package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
  176. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
  177. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
  178. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
  179. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
  180. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
  181. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
  182. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
  183. package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
  184. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -23
  185. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +10 -74
  186. package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
  187. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  188. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
  189. package/src/runtime/channels/backends/discord-access.mjs +32 -0
  190. package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
  191. package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
  192. package/src/runtime/channels/backends/discord.mjs +12 -292
  193. package/src/runtime/channels/index.mjs +241 -894
  194. package/src/runtime/channels/lib/backend-dispatch.mjs +44 -0
  195. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  196. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  197. package/src/runtime/channels/lib/event-pipeline.mjs +18 -1
  198. package/src/runtime/channels/lib/event-queue.mjs +63 -4
  199. package/src/runtime/channels/lib/inbound-routing.mjs +111 -0
  200. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  201. package/src/runtime/channels/lib/output-forwarder.mjs +9 -1
  202. package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
  203. package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
  204. package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
  205. package/src/runtime/channels/lib/session-discovery.mjs +56 -4
  206. package/src/runtime/channels/lib/telegram-format.mjs +19 -22
  207. package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
  208. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  209. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
  210. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
  211. package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
  212. package/src/runtime/channels/lib/webhook/deliveries.mjs +312 -0
  213. package/src/runtime/channels/lib/webhook/log.mjs +42 -0
  214. package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
  215. package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
  216. package/src/runtime/channels/lib/webhook.mjs +36 -570
  217. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  218. package/src/runtime/channels/tool-defs.mjs +11 -130
  219. package/src/runtime/memory/index.mjs +258 -2050
  220. package/src/runtime/memory/lib/core-memory-store.mjs +351 -1
  221. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
  222. package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
  223. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  224. package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
  225. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  226. package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
  227. package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
  228. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
  229. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
  230. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
  231. package/src/runtime/memory/lib/memory-cycle2.mjs +72 -837
  232. package/src/runtime/memory/lib/memory-embed.mjs +149 -0
  233. package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
  234. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  235. package/src/runtime/memory/lib/memory-recall-store.mjs +22 -2
  236. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  237. package/src/runtime/memory/lib/memory.mjs +20 -0
  238. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
  239. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  240. package/src/runtime/memory/lib/query-handlers.mjs +780 -0
  241. package/src/runtime/memory/lib/recall-format.mjs +238 -0
  242. package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
  243. package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
  244. package/src/runtime/memory/tool-defs.mjs +6 -14
  245. package/src/runtime/search/lib/http-fetch.mjs +274 -0
  246. package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
  247. package/src/runtime/search/lib/web-tools.mjs +24 -602
  248. package/src/runtime/shared/abort-controller.mjs +1 -1
  249. package/src/runtime/shared/atomic-file.mjs +26 -1
  250. package/src/runtime/shared/background-tasks.mjs +2 -3
  251. package/src/runtime/shared/buffered-appender.mjs +149 -0
  252. package/src/runtime/shared/launcher-control.mjs +2 -2
  253. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  254. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  255. package/src/runtime/shared/tool-primitives.mjs +308 -0
  256. package/src/runtime/shared/tool-result-summary.mjs +515 -0
  257. package/src/runtime/shared/tool-surface.mjs +80 -898
  258. package/src/runtime/shared/transcript-writer.mjs +52 -2
  259. package/src/runtime/shared/update-checker.mjs +7 -4
  260. package/src/session-runtime/config-helpers.mjs +291 -0
  261. package/src/session-runtime/config-lifecycle.mjs +232 -0
  262. package/src/session-runtime/cwd-plugins.mjs +226 -0
  263. package/src/session-runtime/effort.mjs +128 -0
  264. package/src/session-runtime/fs-utils.mjs +10 -0
  265. package/src/session-runtime/mcp-glue.mjs +177 -0
  266. package/src/session-runtime/model-capabilities.mjs +130 -0
  267. package/src/session-runtime/model-recency.mjs +111 -0
  268. package/src/session-runtime/native-search.mjs +247 -0
  269. package/src/session-runtime/output-styles.mjs +126 -0
  270. package/src/session-runtime/plugin-mcp.mjs +114 -0
  271. package/src/session-runtime/prewarm.mjs +142 -0
  272. package/src/session-runtime/provider-models.mjs +278 -0
  273. package/src/session-runtime/provider-usage.mjs +120 -0
  274. package/src/session-runtime/quick-model-rows.mjs +170 -0
  275. package/src/session-runtime/quick-search-models.mjs +46 -0
  276. package/src/session-runtime/session-hooks.mjs +93 -0
  277. package/src/session-runtime/session-text.mjs +100 -0
  278. package/src/session-runtime/settings-api.mjs +319 -0
  279. package/src/session-runtime/statusline-route.mjs +35 -0
  280. package/src/session-runtime/tool-catalog.mjs +720 -0
  281. package/src/session-runtime/tool-defs.mjs +84 -0
  282. package/src/session-runtime/warmup-schedulers.mjs +201 -0
  283. package/src/session-runtime/workflow.mjs +358 -0
  284. package/src/standalone/agent-tool/helpers.mjs +237 -0
  285. package/src/standalone/agent-tool/notify.mjs +107 -0
  286. package/src/standalone/agent-tool/provider-init.mjs +143 -0
  287. package/src/standalone/agent-tool/render.mjs +152 -0
  288. package/src/standalone/agent-tool/tool-def.mjs +55 -0
  289. package/src/standalone/agent-tool.mjs +155 -677
  290. package/src/standalone/channel-worker.mjs +7 -9
  291. package/src/standalone/explore-tool.mjs +40 -12
  292. package/src/standalone/hook-bus/config.mjs +207 -0
  293. package/src/standalone/hook-bus/constants.mjs +90 -0
  294. package/src/standalone/hook-bus/handlers.mjs +481 -0
  295. package/src/standalone/hook-bus/payload.mjs +31 -0
  296. package/src/standalone/hook-bus/rules.mjs +77 -0
  297. package/src/standalone/hook-bus.mjs +110 -746
  298. package/src/standalone/memory-runtime-proxy.mjs +7 -0
  299. package/src/standalone/opencode-go-login.mjs +125 -0
  300. package/src/standalone/provider-admin.mjs +15 -19
  301. package/src/standalone/usage-dashboard.mjs +3 -1
  302. package/src/tui/App.jsx +1163 -7571
  303. package/src/tui/app/app-format.mjs +206 -0
  304. package/src/tui/app/channel-pickers.mjs +510 -0
  305. package/src/tui/app/clipboard.mjs +67 -0
  306. package/src/tui/app/core-memory-picker.mjs +210 -0
  307. package/src/tui/app/extension-pickers.mjs +506 -0
  308. package/src/tui/app/input-parsers.mjs +193 -0
  309. package/src/tui/app/maintenance-pickers.mjs +324 -0
  310. package/src/tui/app/model-options.mjs +330 -0
  311. package/src/tui/app/model-picker.mjs +365 -0
  312. package/src/tui/app/onboarding-steps.mjs +400 -0
  313. package/src/tui/app/project-picker.mjs +247 -0
  314. package/src/tui/app/provider-setup-picker.mjs +580 -0
  315. package/src/tui/app/resume-picker.mjs +55 -0
  316. package/src/tui/app/route-pickers.mjs +419 -0
  317. package/src/tui/app/settings-picker.mjs +490 -0
  318. package/src/tui/app/slash-commands.mjs +101 -0
  319. package/src/tui/app/slash-dispatch.mjs +427 -0
  320. package/src/tui/app/text-layout.mjs +46 -0
  321. package/src/tui/app/theme-effort-pickers.mjs +154 -0
  322. package/src/tui/app/transcript-window.mjs +671 -0
  323. package/src/tui/app/use-mouse-input.mjs +460 -0
  324. package/src/tui/app/use-prompt-handlers.mjs +310 -0
  325. package/src/tui/app/use-transcript-scroll.mjs +510 -0
  326. package/src/tui/app/use-transcript-window.mjs +589 -0
  327. package/src/tui/components/ConfirmBar.jsx +1 -1
  328. package/src/tui/components/Picker.jsx +32 -4
  329. package/src/tui/components/PromptInput.jsx +259 -80
  330. package/src/tui/components/SlashCommandPalette.jsx +8 -1
  331. package/src/tui/components/StatusLine.jsx +63 -12
  332. package/src/tui/components/TextEntryPanel.jsx +11 -0
  333. package/src/tui/components/ToolExecution.jsx +56 -588
  334. package/src/tui/components/TranscriptItem.jsx +105 -0
  335. package/src/tui/components/UsagePanel.jsx +18 -4
  336. package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
  337. package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
  338. package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
  339. package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
  340. package/src/tui/components/tool-execution/text-format.mjs +161 -0
  341. package/src/tui/components/tool-output-format.mjs +2 -2
  342. package/src/tui/display-width.mjs +20 -3
  343. package/src/tui/dist/index.mjs +18034 -17188
  344. package/src/tui/engine/agent-envelope.mjs +296 -0
  345. package/src/tui/engine/agent-job-feed.mjs +133 -0
  346. package/src/tui/engine/boot-profile.mjs +21 -0
  347. package/src/tui/engine/labels.mjs +67 -0
  348. package/src/tui/engine/notice-text.mjs +112 -0
  349. package/src/tui/engine/notification-plan.mjs +76 -0
  350. package/src/tui/engine/queue-helpers.mjs +161 -0
  351. package/src/tui/engine/render-timing.mjs +17 -0
  352. package/src/tui/engine/session-stats.mjs +46 -0
  353. package/src/tui/engine/tool-approval.mjs +94 -0
  354. package/src/tui/engine/tool-call-fields.mjs +23 -0
  355. package/src/tui/engine/tool-card-results.mjs +234 -0
  356. package/src/tui/engine/tool-result-status.mjs +135 -0
  357. package/src/tui/engine/tool-result-text.mjs +126 -0
  358. package/src/tui/engine.mjs +405 -1385
  359. package/src/tui/figures.mjs +5 -0
  360. package/src/tui/index.jsx +105 -0
  361. package/src/tui/input-editing.mjs +60 -10
  362. package/src/tui/keyboard-protocol.mjs +2 -2
  363. package/src/tui/lib/voice-recorder.mjs +35 -19
  364. package/src/tui/markdown/format-token.mjs +11 -9
  365. package/src/tui/paste-attachments.mjs +38 -0
  366. package/src/tui/statusline-ansi-bridge.mjs +11 -3
  367. package/src/tui/theme.mjs +6 -0
  368. package/src/tui/themes/base.mjs +2 -2
  369. package/src/tui/themes/kanagawa.mjs +4 -4
  370. package/src/tui/themes/teal.mjs +4 -5
  371. package/src/tui/themes/utils.mjs +1 -1
  372. package/src/ui/statusline-agents.mjs +213 -0
  373. package/src/ui/statusline-format.mjs +146 -0
  374. package/src/ui/statusline-segments.mjs +148 -0
  375. package/src/ui/statusline.mjs +77 -462
  376. package/src/ui/tool-card.mjs +0 -1
  377. package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
  378. package/src/workflows/default/WORKFLOW.md +16 -9
  379. package/src/workflows/sequential/WORKFLOW.md +16 -11
  380. package/src/workflows/solo/WORKFLOW.md +5 -1
  381. package/vendor/ink/build/display-width.js +19 -3
  382. package/vendor/ink/build/ink.js +103 -6
  383. package/vendor/ink/build/log-update.js +17 -3
  384. package/vendor/ink/build/wrap-text.js +125 -0
  385. package/scripts/_test-folder-dialog.mjs +0 -30
  386. package/scripts/fix-brief-fn.mjs +0 -35
  387. package/scripts/fix-format-tool-surface.mjs +0 -24
  388. package/scripts/fix-tool-exec-visible.mjs +0 -42
  389. package/scripts/patch-agent-brief.mjs +0 -48
  390. package/scripts/patch-app.mjs +0 -21
  391. package/scripts/patch-app2.mjs +0 -18
  392. package/scripts/patch-dist-brief.mjs +0 -96
  393. package/scripts/patch-tool-exec.mjs +0 -70
  394. package/src/examples/schedules/SCHEDULE.example.md +0 -32
  395. package/src/examples/webhooks/WEBHOOK.example.md +0 -40
  396. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
  397. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
  398. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
  399. package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
  400. package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
  401. package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
  402. package/src/standalone/agent-task-status.test.mjs +0 -76
  403. package/src/tui/components/tool-output-format.test.mjs +0 -399
  404. package/src/tui/display-width.test.mjs +0 -35
  405. package/src/tui/engine-runtime-notification.test.mjs +0 -115
  406. package/src/tui/engine-tool-result-text.test.mjs +0 -75
  407. package/src/tui/markdown/format-token.test.mjs +0 -354
  408. package/src/tui/markdown/render-ansi.test.mjs +0 -108
  409. package/src/tui/markdown/stream-fence.test.mjs +0 -26
  410. package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
  411. package/src/tui/prompt-history-store.test.mjs +0 -52
  412. package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
  413. package/src/tui/transcript-tool-failures.test.mjs +0 -111
  414. package/src/ui/markdown.test.mjs +0 -70
  415. package/src/ui/statusline-context-label.test.mjs +0 -15
  416. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
  417. package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
@@ -9,7 +9,6 @@ process.removeAllListeners('warning')
9
9
  process.on('warning', () => {})
10
10
 
11
11
  import http from 'node:http'
12
- import crypto from 'node:crypto'
13
12
  import os from 'node:os'
14
13
  import fs from 'node:fs'
15
14
  import path from 'node:path'
@@ -17,47 +16,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
17
16
 
18
17
  const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
19
18
 
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()
19
+ import { readPluginVersion, readPromotionCodeFingerprint } from './lib/promotion-fingerprint.mjs'
20
+ const PLUGIN_VERSION = readPluginVersion(PLUGIN_ROOT)
21
+ const BOOT_PROMOTION_CODE_FINGERPRINT = readPromotionCodeFingerprint(PLUGIN_ROOT)
61
22
 
62
23
  try { os.setPriority(os.constants.priority.PRIORITY_BELOW_NORMAL) } catch {}
63
24
  try {
@@ -95,24 +56,43 @@ import {
95
56
  } from './lib/session-ingest.mjs'
96
57
  import { configureEmbedding, embedText, embedTexts, getEmbeddingDims, getEmbeddingDtype, getEmbeddingModelId, getKnownDimsForCurrentModel, isEmbeddingModelReady, primeEmbeddingDims, warmupEmbeddingProvider } from './lib/embedding-provider.mjs'
97
58
  import { startLlmWorker, stopLlmWorker } from './lib/llm-worker-host.mjs'
98
- import { runCycle1, runCycle2, runCycle3, runUnifiedGate, parseInterval, syncRootEmbedding, applySimpleStatus, applyUpdate, applyMerge, CYCLE2_ACTIVE_TARGET_CAP } from './lib/memory-cycle.mjs'
59
+ import { runCycle1, runCycle2, runCycle3, runUnifiedGate, parseInterval, syncRootEmbedding, flushRawEmbeddings, applySimpleStatus, applyUpdate, applyMerge, CYCLE2_ACTIVE_TARGET_CAP } from './lib/memory-cycle.mjs'
99
60
  import { loadConfig as loadAgentConfig } from '../agent/orchestrator/config.mjs'
100
61
  import { initProviders } from '../agent/orchestrator/providers/registry.mjs'
101
62
  import { makeAgentDispatch } from '../agent/orchestrator/agent-runtime/agent-dispatch.mjs'
102
63
  import { getInFlightCycle1 } from './lib/memory-cycle1.mjs'
103
- import { drainSessionCycle1 } from '../agent/orchestrator/session/compact.mjs'
104
- import { claimAndMarkScheduledCycle, makeCycleRequestSignature, resolveCoalesceMaxRetries, scheduleCoalescedCycleRetry } from './lib/memory-cycle-requests.mjs'
64
+ import { claimAndMarkScheduledCycle, resolveCoalesceMaxRetries, scheduleCoalescedCycleRetry } from './lib/memory-cycle-requests.mjs'
105
65
  import { searchRelevantHybrid } from './lib/memory-recall-store.mjs'
106
66
  import { fetchEntriesByIdsScoped } from './lib/memory-recall-id-patch.mjs'
107
67
  import { retrieveEntries } from './lib/memory-retrievers.mjs'
108
68
  import { pruneOldEntries } from './lib/memory-maintenance-store.mjs'
109
69
  import { computeEntryScore } from './lib/memory-score.mjs'
110
70
  import { runFullBackfill } from './lib/memory-ops-policy.mjs'
111
- import { listCore, addCore, editCore, deleteCore, compactCoreIds, CORE_SUMMARY_MAX } from './lib/core-memory-store.mjs'
71
+ import { listCore, addCore, editCore, deleteCore, compactCoreIds, listCoreCandidates, promoteCoreCandidate, dismissCoreCandidate, CORE_SUMMARY_MAX } from './lib/core-memory-store.mjs'
112
72
  import { resolveProjectId, resolveProjectScope } from './lib/project-id-resolver.mjs'
113
73
  import { openTraceDatabase, closeTraceDatabase, insertTraceEvents, enqueueTraceEvents, insertAgentCalls, registerTraceExitDrain } from './lib/trace-store.mjs'
114
74
  import { updateJsonAtomicSync, writeJsonAtomicSync } from '../shared/atomic-file.mjs'
115
75
  import { resolvePluginData, mixdogHome } from '../shared/plugin-paths.mjs'
76
+ import { parsePeriod, formatTs, coreRecallTerms, normalizeRecallProjectScope, sessionRecallTerms, interleaveRawRows, renderEntryLines, renderSessionGroupedLines } from './lib/recall-format.mjs'
77
+ import { readBody, sendJson, sendError, isLocalOrigin, normalizeCoreProjectId } from './lib/http-wire.mjs'
78
+ import { scheduledCycle1Signature, scheduledCycle2Signature, scheduledCycle3Signature } from './lib/cycle-signatures.mjs'
79
+ import { createTranscriptIngest } from './lib/transcript-ingest.mjs'
80
+ import { createEmbeddingWarmup } from './lib/embedding-warmup.mjs'
81
+ import { createCycleLlmAdapters } from './lib/cycle-llm-adapters.mjs'
82
+ import { createCycleScheduler } from './lib/cycle-scheduler.mjs'
83
+ import { createQueryHandlers } from './lib/query-handlers.mjs'
84
+ import {
85
+ readMainConfig,
86
+ readRecapEnabled,
87
+ embeddingWarmupEnabled,
88
+ envFlagEnabled,
89
+ memorySecondaryMode,
90
+ embeddingWarmupCanStart,
91
+ memoryLlmWorkerEnabled,
92
+ memoryCyclesEnabled,
93
+ secondaryPgAdvertised as _secondaryPgAdvertised,
94
+ assertSecondaryPgAttachable as _assertSecondaryPgAttachable,
95
+ } from './lib/memory-config-flags.mjs'
116
96
  const IS_MEMORY_ENTRY = !!process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
117
97
  const USE_ARG_DATA_DIR = IS_MEMORY_ENTRY || process.env.MIXDOG_WORKER_MODE === '1'
118
98
  const DATA_DIR = process.env.MIXDOG_DATA_DIR || (USE_ARG_DATA_DIR ? process.argv[2] : '') || resolvePluginData()
@@ -122,7 +102,15 @@ if (!DATA_DIR) {
122
102
  }
123
103
  __mixdogMemoryLog(`[memory-service] DATA_DIR=${DATA_DIR}\n`)
124
104
 
125
- import { execFileSync } from 'child_process'
105
+ import {
106
+ parsePositivePid,
107
+ isPidAliveLocal,
108
+ tryAcquireMemoryOwnerLock as _tryAcquireMemoryOwnerLock,
109
+ releaseMemoryOwnerLock as _releaseMemoryOwnerLock,
110
+ killPreviousServer as _killPreviousServer,
111
+ acquireLock as _acquireLock,
112
+ releaseLock as _releaseLock,
113
+ } from './lib/memory-process-lock.mjs'
126
114
 
127
115
  const RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
128
116
  ? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
@@ -136,12 +124,8 @@ let _periodicAdvertiseTimer = null
136
124
  // port after fork-proxy promotion swaps in our own locally-bound port.
137
125
  let _currentAdvertisedPort = null
138
126
 
139
- function parsePositivePid(value) {
140
- const pid = Number(value)
141
- return Number.isFinite(pid) && pid > 0 ? pid : null
142
- }
143
-
144
127
  const MEMORY_SERVER_PID = parsePositivePid(process.env.MIXDOG_SERVER_PID) ?? process.pid
128
+ const _isPidAliveLocal = isPidAliveLocal
145
129
  const MEMORY_DAEMON_MODE = process.env.MIXDOG_MEMORY_DAEMON === '1'
146
130
  const MEMORY_IDLE_TTL_MS = Math.max(0, Number(process.env.MIXDOG_MEMORY_IDLE_TTL_MS) || 10 * 60_000)
147
131
  let _idleShutdownTimer = null
@@ -191,7 +175,7 @@ function advertiseMemoryPort(boundPort, attempt = 0) {
191
175
  updatedAt: Date.now(),
192
176
  }
193
177
  return next
194
- }, { compact: true, fsyncDir: true })
178
+ }, { compact: true, fsyncDir: true, renameFallback: 'truncate' })
195
179
  if (!_periodicAdvertiseInstalled) {
196
180
  _periodicAdvertiseInstalled = true
197
181
  _periodicAdvertiseTimer = setInterval(() => {
@@ -221,58 +205,12 @@ const LOCK_FILE = path.join(DATA_DIR, '.memory-service.lock')
221
205
  // EEXIST when another process won the race.
222
206
  const OWNER_LOCK_FILE = path.join(DATA_DIR, '.memory-owner.lock')
223
207
 
224
- function _isPidAliveLocal(pid) {
225
- if (!Number.isFinite(pid) || pid <= 0) return false
226
- try { process.kill(pid, 0); return true }
227
- catch (e) { return e.code !== 'ESRCH' }
228
- }
229
-
230
208
  function tryAcquireMemoryOwnerLock() {
231
- // Returns true on success (this process now owns memory worker for the data
232
- // dir), false when a live peer holds the lock. Stale locks (dead PID) are
233
- // unlinked and retried atomically. Throws on unexpected fs errors so callers
234
- // surface lock-system corruption rather than silently downgrading.
235
- //
236
- // EPERM/EBUSY/EACCES at openSync are transient — AV scanners (SignKorea /
237
- // SKCert / ezPDFWS etc) briefly lock newly-created files during inspection.
238
- // The 0.1.x baseline threw immediately and the worker promoted to
239
- // permanentlyDegraded, killing memory tools for the rest of the session.
240
- // Treat the AV error codes as retryable with bounded backoff (~750ms total)
241
- // before giving up and rethrowing.
242
- for (let attempt = 0; attempt < 5; attempt++) {
243
- try {
244
- const fd = fs.openSync(OWNER_LOCK_FILE, 'wx')
245
- fs.writeSync(fd, String(process.pid))
246
- fs.closeSync(fd)
247
- return true
248
- } catch (e) {
249
- if (e.code === 'EEXIST') {
250
- let ownerPid = NaN
251
- try { ownerPid = Number(fs.readFileSync(OWNER_LOCK_FILE, 'utf8').trim()) } catch {}
252
- if (_isPidAliveLocal(ownerPid)) return false
253
- // Stale lock: dead owner — unlink and retry exclusive create.
254
- try { fs.unlinkSync(OWNER_LOCK_FILE) } catch {}
255
- continue
256
- }
257
- const transient = e.code === 'EPERM' || e.code === 'EBUSY' || e.code === 'EACCES'
258
- if (transient && attempt < 4) {
259
- // Sync busy-wait acceptable here: this runs on memory worker boot
260
- // path, once per process; the parent handler is not blocked.
261
- const end = Date.now() + 50 * (attempt + 1)
262
- while (Date.now() < end) {}
263
- continue
264
- }
265
- throw e
266
- }
267
- }
268
- return false
209
+ return _tryAcquireMemoryOwnerLock(OWNER_LOCK_FILE, __mixdogMemoryLog)
269
210
  }
270
211
 
271
212
  function releaseMemoryOwnerLock() {
272
- try {
273
- const ownerPid = Number(fs.readFileSync(OWNER_LOCK_FILE, 'utf8').trim())
274
- if (ownerPid === process.pid) fs.unlinkSync(OWNER_LOCK_FILE)
275
- } catch {}
213
+ return _releaseMemoryOwnerLock(OWNER_LOCK_FILE)
276
214
  }
277
215
 
278
216
  const BASE_PORT = 3350
@@ -283,139 +221,39 @@ let _traceDb = null
283
221
  const MEMORY_INSTRUCTIONS_TEXT = ''
284
222
 
285
223
  function killPreviousServer(pid) {
286
- if (pid <= 0 || pid === process.pid) return false
287
- if (process.platform === 'win32') {
288
- try {
289
- execFileSync('taskkill', ['/F', '/T', '/PID', String(pid)], { encoding: 'utf8', timeout: 5000, windowsHide: true })
290
- __mixdogMemoryLog(`[memory-service] Killed previous server PID ${pid}\n`)
291
- return true
292
- } catch (e) {
293
- // Exit code 128 = process not found; treat stale lock as already-dead = success.
294
- // Status 128 reliably means "process not found" regardless of locale; no text match needed.
295
- // Status 1 with English text match handles edge cases on some Windows versions.
296
- const notFoundText = /not found|no running instance/i.test(e.stdout || '')
297
- || /not found|no running instance/i.test(e.stderr || '')
298
- || /not found|no running instance/i.test(e.message || '')
299
- const alreadyDead = e.status === 128 || (e.status === 1 && notFoundText)
300
- if (alreadyDead) {
301
- __mixdogMemoryLog(`[memory-service] PID ${pid} already dead (stale lock), proceeding\n`)
302
- return true
303
- }
304
- __mixdogMemoryLog(`[memory-service] taskkill failed for PID ${pid}: ${e.message}\n`)
305
- return false
306
- }
307
- } else {
308
- // Pre-flight: if the process is already gone, treat stale lock as success.
309
- try {
310
- process.kill(pid, 0)
311
- } catch (e) {
312
- if (e.code === 'ESRCH') {
313
- __mixdogMemoryLog(`[memory-service] PID ${pid} already dead (stale lock), proceeding\n`)
314
- return true
315
- }
316
- }
317
- try { process.kill(pid, 'SIGTERM') } catch {}
318
- try { process.kill(pid, 'SIGKILL') } catch {}
319
- // Poll for death up to 2s
320
- const deadline = Date.now() + 2000
321
- while (Date.now() < deadline) {
322
- try {
323
- process.kill(pid, 0)
324
- } catch (e) {
325
- if (e.code === 'ESRCH') {
326
- __mixdogMemoryLog(`[memory-service] Killed previous server PID ${pid}\n`)
327
- return true
328
- }
329
- }
330
- // Synchronous 50ms sleep via shared buffer spin
331
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50)
332
- }
333
- __mixdogMemoryLog(`[memory-service] PID ${pid} still alive after SIGKILL\n`)
334
- return false
335
- }
224
+ return _killPreviousServer(pid, __mixdogMemoryLog)
336
225
  }
337
226
 
338
227
  function acquireLock() {
339
- // Multi-instance guard. In multi-terminal mode the lock owner is a *peer*
340
- // memory worker serving recall for another CC session. killPreviousServer
341
- // would taskkill /F that healthy peer mid-flight, then this fork-proxy
342
- // mode wouldn't even need a lock anyway. Skip the entire kill-the-previous
343
- // protocol; fork-proxy detection in init() takes priority. If neither
344
- // proxy nor lock-owner path applies (race window during simultaneous
345
- // boot), the worker simply continues without the lock — server-main /
346
- // PG / port-listen handle the actual conflict cases.
347
- if (process.env.MIXDOG_MULTI_INSTANCE === '1') return
348
- try {
349
- if (fs.existsSync(LOCK_FILE)) {
350
- const lockedPid = Number(fs.readFileSync(LOCK_FILE, 'utf8').trim())
351
- if (lockedPid > 0 && lockedPid !== process.pid) {
352
- const killed = killPreviousServer(lockedPid)
353
- if (!killed) {
354
- __mixdogMemoryLog(`[memory-service] Could not kill previous server PID ${lockedPid}, aborting\n`)
355
- process.exit(1)
356
- }
357
- try { fs.unlinkSync(LOCK_FILE) } catch {}
358
- }
359
- }
360
- const fd = fs.openSync(LOCK_FILE, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600)
361
- try {
362
- fs.writeSync(fd, String(process.pid))
363
- } finally {
364
- fs.closeSync(fd)
365
- }
366
- } catch (e) {
367
- if (e.code === 'EEXIST') {
368
- __mixdogMemoryLog(`[memory-service] Lock file exists (EEXIST) — another instance is already running, exiting\n`)
369
- process.exit(0)
370
- }
371
- __mixdogMemoryLog(`[memory-service] Lock acquisition failed: ${e.message}\n`)
372
- process.exit(1)
373
- }
228
+ return _acquireLock(LOCK_FILE, __mixdogMemoryLog)
374
229
  }
375
230
 
376
231
  function releaseLock() {
377
- try {
378
- const content = fs.readFileSync(LOCK_FILE, 'utf8').trim()
379
- if (Number(content) === process.pid) fs.unlinkSync(LOCK_FILE)
380
- } catch {}
381
- }
382
-
383
- import { readSection } from '../shared/config.mjs'
384
-
385
- function readMainConfig() {
386
- return readSection('memory')
232
+ return _releaseLock(LOCK_FILE)
387
233
  }
388
234
 
389
235
  let db = null
390
236
  let mainConfig = null
391
- let _cycleInterval = null
392
- let _startupTimeout = null
393
- // Outer-layer cycle1 in-flight tracker (MCP-server scope).
394
- //
395
- // The AUTHORITATIVE guard lives in memory-cycle.mjs:runCycle1 itself — that
396
- // one catches every caller, including direct imports (setup-server backfill,
397
- // policy-layer backfill). This outer tracker is kept as a defense-in-depth
398
- // layer local to the MCP server process: it coalesces simultaneous
399
- // _awaitCycle1Run callers (MCP action, scheduler, flush) onto a shared
400
- // promise so they all observe the SAME result object rather than some
401
- // getting the real stats and others getting `skippedInFlight: true` from
402
- // the inner guard.
403
- let _cycle1InFlight = null // shared cycle1 promise (outer coalesce layer)
404
- // Per-session recall-drain in-flight map. Multiple terminals share one
405
- // memory daemon (active-instance.json memory_port); concurrent recall calls
406
- // for the SAME session must share one drain promise (dedup), while DIFFERENT
407
- // sessions drain in parallel (cycle1's own advisory-lock/coalesce guard in
408
- // memory-cycle1.mjs still serializes the actual DB work). A drain never
409
- // blocks recall for sessions other than its own.
410
- const _recallDrainInFlight = new Map() // sessionId -> Promise
237
+ // NOTE: cycle tick timers + the cycle1 outer-coalesce in-flight tracker now
238
+ // live inside the cycle scheduler factory (lib/cycle-scheduler.mjs). The
239
+ // AUTHORITATIVE cycle1 guard is still memory-cycle.mjs:runCycle1; the
240
+ // scheduler's outer layer coalesces simultaneous awaitCycle1Run callers.
411
241
  let _initialized = false
412
242
  let _initPromise = null
413
243
  let _stopPromise = null
414
244
  let _bootTimestamp = null
415
- let _transcriptOffsets = new Map()
416
- /** @type {Map<string, Promise<unknown>>} */
417
- const _ingestTranscriptTails = new Map()
418
- let _transcriptOffsetsPersistTail = Promise.resolve()
245
+ // Post-ingest raw-embedding flush chain (facade-local). The checkCycles tick
246
+ // keeps its OWN independent guard inside the scheduler; this chain serializes
247
+ // flushes kicked from ingestSessionMessages so ingest bursts never stack
248
+ // embedding work, while still guaranteeing every ingest's rows get their own
249
+ // flush pass (the old boolean guard silently SKIPPED bursts, leaving rows
250
+ // embedding-less until the ~60s tick — a recall (no results) window).
251
+ let _rawEmbedFlushChain = Promise.resolve()
252
+ // Ingest now WAITS for its embedding flush (bounded) so freshly ingested rows
253
+ // are dense-searchable the moment ingest_session returns. On a cold ONNX
254
+ // model the flush can stall on warmup; the race below caps the wait and lets
255
+ // the flush finish in the background (tick fallback still sweeps stragglers).
256
+ const INGEST_EMBED_WAIT_MS = 15_000
419
257
  // Boot-edge background warmup. ONNX session creation on the embedding worker
420
258
  // thread is CPU-heavy, so it must not overlap the worker's own init (DB open,
421
259
  // schema, cycle wiring). Previously this was gated behind a fixed setTimeout —
@@ -423,83 +261,47 @@ let _transcriptOffsetsPersistTail = Promise.resolve()
423
261
  // _initStore and fired at the _initRuntime completion edge (see _initRuntime),
424
262
  // so it starts the instant boot's CPU-heavy work is done — no magic-number
425
263
  // delay. MIXDOG_EMBED_WARMUP=0 disables it (model loads lazily on first use).
426
- let _pendingEmbeddingWarmup = null
427
- let _embeddingColdRecallLogAt = 0
428
264
 
429
265
  const TRANSCRIPT_OFFSETS_KEY = 'state.transcript_offsets'
430
266
  const CYCLE_LAST_RUN_KEY = 'state.cycle_last_run'
431
267
 
432
- function embeddingWarmupEnabled() {
433
- const raw = String(process.env.MIXDOG_EMBED_WARMUP ?? '1').trim().toLowerCase()
434
- return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')
435
- }
436
-
437
- function envFlagEnabled(name) {
438
- const raw = String(process.env[name] ?? '').trim().toLowerCase()
439
- return raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes'
440
- }
441
-
442
- function memorySecondaryMode() {
443
- return envFlagEnabled('MIXDOG_MEMORY_SECONDARY')
444
- }
445
-
446
- function embeddingWarmupCanStart() {
447
- return embeddingWarmupEnabled() && !memorySecondaryMode()
448
- }
449
-
450
- function memoryLlmWorkerEnabled() {
451
- return !memorySecondaryMode() && !envFlagEnabled('MIXDOG_MEMORY_DISABLE_LLM_WORKER')
452
- }
453
-
454
- function memoryCyclesEnabled() {
455
- return !memorySecondaryMode() && !envFlagEnabled('MIXDOG_MEMORY_DISABLE_CYCLES')
456
- }
268
+ // Transcript ingest cluster (extracted to lib/transcript-ingest.mjs). Live
269
+ // db/config coupling is injected so index.mjs keeps lifecycle ownership.
270
+ const _transcriptIngest = createTranscriptIngest({
271
+ getDb: () => db,
272
+ loadMeta: () => getMetaValue(db, TRANSCRIPT_OFFSETS_KEY, '{}'),
273
+ persistMeta: (json) => setMetaValue(db, TRANSCRIPT_OFFSETS_KEY, json),
274
+ projectsRoot: () => path.join(mixdogHome(), 'projects'),
275
+ resolveProjectId,
276
+ firstTextContent,
277
+ cleanMemoryText,
278
+ log: __mixdogMemoryLog,
279
+ })
280
+ const {
281
+ loadTranscriptOffsets,
282
+ ingestTranscriptFile,
283
+ cwdFromTranscriptPath,
284
+ parseTsToMs,
285
+ } = _transcriptIngest
286
+
287
+ // Boot-edge embedding warmup queue (extracted to lib/embedding-warmup.mjs).
288
+ const _embeddingWarmup = createEmbeddingWarmup({
289
+ canStart: embeddingWarmupCanStart,
290
+ warmup: warmupEmbeddingProvider,
291
+ getDims: getEmbeddingDims,
292
+ persistMeta: (metaPath, value) => writeJsonAtomicSync(metaPath, value, { lock: true }),
293
+ log: __mixdogMemoryLog,
294
+ })
457
295
 
296
+ // DATA_DIR-bound wrappers over the extracted pure flag helpers (see
297
+ // ./lib/memory-config-flags.mjs). The pg-attach check needs DATA_DIR, which is
298
+ // module-local here.
458
299
  function secondaryPgAdvertised() {
459
- if (!memorySecondaryMode()) return true
460
- const runtimeRoot = process.env.MIXDOG_RUNTIME_ROOT
461
- ? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
462
- : path.join(os.tmpdir(), 'mixdog')
463
- try {
464
- const cur = JSON.parse(fs.readFileSync(path.join(runtimeRoot, 'active-instance.json'), 'utf8'))
465
- const port = Number(cur?.pg_port)
466
- const pgdata = cur?.pg_pgdata ? path.resolve(String(cur.pg_pgdata)) : ''
467
- return Number.isInteger(port) && port > 0 && pgdata === path.resolve(path.join(DATA_DIR, 'pgdata'))
468
- } catch {
469
- return false
470
- }
300
+ return _secondaryPgAdvertised(DATA_DIR)
471
301
  }
472
302
 
473
303
  function assertSecondaryPgAttachable() {
474
- if (!secondaryPgAdvertised()) {
475
- throw new Error('memory-service: secondary mode requires an existing primary PG instance')
476
- }
477
- }
478
-
479
- function scheduleBackgroundEmbeddingWarmup(metaPath, metaKey) {
480
- if (!embeddingWarmupCanStart()) return
481
- // Queue the warmup; _initRuntime fires it once boot completes.
482
- _pendingEmbeddingWarmup = () => {
483
- warmupEmbeddingProvider()
484
- .then(() => {
485
- const measured = Number(getEmbeddingDims())
486
- try {
487
- writeJsonAtomicSync(metaPath, { ...metaKey, dims: measured }, { lock: true })
488
- } catch (e) {
489
- __mixdogMemoryLog(`[memory-service] could not persist embedding-meta: ${e?.message || e}\n`)
490
- }
491
- })
492
- .catch(err => {
493
- __mixdogMemoryLog(`[memory-service] background warmup failed: ${err?.message || err}\n`)
494
- })
495
- }
496
- }
497
-
498
- function fireDeferredEmbeddingWarmup() {
499
- const fire = _pendingEmbeddingWarmup
500
- if (!fire) return
501
- _pendingEmbeddingWarmup = null
502
- fire()
304
+ return _assertSecondaryPgAttachable(DATA_DIR)
503
305
  }
504
306
 
505
307
  async function _initStore() {
@@ -545,7 +347,7 @@ async function _initStore() {
545
347
  primeEmbeddingDims(dimsResolved)
546
348
  assertSecondaryPgAttachable()
547
349
  db = await openDatabase(DATA_DIR, dimsResolved)
548
- scheduleBackgroundEmbeddingWarmup(EMBEDDING_META_PATH, metaKey)
350
+ _embeddingWarmup.schedule(EMBEDDING_META_PATH, metaKey)
549
351
  } else {
550
352
  if (!embeddingWarmupCanStart()) {
551
353
  throw new Error('memory-service: embedding dims unavailable while warmup is disabled')
@@ -583,7 +385,14 @@ async function _initStore() {
583
385
  // MIXDOG_MEMORY_DISABLE_LLM_WORKER=1 + cycles enabled hits an empty registry
584
386
  // and fails with "Provider not found". Non-fatal: a failure here is logged
585
387
  // and cycle1's own callLlm surfaces the unresolved-provider error per call.
586
- if (memoryCyclesEnabled() || memoryLlmWorkerEnabled()) {
388
+ // Registry must be available whenever cycles COULD run in this process, not
389
+ // just when they are currently enabled: recap can be toggled on at runtime
390
+ // (memoryCyclesEnabled() now includes the recap flag), so gate registry init
391
+ // on the cycle-capable conditions (not secondary, env not hard-disabled) OR
392
+ // the llm worker gate — never on the runtime recap flag itself, or enabling
393
+ // recap later would hit an empty registry and fail with "Provider not found".
394
+ const cyclesCapable = !memorySecondaryMode() && !envFlagEnabled('MIXDOG_MEMORY_DISABLE_CYCLES')
395
+ if (cyclesCapable || memoryLlmWorkerEnabled()) {
587
396
  try {
588
397
  const agentCfg = loadAgentConfig()
589
398
  await initProviders(agentCfg.providers || {})
@@ -595,29 +404,6 @@ async function _initStore() {
595
404
  await loadTranscriptOffsets()
596
405
  }
597
406
 
598
- async function loadTranscriptOffsets() {
599
- try {
600
- const raw = await getMetaValue(db, TRANSCRIPT_OFFSETS_KEY, '{}')
601
- const obj = JSON.parse(raw)
602
- _transcriptOffsets = new Map(Object.entries(obj))
603
- } catch {
604
- _transcriptOffsets = new Map()
605
- }
606
- }
607
-
608
- async function persistTranscriptOffsets() {
609
- const run = _transcriptOffsetsPersistTail.catch(() => {}).then(async () => {
610
- try {
611
- const obj = Object.fromEntries(_transcriptOffsets)
612
- await setMetaValue(db, TRANSCRIPT_OFFSETS_KEY, JSON.stringify(obj))
613
- } catch (e) {
614
- __mixdogMemoryLog(`[memory] persist transcript offsets failed: ${e.message}\n`)
615
- }
616
- })
617
- _transcriptOffsetsPersistTail = run.catch(() => {})
618
- return run
619
- }
620
-
621
407
  async function getCycleLastRun() {
622
408
  try {
623
409
  const raw = await getMetaValue(db, CYCLE_LAST_RUN_KEY, '{}')
@@ -652,141 +438,6 @@ async function setCycleLastRun(kind, ts) {
652
438
  await mergeMetaValue(db, CYCLE_LAST_RUN_KEY, { [kind]: ts })
653
439
  }
654
440
 
655
- // Raw-row priority lookup for narrow-window queries. Raw rows (is_root=0,
656
- // chunk_root IS NULL) are inserted immediately by ingestTranscriptFile before
657
- // cycle1 runs, so they always carry the freshest turns in the DB.
658
- async function readRawRowsInWindow(db, tsFromMs, tsToMs, hardLimit = 10, { projectScope } = {}) {
659
- try {
660
- let sql, params
661
- if (projectScope === 'common') {
662
- sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
663
- element, category, summary, status, score, last_seen_at, project_id
664
- FROM entries
665
- WHERE chunk_root IS NULL AND is_root = 0
666
- AND ts >= $1 AND ts <= $2
667
- AND project_id IS NULL
668
- ORDER BY ts DESC
669
- LIMIT $3`
670
- params = [tsFromMs ?? 0, tsToMs ?? Date.now(), hardLimit]
671
- } else if (projectScope && projectScope !== 'all') {
672
- sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
673
- element, category, summary, status, score, last_seen_at, project_id
674
- FROM entries
675
- WHERE chunk_root IS NULL AND is_root = 0
676
- AND ts >= $1 AND ts <= $2
677
- AND (project_id IS NULL OR project_id = $3)
678
- ORDER BY ts DESC
679
- LIMIT $4`
680
- params = [tsFromMs ?? 0, tsToMs ?? Date.now(), projectScope, hardLimit]
681
- } else {
682
- sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
683
- element, category, summary, status, score, last_seen_at, project_id
684
- FROM entries
685
- WHERE chunk_root IS NULL AND is_root = 0
686
- AND ts >= $1 AND ts <= $2
687
- ORDER BY ts DESC
688
- LIMIT $3`
689
- params = [tsFromMs ?? 0, tsToMs ?? Date.now(), hardLimit]
690
- }
691
- const rows = (await db.query(sql, params)).rows
692
- return rows.map(r => ({ ...r, retrievalScore: 0, rrf: 0 }))
693
- } catch { return [] }
694
- }
695
-
696
- // Spread raw (unchunked) rows evenly across a hybrid-ranked list at a fixed
697
- // stride so every offset page draws its proportional share of raw rows,
698
- // instead of them all landing after the hybrid page-0 window (the old
699
- // append-only behaviour). Stride is derived from the ratio of hybrid:raw
700
- // counts so a small raw set doesn't get pushed arbitrarily deep, and a large
701
- // raw set doesn't crowd out every hybrid hit near the top.
702
- function interleaveRawRows(hybridRows, rawRows) {
703
- if (!Array.isArray(rawRows) || rawRows.length === 0) return hybridRows
704
- const out = []
705
- const stride = Math.max(1, Math.round(hybridRows.length / (rawRows.length + 1)))
706
- let rawIdx = 0
707
- for (let i = 0; i < hybridRows.length; i += 1) {
708
- out.push(hybridRows[i])
709
- if ((i + 1) % stride === 0 && rawIdx < rawRows.length) {
710
- out.push(rawRows[rawIdx])
711
- rawIdx += 1
712
- }
713
- }
714
- while (rawIdx < rawRows.length) out.push(rawRows[rawIdx++])
715
- return out
716
- }
717
-
718
- function sessionRecallTerms(query) {
719
- return [...new Set(String(query || '').toLowerCase().match(/[\p{L}\p{N}_./:-]{2,}/gu) || [])]
720
- .filter((term) => !CORE_RECALL_STOPWORDS.has(term))
721
- .slice(0, 12)
722
- }
723
-
724
- async function recallSessionRows(args = {}) {
725
- const sessionId = String(args.sessionId || args.session_id || '').trim()
726
- if (!sessionId) return { text: '(no current session)' }
727
- const limit = Math.max(1, Math.min(100, Number(args.limit) || 20))
728
- const terms = sessionRecallTerms(args.query)
729
- const params = [sessionId]
730
- const where = ['session_id = $1']
731
- if (terms.length > 0) {
732
- const textExpr = `lower(coalesce(content, '') || ' ' || coalesce(element, '') || ' ' || coalesce(summary, ''))`
733
- const clauses = terms.map((term) => {
734
- params.push(`%${term}%`)
735
- return `${textExpr} LIKE $${params.length}`
736
- })
737
- where.push(`(${clauses.join(' OR ')})`)
738
- }
739
- params.push(limit)
740
- let rows = (await db.query(`
741
- SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
742
- element, category, summary, status, score, last_seen_at, project_id
743
- FROM entries
744
- WHERE ${where.join(' AND ')}
745
- ORDER BY ts DESC, id DESC
746
- LIMIT $${params.length}
747
- `, params)).rows
748
- if (rows.length < limit) {
749
- const seen = new Set(rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id)))
750
- const fillLimit = Math.max(0, limit - rows.length)
751
- const fillRows = fillLimit > 0
752
- ? (await db.query(`
753
- SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
754
- element, category, summary, status, score, last_seen_at, project_id
755
- FROM entries
756
- WHERE session_id = $1
757
- AND id <> ALL($2::bigint[])
758
- ORDER BY ts DESC, id DESC
759
- LIMIT $3
760
- `, [sessionId, [...seen], fillLimit])).rows
761
- : []
762
- if (fillRows.length > 0) rows = [...rows, ...fillRows]
763
- }
764
- if (args.includeMembers === true) {
765
- const rootIds = rows
766
- .filter((row) => Number(row.is_root) === 1)
767
- .map((row) => Number(row.id))
768
- .filter((id) => Number.isFinite(id))
769
- if (rootIds.length > 0) {
770
- const members = (await db.query(`
771
- SELECT id, ts, role, content, session_id, source_turn, project_id, chunk_root
772
- FROM entries
773
- WHERE chunk_root = ANY($1::bigint[]) AND is_root = 0
774
- ORDER BY chunk_root ASC, COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
775
- `, [rootIds])).rows
776
- const byRoot = new Map(rootIds.map((id) => [id, []]))
777
- for (const member of members) {
778
- const root = Number(member.chunk_root)
779
- if (byRoot.has(root)) byRoot.get(root).push(member)
780
- }
781
- for (const row of rows) {
782
- const id = Number(row.id)
783
- if (byRoot.has(id)) row.members = byRoot.get(id)
784
- }
785
- }
786
- }
787
- return { text: renderEntryLines(rows) }
788
- }
789
-
790
441
  async function ingestSessionMessages(args = {}) {
791
442
  const sessionId = String(args.sessionId || args.session_id || `session-${Date.now()}`).trim()
792
443
  const messages = Array.isArray(args.messages) ? args.messages : []
@@ -859,808 +510,85 @@ async function ingestSessionMessages(args = {}) {
859
510
  turnAllocator.next()
860
511
  }
861
512
  }
862
- return { text: `ingest_session: considered=${considered} inserted=${inserted} session=${sessionId}` }
863
- }
864
-
865
- function runTranscriptIngestSerialized(transcriptPath, fn) {
866
- const key = path.resolve(transcriptPath)
867
- const prev = _ingestTranscriptTails.get(key) ?? Promise.resolve()
868
- const run = prev.catch(() => {}).then(fn)
869
- _ingestTranscriptTails.set(key, run.catch(() => {}))
870
- return run
871
- }
872
-
873
- function snapshotTranscriptOffset(transcriptPath) {
874
- const stored = _transcriptOffsets.get(transcriptPath)
875
- if (!stored) return { bytes: 0, lineIndex: 0 }
876
- return { bytes: Number(stored.bytes) || 0, lineIndex: Number(stored.lineIndex) || 0 }
877
- }
878
-
879
- async function ingestTranscriptFileImpl(transcriptPath, { cwd } = {}) {
880
- let stat
881
- try { stat = await fs.promises.stat(transcriptPath) } catch { return 0 }
882
- const sessionUuid = path.basename(transcriptPath, '.jsonl')
883
- const prev = snapshotTranscriptOffset(transcriptPath)
884
- if (stat.size < prev.bytes) {
885
- prev.bytes = 0
886
- prev.lineIndex = 0
887
- }
888
- if (stat.size <= prev.bytes) return 0
889
-
890
- const fh = await fs.promises.open(transcriptPath, 'r')
891
- const buf = Buffer.alloc(stat.size - prev.bytes)
892
- try {
893
- await fh.read(buf, 0, buf.length, prev.bytes)
894
- } finally {
895
- await fh.close()
896
- }
897
- const text = buf.toString('utf8')
898
-
899
- const resolvedCwd = typeof cwd === 'string' && cwd ? cwd : cwdFromTranscriptPath(transcriptPath)
900
- // No cwd resolved -> classify as COMMON (project_id NULL). Falling back to
901
- // process.cwd() would misclassify rows under the service/plugin cwd.
902
- const projectId = resolvedCwd ? resolveProjectId(resolvedCwd) : null
903
-
904
- let count = 0
905
- let index = prev.lineIndex
906
- // Track the byte boundary of the LAST line we fully consumed (parsed +
907
- // either inserted or intentionally skipped). On parse failure or
908
- // transient insert error we stop and leave the boundary untouched so the
909
- // next sweep retries from the same position. This prevents malformed
910
- // trailing JSONL (mid-write partial lines) and DB hiccups from being
911
- // silently consumed forever.
912
- let lastGoodBytes = prev.bytes
913
- let lastGoodLineIndex = prev.lineIndex
914
- let cursor = 0
915
- while (cursor < text.length) {
916
- const nl = text.indexOf('\n', cursor)
917
- // No trailing newline -> partial line still being written; stop here
918
- // without advancing so the rest is re-read once the writer flushes.
919
- if (nl === -1) break
920
- const rawLine = text.slice(cursor, nl)
921
- const consumedBytes = Buffer.byteLength(rawLine, 'utf8') + 1
922
- cursor = nl + 1
923
- const line = rawLine.replace(/\r$/, '')
924
- if (!line) {
925
- lastGoodBytes += consumedBytes
926
- continue
927
- }
928
- index += 1
929
- let parsed
930
- try { parsed = JSON.parse(line) } catch {
931
- // Malformed line: do not advance past it; retry on next sweep.
932
- index -= 1
933
- break
934
- }
935
- const role = parsed.message?.role
936
- if (role !== 'user' && role !== 'assistant') {
937
- lastGoodBytes += consumedBytes
938
- lastGoodLineIndex = index
939
- continue
940
- }
941
- const content = firstTextContent(parsed.message?.content)
942
- if (!content || !content.trim()) {
943
- lastGoodBytes += consumedBytes
944
- lastGoodLineIndex = index
945
- continue
946
- }
947
- const cleaned = cleanMemoryText(content)
948
- if (!cleaned) {
949
- lastGoodBytes += consumedBytes
950
- lastGoodLineIndex = index
951
- continue
952
- }
953
- const tsMs = parseTsToMs(parsed.timestamp ?? parsed.ts ?? Date.now())
954
- const sourceRef = `transcript:${sessionUuid}#${index}`
955
- try {
956
- const result = await db.query(
957
- `INSERT INTO entries(ts, role, content, source_ref, session_id, source_turn, project_id)
958
- VALUES ($1, $2, $3, $4, $5, $6, $7)
959
- ON CONFLICT DO NOTHING`,
960
- [tsMs, role, cleaned, sourceRef, sessionUuid, index, projectId]
961
- )
962
- if (Number(result.rowCount ?? result.affectedRows ?? 0) > 0) count += 1
963
- lastGoodBytes += consumedBytes
964
- lastGoodLineIndex = index
965
- } catch (e) {
966
- __mixdogMemoryLog(`[transcript-watch] insert error (${sourceRef}): ${e.message}\n`)
967
- // Transient insert failure: leave the boundary before this line so
968
- // the next sweep retries it. Roll back the line counter too.
969
- index -= 1
970
- break
971
- }
972
- }
973
- _transcriptOffsets.set(transcriptPath, {
974
- bytes: lastGoodBytes,
975
- lineIndex: lastGoodLineIndex,
976
- })
977
- await persistTranscriptOffsets()
978
- return count
979
- }
980
-
981
- async function ingestTranscriptFile(transcriptPath, options = {}) {
982
- return runTranscriptIngestSerialized(transcriptPath, () => ingestTranscriptFileImpl(transcriptPath, options))
983
- }
984
-
985
- function parseTsToMs(value) {
986
- if (typeof value === 'number' && Number.isFinite(value)) return value < 1e12 ? value * 1000 : value
987
- const parsed = Date.parse(String(value))
988
- return Number.isFinite(parsed) ? parsed : Date.now()
989
- }
990
-
991
- // Extract cwd from the transcript file's JSONL rows. Mixdog embeds
992
- // the session cwd as a top-level `cwd` field on every message row, so
993
- // scanning the first few lines is reliable on all platforms (Windows/Linux)
994
- // without slug-decoding ambiguity. Returns undefined when no cwd is found
995
- // or the extracted path does not exist on disk (falls back to COMMON).
996
- function cwdFromTranscriptPath(fp) {
997
- let fd
998
- try {
999
- fd = fs.openSync(fp, 'r')
1000
- const buf = Buffer.alloc(Math.min(fs.fstatSync(fd).size, 100 * 1024))
1001
- fs.readSync(fd, buf, 0, buf.length, 0)
1002
- fs.closeSync(fd)
1003
- fd = undefined
1004
- const lines = buf.toString('utf8').split('\n')
1005
- for (let i = 0; i < Math.min(lines.length, 5); i++) {
1006
- const line = lines[i].trim()
1007
- if (!line) continue
1008
- try {
1009
- const obj = JSON.parse(line)
1010
- if (typeof obj.cwd === 'string' && obj.cwd) {
1011
- const candidate = obj.cwd
1012
- try { if (fs.statSync(candidate).isDirectory()) return candidate } catch {}
1013
- }
1014
- } catch {}
1015
- }
1016
- } catch {} finally {
1017
- if (fd != null) { try { fs.closeSync(fd) } catch {} }
1018
- }
1019
- return undefined
1020
- }
1021
-
1022
- function _initTranscriptWatcher() {
1023
- const projectsRoot = path.join(mixdogHome(), 'projects')
1024
- const SAFETY_POLL_MS = 5 * 60_000
1025
- const DEBOUNCE_MS = 500
1026
- const watchedFiles = new Map()
1027
- const pendingByFile = new Map()
1028
- const watchers = []
1029
- const intervals = []
1030
- const polledFiles = new Set()
1031
- let safetySweepTimeout = null
1032
-
1033
- function isWatchable(relOrBase) {
1034
- const base = path.basename(relOrBase)
1035
- if (!base.endsWith('.jsonl') || base.startsWith('agent-')) return false
1036
- if (relOrBase.includes('tmp') || relOrBase.includes('cache') || relOrBase.includes('plugins')) return false
1037
- return true
1038
- }
1039
-
1040
- async function ingestOne(fp) {
1041
- try {
1042
- if (!fs.existsSync(fp)) return
1043
- const stat = fs.statSync(fp)
1044
- const mtime = stat.mtimeMs
1045
- const prev = watchedFiles.get(fp)
1046
- if (prev && prev >= mtime) return
1047
- const n = await ingestTranscriptFile(fp, { cwd: cwdFromTranscriptPath(fp) })
1048
- // Only mark this mtime as 'consumed' once the persisted offset has
1049
- // fully advanced past the observed file size. On a transient insert
1050
- // error (or a malformed trailing line) ingestTranscriptFile leaves
1051
- // the persisted offset before the failed line for retry; caching
1052
- // the new mtime unconditionally would suppress the next sweep until
1053
- // the file mutated again, losing the retry. Leave the cache
1054
- // untouched on partial advance so the next sweep re-ingests.
1055
- const off = _transcriptOffsets.get(fp)
1056
- if (off && off.bytes >= stat.size) {
1057
- watchedFiles.set(fp, mtime)
1058
- }
1059
- if (n > 0) {
1060
- __mixdogMemoryLog(`[transcript-watch] ingested ${n} entries from ${path.basename(fp)}\n`)
1061
- }
1062
- } catch (e) {
1063
- __mixdogMemoryLog(`[transcript-watch] ingest error: ${e.message}\n`)
1064
- }
1065
- }
1066
-
1067
- function scheduleIngest(fp) {
1068
- const existing = pendingByFile.get(fp)
1069
- if (existing) clearTimeout(existing)
1070
- const timer = setTimeout(() => {
1071
- pendingByFile.delete(fp)
1072
- ingestOne(fp)
1073
- }, DEBOUNCE_MS)
1074
- pendingByFile.set(fp, timer)
1075
- }
1076
-
1077
- async function discoverActiveTranscripts() {
1078
- let topLevel
1079
- try { topLevel = await fs.promises.readdir(projectsRoot) }
1080
- catch { return [] }
1081
- const files = []
1082
- for (const d of topLevel) {
1083
- if (d.includes('tmp') || d.includes('cache') || d.includes('plugins')) continue
1084
- const full = path.join(projectsRoot, d)
1085
- let inner
1086
- try { inner = await fs.promises.readdir(full) } catch { continue }
1087
- for (const f of inner) {
1088
- if (!f.endsWith('.jsonl') || f.startsWith('agent-')) continue
1089
- const fp = path.join(full, f)
1090
- try {
1091
- const stat = await fs.promises.stat(fp)
1092
- files.push({ path: fp, mtime: stat.mtimeMs })
1093
- } catch {}
1094
- }
1095
- }
1096
- const cutoff = Date.now() - 30 * 60_000
1097
- return files.filter(f => f.mtime > cutoff)
1098
- }
1099
-
1100
- async function safetySweep() {
1101
- try {
1102
- const active = await discoverActiveTranscripts()
1103
- for (const { path: fp } of active) ingestOne(fp)
1104
- } catch (e) {
1105
- __mixdogMemoryLog(`[transcript-watch] safety sweep error: ${e.message}\n`)
1106
- }
1107
- }
1108
-
1109
- safetySweepTimeout = setTimeout(safetySweep, 3_000)
1110
-
1111
- // fs.watch({recursive}) is only reliable on win32.
1112
- // darwin: recursive option unreliable — use flat watch per-entry (glob dirs at start).
1113
- // linux/WSL: recursive not supported — use fs.watchFile polling per file found via
1114
- // the safety sweep, or fall back entirely to safety sweep.
1115
- if (process.platform === 'win32') {
1116
- try {
1117
- const watcher = fs.watch(projectsRoot, { recursive: true, persistent: true }, (_event, filename) => {
1118
- if (!filename) return
1119
- if (!isWatchable(filename)) return
1120
- const fp = path.join(projectsRoot, filename)
1121
- scheduleIngest(fp)
513
+ // Always-on post-ingest raw embedding: freshly ingested rows become
514
+ // dense-searchable immediately (autoclear/recall-fasttrack hydration,
515
+ // recall empty-fallback), without waiting for cycle1 chunking or the
516
+ // ~60s background tick. Local ONNX only — no LLM cost, so it runs
517
+ // regardless of the recap toggle. SYNCHRONOUS (bounded): ingest_session
518
+ // resolves only after this session's rows are embedded, so an immediately
519
+ // following recall sees them in the dense leg. Bursts serialize on
520
+ // _rawEmbedFlushChain instead of being skipped; a 15s cap keeps a cold
521
+ // ONNX warmup from wedging ingest (tick fallback sweeps what's left).
522
+ if (inserted > 0) {
523
+ const run = _rawEmbedFlushChain
524
+ .catch(() => {}) // never let a previous flush failure poison the chain
525
+ .then(() => flushRawEmbeddings(db, { limit: 200 }))
526
+ .then((r) => {
527
+ if (r.attempted > 0) __mixdogMemoryLog(`[embed] post-ingest raw flush attempted=${r.attempted} embedded=${r.embedded}\n`)
528
+ return r
1122
529
  })
1123
- watcher.on('error', (err) => {
1124
- __mixdogMemoryLog(`[transcript-watch] fs.watch error: ${err.message}\n`)
1125
- })
1126
- watchers.push(watcher)
1127
- __mixdogMemoryLog(`[transcript-watch] fs.watch(recursive) active on ${projectsRoot}\n`)
1128
- } catch (e) {
1129
- __mixdogMemoryLog(`[transcript-watch] fs.watch setup failed: ${e.message} — relying on safety sweep only\n`)
1130
- }
1131
- intervals.push(setInterval(safetySweep, SAFETY_POLL_MS))
1132
- } else if (process.platform === 'darwin') {
1133
- // Flat watch: register a non-recursive watcher on each immediate subdirectory.
1134
- // New subdirs are picked up on the next safety sweep cycle.
1135
- try {
1136
- const registerFlat = (dir) => {
1137
- try {
1138
- const w = fs.watch(dir, { persistent: true }, (_event, filename) => {
1139
- if (!filename) return
1140
- const fp = path.join(dir, filename)
1141
- if (!isWatchable(fp)) return
1142
- scheduleIngest(fp)
1143
- })
1144
- w.on('error', () => { /* ignore individual dir errors */ })
1145
- watchers.push(w)
1146
- } catch { /* dir may not exist yet */ }
1147
- }
1148
- registerFlat(projectsRoot)
1149
- try {
1150
- for (const entry of fs.readdirSync(projectsRoot, { withFileTypes: true })) {
1151
- if (entry.isDirectory()) registerFlat(path.join(projectsRoot, entry.name))
1152
- }
1153
- } catch { /* best effort */ }
1154
- __mixdogMemoryLog(`[transcript-watch] flat fs.watch active on ${projectsRoot} (darwin)\n`)
1155
- } catch (e) {
1156
- __mixdogMemoryLog(`[transcript-watch] flat watch setup failed: ${e.message} — relying on safety sweep only\n`)
1157
- }
1158
- intervals.push(setInterval(safetySweep, SAFETY_POLL_MS))
1159
- } else {
1160
- // linux/WSL: fs.watch recursive is unsupported. Use fs.watchFile polling for
1161
- // individual files surfaced by the safety sweep, in addition to the sweep itself.
1162
- __mixdogMemoryLog(`[transcript-watch] linux/WSL — using safety sweep + fs.watchFile polling (no recursive watch)\n`)
1163
- // Wrap by reassigning the closure-captured reference is not possible here;
1164
- // instead, register watchFile inside the safety sweep callback by intercepting
1165
- // active file list after each sweep. The interval already calls safetySweep
1166
- // which calls ingestOne; watchFile additions happen as a side-effect of the sweep.
1167
- const _patchedSweep = async () => {
1168
- try {
1169
- const active = await discoverActiveTranscripts()
1170
- for (const { path: fp } of active) {
1171
- if (!polledFiles.has(fp)) {
1172
- polledFiles.add(fp)
1173
- fs.watchFile(fp, { persistent: false, interval: 2000 }, () => {
1174
- if (isWatchable(fp)) scheduleIngest(fp)
1175
- })
1176
- }
1177
- ingestOne(fp)
1178
- }
1179
- } catch (e) {
1180
- __mixdogMemoryLog(`[transcript-watch] linux sweep error: ${e.message}\n`)
1181
- }
1182
- }
1183
- // Replace the safety sweep interval with the patched version.
1184
- intervals.push(setInterval(_patchedSweep, SAFETY_POLL_MS))
1185
- }
1186
-
1187
- return {
1188
- stop() {
1189
- if (safetySweepTimeout) { clearTimeout(safetySweepTimeout); safetySweepTimeout = null }
1190
- for (const t of pendingByFile.values()) { try { clearTimeout(t) } catch {} }
1191
- pendingByFile.clear()
1192
- for (const i of intervals) { try { clearInterval(i) } catch {} }
1193
- intervals.length = 0
1194
- for (const w of watchers) { try { w.close() } catch {} }
1195
- watchers.length = 0
1196
- for (const fp of polledFiles) { try { fs.unwatchFile(fp) } catch {} }
1197
- polledFiles.clear()
1198
- },
1199
- }
1200
- }
1201
-
1202
- // Phase B §2.4 — cache-keeper health thresholds.
1203
- // warning fires when cycle1 is overdue past HEALTH_OVERDUE_MS; an auto-
1204
- // restart attempt fires when the warning has been emitted AND the most
1205
- // recent unscheduled restart was more than AUTO_RESTART_COOLDOWN_MS ago.
1206
- // Both default to 5 min per spec; caller overrides are not exposed yet.
1207
- const CYCLE1_HEALTH_OVERDUE_MS = 5 * 60_000
1208
- const CYCLE1_AUTO_RESTART_COOLDOWN_MS = 5 * 60_000
1209
-
1210
- // In-process cycle1 LLM adapter. The memory daemon runs makeAgentDispatch()
1211
- // locally (provider registry is initialized in _initStore), so cycle1 never
1212
- // has to route over the dead IPC agent path (agent-ipc.mjs callAgentDispatch). The
1213
- // factory is built once (role/taskType are fixed) and the returned function
1214
- // is reshaped to cycle1's call signature: cycle1 invokes
1215
- // `callLlm({ role, taskType, mode, preset, timeout, cwd }, userMessage)` and
1216
- // expects a raw string, while makeAgentDispatch's function takes a single
1217
- // `{ prompt }` object. The adapter maps the two — preset/cwd resolution is
1218
- // handled inside makeAgentDispatch via role (cycle1-agent → maint.memory slot).
1219
- let _cycle1AgentDispatch = null
1220
- function getCycle1CallLlm() {
1221
- if (!_cycle1AgentDispatch) {
1222
- _cycle1AgentDispatch = makeAgentDispatch({
1223
- agent: 'cycle1-agent',
1224
- taskType: 'maintenance',
1225
- sourceType: 'memory-cycle',
1226
- // cycle1 parses the full raw line-format response; the agent brief cap
1227
- // (12KB) would truncate a large valid response and append prose, causing
1228
- // partial parsing / omitted / invalid chunks. Opt out so the cycle1
1229
- // no-truncation contract is preserved through makeAgentDispatch.
1230
- brief: false,
1231
- })
1232
- }
1233
- return async (opts = {}, userMessage) => {
1234
- // Preserve cycle1's timeout contract: cycle1 derives `opts.timeout` from
1235
- // config / caller deadline and expects it to bound the call. makeAgentDispatch
1236
- // takes it as a per-call `idleTimeoutMs` (stale watchdog). Map it through;
1237
- // omit when absent/0 so agent defaults apply.
1238
- const callTimeout = Number(opts?.timeout)
1239
- return _cycle1AgentDispatch({
1240
- prompt: String(userMessage ?? ''),
1241
- preset: opts?.preset || undefined,
1242
- ...(Number.isFinite(callTimeout) && callTimeout > 0 ? { idleTimeoutMs: callTimeout } : {}),
1243
- })
1244
- }
1245
- }
1246
-
1247
- let _cycle2AgentDispatch = null
1248
- function getCycle2CallLlm() {
1249
- if (!_cycle2AgentDispatch) {
1250
- _cycle2AgentDispatch = makeAgentDispatch({
1251
- agent: 'cycle2-agent',
1252
- taskType: 'maintenance',
1253
- sourceType: 'memory-cycle',
1254
- brief: false,
1255
- })
1256
- }
1257
- return async (opts = {}, userMessage) => {
1258
- const callTimeout = Number(opts?.timeout)
1259
- return _cycle2AgentDispatch({
1260
- prompt: String(userMessage ?? ''),
1261
- preset: opts?.preset || undefined,
1262
- ...(Number.isFinite(callTimeout) && callTimeout > 0 ? { idleTimeoutMs: callTimeout } : {}),
1263
- })
1264
- }
1265
- }
1266
-
1267
- let _cycle3AgentDispatch = null
1268
- function getCycle3CallLlm() {
1269
- if (!_cycle3AgentDispatch) {
1270
- _cycle3AgentDispatch = makeAgentDispatch({
1271
- agent: 'cycle3-agent',
1272
- taskType: 'maintenance',
1273
- sourceType: 'memory-cycle',
1274
- brief: false,
1275
- })
1276
- }
1277
- return async (opts = {}, userMessage) => {
1278
- const callTimeout = Number(opts?.timeout)
1279
- return _cycle3AgentDispatch({
1280
- prompt: String(userMessage ?? ''),
1281
- preset: opts?.preset || undefined,
1282
- ...(Number.isFinite(callTimeout) && callTimeout > 0 ? { idleTimeoutMs: callTimeout } : {}),
1283
- })
1284
- }
1285
- }
1286
-
1287
- async function recordCycle1Result(result) {
1288
- const now = Date.now()
1289
- await setCycleLastRun('cycle1_heartbeat', now)
1290
- const skipped = result?.skippedInFlight === true
1291
- const coalescedNoop = result?.coalescedRetryNoop === true
1292
- const allFailed = !skipped
1293
- && Number(result?.chunks ?? 0) === 0
1294
- && Number(result?.processed ?? 0) === 0
1295
- && Number(result?.skipped ?? 0) > 0
1296
- if (!skipped && !coalescedNoop && !allFailed) {
1297
- await setCycleLastRun('cycle1', now)
1298
- }
1299
- }
1300
-
1301
- function _startCycle1Run(config = {}, options = {}) {
1302
- // Default to the in-process agent dispatch so every cycle1 path — scheduled,
1303
- // auto-restart, periodic, manual (action:cycle1), backfill drain, rebuild —
1304
- // dispatches locally and the dead IPC fallback in memory-cycle1.mjs is never
1305
- // reached. Explicit options.callLlm (if a caller ever passes one) wins.
1306
- if (typeof options?.callLlm !== 'function') {
1307
- options = { ...options, callLlm: getCycle1CallLlm() }
1308
- }
1309
- _cycle1InFlight = (async () => {
1310
- try {
1311
- const result = await runCycle1(db, config, options, DATA_DIR)
1312
- // #13: heartbeat (attempt) is always recorded so the overdue check
1313
- // can tell the keeper is alive; success timestamp only advances when
1314
- // the run actually did work. Skipped/in-flight runs do NOT count as
1315
- // success because the next overdue check would otherwise see a fake
1316
- // green and stop forcing auto-restarts.
1317
- if (typeof options?.onCoalescedSuccess !== 'function') {
1318
- await recordCycle1Result(result)
1319
- }
1320
- return result
1321
- } finally {
1322
- if (_cycle1InFlight === promise) _cycle1InFlight = null
1323
- }
1324
- })()
1325
- const promise = _cycle1InFlight
1326
- return _cycle1InFlight
1327
- }
1328
-
1329
- async function _awaitCycle1Run(config = {}, options = {}) {
1330
- const target = _cycle1InFlight || _startCycle1Run(config, options)
1331
- const callerDeadlineMs = Number(options.callerDeadlineMs) || 0
1332
- if (callerDeadlineMs <= 0) return await target
1333
- // Caller-deadline race. When the channels-side timeout fires, we
1334
- // (a) graceful-return a skippedInFlight envelope so the calling
1335
- // SessionStart slot stops blocking with a 200 OK + flags instead of a
1336
- // 503-class throw, and (b) release the outer in-flight handle. The
1337
- // underlying LLM run keeps progressing in the background — it still
1338
- // owns the inner dedup guard (memory-cycle.mjs _runCycle1InFlight).
1339
- // Releasing the outer handle is what breaks the cascade: any later
1340
- // _awaitCycle1Run call now re-enters _startCycle1Run, whose inner
1341
- // runCycle1 short-circuits with skippedInFlight:true the moment it
1342
- // sees the same db still busy. Returning a graceful object (vs the
1343
- // pre-0.1.198 throw) keeps the channel route response shape stable
1344
- // and lets pollers read inFlight=true rather than parse an error.
1345
- let timer
1346
- const deadlinePromise = new Promise((resolve) => {
1347
- timer = setTimeout(() => {
1348
- if (_cycle1InFlight === target) _cycle1InFlight = null
1349
- resolve({
1350
- processed: 0,
1351
- chunks: 0,
1352
- skipped: 0,
1353
- sessions: 0,
1354
- skippedInFlight: true,
1355
- timedOutWaiting: true,
1356
- callerDeadlineMs,
1357
- })
1358
- }, callerDeadlineMs)
1359
- })
1360
- try {
1361
- return await Promise.race([target, deadlinePromise])
1362
- } finally {
1363
- clearTimeout(timer)
1364
- }
1365
- }
1366
-
1367
- // Periodic cycle1 sizing: only enter when ≥ 20 pending rows have built up,
1368
- // then process up to 2 sessions × 50 rows per tick. cycle1 itself keeps each
1369
- // classifier window session-isolated. The on-demand path used by SessionStart
1370
- // hooks runs with a 1-row threshold and 5×20 windows instead — see
1371
- // hooks/session-start.cjs ON_DEMAND_CYCLE1_ARGS.
1372
- // mainConfig.cycle1 values still win, so users can override any of these in
1373
- // config.json.
1374
- function periodicCycle1Config() {
1375
- return {
1376
- min_batch: 20,
1377
- session_cap: 2,
1378
- batch_size: 50,
1379
- concurrency: 2,
1380
- ...(mainConfig?.cycle1 || {}),
1381
- }
1382
- }
1383
-
1384
- function scheduledCycle1Signature(config) {
1385
- return makeCycleRequestSignature('cycle1', config, {
1386
- preset: undefined,
1387
- concurrency: undefined,
1388
- maxConcurrent: undefined,
1389
- })
1390
- }
1391
-
1392
- function scheduledCycle2Signature(config) {
1393
- return makeCycleRequestSignature('cycle2', config, {
1394
- cascadePreset: undefined,
1395
- concurrency: undefined,
1396
- })
1397
- }
1398
-
1399
- function scheduledCycle3ApplyMode(config) {
1400
- const raw = String(config?.cycle3?.applyMode || 'conservative').trim().toLowerCase()
1401
- return (raw === 'proposal' || raw === 'dry-run' || raw === 'dryrun') ? 'proposal' : 'conservative'
1402
- }
1403
-
1404
- function scheduledCycle3Signature(config) {
1405
- const retryConfig = config?.cycle3 || config
1406
- return makeCycleRequestSignature('cycle3', retryConfig, {
1407
- applyMode: scheduledCycle3ApplyMode(config),
1408
- apply: undefined,
1409
- })
1410
- }
1411
-
1412
- async function enqueueScheduledCycle(kind, intervalMs, signature) {
1413
- const claim = await claimAndMarkScheduledCycle(db, kind, intervalMs, signature, { reason: 'scheduled' })
1414
- return claim.claimed === true
1415
- }
1416
-
1417
- async function enqueueScheduledCycle1(intervalMs, _reason = 'scheduled') {
1418
- const config = periodicCycle1Config()
1419
- const signature = scheduledCycle1Signature(config)
1420
- if (await enqueueScheduledCycle('cycle1', intervalMs, signature)) {
1421
- scheduleScheduledCycle1(config, signature)
1422
- }
1423
- }
1424
-
1425
- async function enqueueScheduledCycle2(intervalMs, _reason = 'scheduled') {
1426
- const config = mainConfig?.cycle2 || {}
1427
- const signature = scheduledCycle2Signature(config)
1428
- if (await enqueueScheduledCycle('cycle2', intervalMs, signature)) {
1429
- scheduleScheduledCycle2(config, signature)
1430
- }
1431
- }
1432
-
1433
- async function enqueueScheduledCycle3(intervalMs, _reason = 'scheduled') {
1434
- const config = mainConfig || {}
1435
- const signature = scheduledCycle3Signature(config)
1436
- if (await enqueueScheduledCycle('cycle3', intervalMs, signature)) {
1437
- scheduleScheduledCycle3(config, signature)
1438
- }
1439
- }
1440
-
1441
- function scheduleScheduledCycle1(config, signature, attempt = 0) {
1442
- const maxRetries = resolveCoalesceMaxRetries(config, 3)
1443
- if (attempt > maxRetries) {
1444
- __mixdogMemoryLog('[cycle1] scheduled queue retry cap reached\n')
1445
- return
1446
- }
1447
- scheduleCoalescedCycleRetry(db, 'cycle1', async () => {
1448
- if (_cycle1InFlight) {
1449
- scheduleScheduledCycle1(config, signature, attempt + 1)
1450
- return
1451
- }
1452
- const result = await _awaitCycle1Run(config, {
1453
- coalescedRetry: true,
1454
- onCoalescedSuccess: recordCycle1Result,
1455
- })
1456
- if (result?.skippedInFlight) scheduleScheduledCycle1(config, signature, attempt + 1)
1457
- }, config, signature)
1458
- }
1459
-
1460
- function scheduleScheduledCycle2(config, signature, attempt = 0) {
1461
- const maxRetries = resolveCoalesceMaxRetries(config, 3)
1462
- if (attempt > maxRetries) {
1463
- __mixdogMemoryLog('[cycle2] scheduled queue retry cap reached\n')
1464
- return
1465
- }
1466
- scheduleCoalescedCycleRetry(db, 'cycle2', async () => {
1467
- if (_cycle2InFlight) {
1468
- scheduleScheduledCycle2(config, signature, attempt + 1)
1469
- return
1470
- }
1471
- _cycle2InFlight = true
1472
- try {
1473
- let c2Options = {
1474
- coalescedRetry: true,
1475
- onCoalescedSuccess: _finalizeCycle2Run,
1476
- }
1477
- if (typeof c2Options?.callLlm !== 'function') {
1478
- c2Options = { ...c2Options, callLlm: getCycle2CallLlm() }
1479
- }
1480
- const result = await runCycle2(db, config, c2Options, DATA_DIR)
1481
- if (result?.skippedInFlight) {
1482
- scheduleScheduledCycle2(config, signature, attempt + 1)
1483
- } else if (result?.coalescedRetryNoop) {
1484
- __mixdogMemoryLog('[cycle2] scheduled queue noop\n')
1485
- } else if (result?.ok === false) {
1486
- await _finalizeCycle2Run(result)
1487
- }
1488
- } catch (err) {
1489
- __mixdogMemoryLog(`[cycle2] scheduled queue failed: ${err?.message || err}\n`)
1490
- } finally {
1491
- _cycle2InFlight = false
1492
- }
1493
- }, config, signature)
1494
- }
1495
-
1496
- function scheduleScheduledCycle3(config, signature, attempt = 0) {
1497
- const retryConfig = config?.cycle3 || config
1498
- const maxRetries = resolveCoalesceMaxRetries(retryConfig, 3)
1499
- if (attempt > maxRetries) {
1500
- __mixdogMemoryLog('[cycle3] scheduled queue retry cap reached\n')
1501
- return
1502
- }
1503
- scheduleCoalescedCycleRetry(db, 'cycle3', async () => {
1504
- if (_cycle3InFlight) {
1505
- scheduleScheduledCycle3(config, signature, attempt + 1)
1506
- return
1507
- }
1508
- _cycle3InFlight = true
1509
- try {
1510
- let c3Options = {
1511
- coalescedRetry: true,
1512
- onCoalescedSuccess: () => setCycleLastRun('cycle3', Date.now()),
1513
- }
1514
- if (typeof c3Options?.callLlm !== 'function') {
1515
- c3Options = { ...c3Options, callLlm: getCycle3CallLlm() }
1516
- }
1517
- const result = await runCycle3(db, config, DATA_DIR, c3Options)
1518
- if (result?.skippedInFlight) {
1519
- scheduleScheduledCycle3(config, signature, attempt + 1)
1520
- } else if (result?.coalescedRetryNoop) {
1521
- __mixdogMemoryLog('[cycle3] scheduled queue noop\n')
1522
- }
1523
- } catch (err) {
1524
- __mixdogMemoryLog(`[cycle3] scheduled queue failed: ${err?.message || err}\n`)
1525
- } finally {
1526
- _cycle3InFlight = false
1527
- }
1528
- }, retryConfig, signature)
1529
- }
1530
-
1531
- async function _finalizeCycle2Run(result) {
1532
- if (result?.skippedInFlight) {
1533
- __mixdogMemoryLog('[cycle2] skipped: in flight\n')
1534
- return
1535
- }
1536
- if (result.ok) {
1537
- await setCycleLastRun('cycle2', Date.now())
1538
- await setCycleLastRun('cycle2_last_error', '')
1539
- __mixdogMemoryLog('[cycle2] completed\n')
1540
- } else {
1541
- await setCycleLastRun('cycle2_last_error', result.error || 'unknown error')
1542
- __mixdogMemoryLog(`[cycle2] failed: ${result.error}\n`)
530
+ .catch((err) => __mixdogMemoryLog(`[embed] post-ingest raw flush failed: ${err?.message || err}\n`))
531
+ _rawEmbedFlushChain = run
532
+ let timer
533
+ await Promise.race([
534
+ run,
535
+ new Promise((resolve) => { timer = setTimeout(resolve, INGEST_EMBED_WAIT_MS) }),
536
+ ]).finally(() => clearTimeout(timer))
1543
537
  }
538
+ return { text: `ingest_session: considered=${considered} inserted=${inserted} session=${sessionId}` }
1544
539
  }
1545
540
 
1546
- async function checkCycles() {
1547
- // Poll-on-use: re-read memory config each tick so changed enabled/interval
1548
- // values apply without a restart (mirrors search/cwd poll-on-use). The fixed
1549
- // 60s poll bounds latency; manual `memory` tool calls already re-read per-call.
1550
- mainConfig = readMainConfig();
1551
- if (mainConfig?.enabled === false) return
1552
-
1553
- const cycle1Ms = parseInterval(mainConfig?.cycle1?.interval || '10m')
1554
- const cycle2Ms = parseInterval(mainConfig?.cycle2?.interval || '1h')
1555
- const cycle3Ms = parseInterval(mainConfig?.cycle3?.interval || '24h')
1556
-
1557
- const now = Date.now()
1558
- const last = await getCycleLastRun()
1559
-
1560
- // Phase B §2.4 — cache-keeper health check + auto-restart.
1561
- //
1562
- // `last.cycle1 + cycle1Ms` is the next scheduled run time; anything beyond
1563
- // that by > HEALTH_OVERDUE_MS means the keeper missed its window and the
1564
- // Anthropic shard is drifting cold. Emit a warning, and — if we haven't
1565
- // retried in the last cooldown window — force an unscheduled run so the
1566
- // shard gets re-touched before the next Worker / Sub call pays the 2×
1567
- // write premium. Cooldown prevents a tight retry loop when the underlying
1568
- // cause (network, provider outage) is still broken.
1569
- //
1570
- // Cold-start guard: a fresh DB has last.cycle1 = 0, which would make
1571
- // (now - 0 - cycle1Ms) blow past HEALTH_OVERDUE_MS on every first boot
1572
- // and force-trigger the auto-restart branch even though the shard never
1573
- // existed in the first place. The "drifting cold" concept doesn't apply
1574
- // until at least one successful run has anchored a baseline.
1575
- const cycle1OverdueMs = last.cycle1 > 0
1576
- ? Math.max(0, now - last.cycle1 - cycle1Ms)
1577
- : 0
1578
- if (cycle1OverdueMs > CYCLE1_HEALTH_OVERDUE_MS) {
1579
- const lastSeen = last.cycle1 ? new Date(last.cycle1).toISOString() : 'never'
1580
- __mixdogMemoryLog(
1581
- `[cycle1] overdue by ${Math.floor(cycle1OverdueMs / 60_000)}min `
1582
- + `(last=${lastSeen}). Pool B Anthropic shard may be cold.\n`
1583
- )
1584
- const lastAutoRestart = last.cycle1_autoRestart || 0
1585
- if (now - lastAutoRestart >= CYCLE1_AUTO_RESTART_COOLDOWN_MS) {
1586
- // #14: record the attempt timestamp BEFORE the call (so a hung run
1587
- // cannot tight-loop) and the result timestamp only on success. On
1588
- // failure we return immediately instead of falling through into the
1589
- // due branch — falling through would silently re-enter the same
1590
- // failing path within the same tick.
1591
- await setCycleLastRun('cycle1_autoRestart_attempt', now)
1592
- try {
1593
- const result = await _awaitCycle1Run(periodicCycle1Config())
1594
- await setCycleLastRun('cycle1_autoRestart', Date.now())
1595
- __mixdogMemoryLog(
1596
- `[cycle1] auto-restart completed chunks=${result?.chunks ?? 0} processed=${result?.processed ?? 0}\n`
1597
- )
1598
- return
1599
- } catch (e) {
1600
- __mixdogMemoryLog(`[cycle1] auto-restart error: ${e.message}\n`)
1601
- // Cooldown attempt timestamp is committed; do NOT fall through
1602
- // to the due branch — next tick will retry after cooldown.
1603
- return
1604
- }
1605
- }
1606
- }
1607
-
1608
- if (now - last.cycle1 >= cycle1Ms) {
1609
- await enqueueScheduledCycle1(cycle1Ms, 'scheduled')
1610
- }
1611
-
1612
- if (now - last.cycle2 >= cycle2Ms) {
1613
- await enqueueScheduledCycle2(cycle2Ms, 'scheduled')
1614
- }
541
+ // ── Cycle scheduling cluster (extracted to lib/cycle-scheduler.mjs) ────────
542
+ // The mutually-referential cycle machinery (health ledger, cycle1 outer
543
+ // coalesce layer, scheduled enqueue/retry paths, checkCycles, tick loop) lives
544
+ // in the factory below. index.mjs keeps lifecycle ownership by injecting live
545
+ // getters (getDb/getConfig/setConfig) plus runners and LLM adapters.
546
+ const CYCLE_STATE_FILE = path.join(DATA_DIR, 'memory-cycle-state.json')
547
+
548
+ const _cycleLlmAdapters = createCycleLlmAdapters({ makeAgentDispatch })
549
+ const { getCycle1CallLlm, getCycle2CallLlm, getCycle3CallLlm } = _cycleLlmAdapters
550
+
551
+ const _cycleScheduler = createCycleScheduler({
552
+ getDb: () => db,
553
+ getConfig: () => mainConfig,
554
+ setConfig: (cfg) => { mainConfig = cfg },
555
+ dataDir: DATA_DIR,
556
+ log: __mixdogMemoryLog,
557
+ getCycleLastRun,
558
+ setCycleLastRun,
559
+ readMainConfig,
560
+ memoryCyclesEnabled,
561
+ getCycle1CallLlm,
562
+ getCycle2CallLlm,
563
+ getCycle3CallLlm,
564
+ runCycle1,
565
+ runCycle2,
566
+ runCycle3,
567
+ parseInterval,
568
+ flushRawEmbeddings,
569
+ getInFlightCycle1,
570
+ claimAndMarkScheduledCycle,
571
+ resolveCoalesceMaxRetries,
572
+ scheduleCoalescedCycleRetry,
573
+ scheduledCycle1Signature,
574
+ scheduledCycle2Signature,
575
+ scheduledCycle3Signature,
576
+ cycleStateFile: CYCLE_STATE_FILE,
577
+ })
578
+ // Cycle1 run primitives + cycle2 finalize used by MCP action handlers below.
579
+ const _startCycle1Run = _cycleScheduler.startCycle1Run
580
+ const _awaitCycle1Run = _cycleScheduler.awaitCycle1Run
581
+ const _finalizeCycle2Run = _cycleScheduler.finalizeCycle2Run
1615
582
 
1616
- if (now - last.cycle3 >= cycle3Ms) {
1617
- await enqueueScheduledCycle3(cycle3Ms, 'scheduled')
1618
- }
1619
- }
1620
- let _cycle2InFlight = false
1621
- let _cycle3InFlight = false
1622
-
1623
- // #12: self-rescheduling timer. setInterval would fire ticks regardless of
1624
- // whether the previous checkCycles() call had finished; with cycle1/cycle2
1625
- // each potentially taking minutes, that races. Use setTimeout that re-arms
1626
- // itself only after the prior tick resolves, plus an in-flight guard so a
1627
- // stray manual call cannot stack ticks.
1628
- let _checkCyclesInFlight = false
1629
- async function _runCheckCyclesGuarded() {
1630
- if (_checkCyclesInFlight) return
1631
- _checkCyclesInFlight = true
1632
- try { await checkCycles() }
1633
- catch (e) { __mixdogMemoryLog(`[cycle-tick] error: ${e.message}\n`) }
1634
- finally { _checkCyclesInFlight = false }
1635
- }
1636
- function _scheduleNextCheck() {
1637
- _cycleInterval = setTimeout(async () => {
1638
- _cycleInterval = null
1639
- try {
1640
- await _runCheckCyclesGuarded()
1641
- } catch (e) {
1642
- __mixdogMemoryLog(`[cycle-tick] re-arm guard caught: ${e?.message || e}\n`)
1643
- } finally {
1644
- // Re-arm regardless of inner outcome — _runCheckCyclesGuarded already
1645
- // swallows its own errors, but defensive try/finally guarantees the
1646
- // periodic tick continues even if a synchronous throw escapes.
1647
- if (_cyclesActive) _scheduleNextCheck()
1648
- }
1649
- }, 60_000)
1650
- }
1651
- let _cyclesActive = false
583
+ // Transcript watcher lifecycle stays in the facade (owns _transcriptIngest);
584
+ // the cycle tick loop start/stop is delegated to the scheduler.
1652
585
  let _transcriptWatcher = null
1653
586
  function _startCycles() {
1654
- if (_cyclesActive) return
1655
- _cyclesActive = true
1656
- _scheduleNextCheck()
1657
- _startupTimeout = setTimeout(() => { void _runCheckCyclesGuarded() }, 30_000)
587
+ _cycleScheduler.startCycles()
1658
588
  }
1659
589
 
1660
590
  function _stopCycles() {
1661
- _cyclesActive = false
1662
- if (_cycleInterval) { clearTimeout(_cycleInterval); _cycleInterval = null }
1663
- if (_startupTimeout) { clearTimeout(_startupTimeout); _startupTimeout = null }
591
+ _cycleScheduler.stopCycles()
1664
592
  if (_transcriptWatcher) { try { _transcriptWatcher.stop() } catch {} _transcriptWatcher = null }
1665
593
  }
1666
594
 
@@ -1671,17 +599,28 @@ async function _initRuntime() {
1671
599
  // increments, so deleted rows leave permanent gaps. Fast no-op when already
1672
600
  // contiguous (or empty). Runs only here — never in cycle2/addCore/deleteCore.
1673
601
  await compactCoreIds(DATA_DIR)
1674
- if (memoryCyclesEnabled()) {
1675
- _transcriptWatcher = _initTranscriptWatcher()
602
+ // Memory module is always-on: the transcript watcher/ingest runs
603
+ // unconditionally except in secondary mode (secondary attaches to a primary's
604
+ // PG and must not double-ingest). The recap toggle only gates whether the
605
+ // background cycles actually schedule work — but we still start the tick loop
606
+ // so recap can be toggled ON at runtime (checkCycles polls recap each tick and
607
+ // no-ops while recap is off). The env hard-override / secondary mode skip the
608
+ // tick loop entirely.
609
+ if (!memorySecondaryMode()) {
610
+ _transcriptWatcher = _transcriptIngest.initTranscriptWatcher()
611
+ } else {
612
+ __mixdogMemoryLog('[memory-service] secondary mode; skipping transcript watcher\n')
613
+ }
614
+ if (!memorySecondaryMode() && !envFlagEnabled('MIXDOG_MEMORY_DISABLE_CYCLES')) {
1676
615
  _startCycles()
1677
616
  } else {
1678
- __mixdogMemoryLog('[memory-service] secondary mode; skipping background cycles\n')
617
+ __mixdogMemoryLog('[memory-service] background cycle tick loop not started (secondary/env-disabled)\n')
1679
618
  }
1680
619
  _initialized = true
1681
620
  // Boot complete — continue straight into the deferred embedding warmup.
1682
621
  // Fire-and-forget on the embedding worker thread; never awaited so it does
1683
622
  // not delay init() returning or the memory-ready signal.
1684
- fireDeferredEmbeddingWarmup()
623
+ _embeddingWarmup.fireDeferred()
1685
624
  }
1686
625
 
1687
626
  function _beginRuntimeInit() {
@@ -1696,748 +635,22 @@ function _beginRuntimeInit() {
1696
635
  return _initPromise
1697
636
  }
1698
637
 
1699
-
1700
- function parsePeriod(period, hasQuery) {
1701
- if (!period && hasQuery) period = '30d'
1702
- if (!period) return null
1703
- if (period === 'all') return null
1704
- if (period === 'last') return { mode: 'last' }
1705
- // Calendar-day windows: 'today' anchors at local midnight rather than
1706
- // rolling 24h. Without this, a query asking 'today' at 01:30 would silently
1707
- // include yesterday's last 22.5h of activity, mislabelling them as
1708
- // 'today's work'. 'yesterday' is the previous calendar day.
1709
- if (period === 'today') {
1710
- const start = new Date()
1711
- start.setHours(0, 0, 0, 0)
1712
- return { startMs: start.getTime(), endMs: Date.now() }
1713
- }
1714
- if (period === 'yesterday') {
1715
- const start = new Date()
1716
- start.setDate(start.getDate() - 1)
1717
- start.setHours(0, 0, 0, 0)
1718
- const end = new Date(start)
1719
- end.setHours(23, 59, 59, 999)
1720
- return { startMs: start.getTime(), endMs: end.getTime() }
1721
- }
1722
- if (period === 'this_week' || period === 'last_week') {
1723
- // R6 P9: calendar Mon-Sun previous/current week. Mon-start ISO
1724
- // convention. Replaces R5 rolling 7-14d range which was empty for
1725
- // sessions where "last week" decisions actually fell on Mon (4/27) of
1726
- // this week. Precise calendar bounds match natural-language intuition.
1727
- const d = new Date()
1728
- d.setHours(0, 0, 0, 0)
1729
- const dayOfWeek = d.getDay()
1730
- const daysSinceMon = (dayOfWeek + 6) % 7
1731
- const thisWeekMon = new Date(d)
1732
- thisWeekMon.setDate(d.getDate() - daysSinceMon)
1733
- if (period === 'this_week') {
1734
- return { startMs: thisWeekMon.getTime(), endMs: Date.now() }
1735
- }
1736
- const lastWeekMon = new Date(thisWeekMon)
1737
- lastWeekMon.setDate(thisWeekMon.getDate() - 7)
1738
- const lastWeekSunEnd = new Date(thisWeekMon.getTime() - 1)
1739
- return { startMs: lastWeekMon.getTime(), endMs: lastWeekSunEnd.getTime() }
1740
- }
1741
- const relMatch = period.match(/^(\d+)(m|h|d)$/)
1742
- if (relMatch) {
1743
- const n = parseInt(relMatch[1])
1744
- const unit = relMatch[2]
1745
- const now = new Date()
1746
- if (unit === 'm') {
1747
- // Minute granularity is for "resume from the previous turn / pick
1748
- // up where we left off" style recall — sub-hour windows where 1h
1749
- // is too coarse. n=0 is invalid (the regex requires \d+ which
1750
- // matches "0" but a zero-width window returns no rows; leave that
1751
- // as caller-supplied no-op).
1752
- const start = new Date(now.getTime() - n * 60_000)
1753
- return { startMs: start.getTime(), endMs: now.getTime() }
1754
- }
1755
- if (unit === 'h') {
1756
- const start = new Date(now.getTime() - n * 3600_000)
1757
- return { startMs: start.getTime(), endMs: now.getTime() }
1758
- }
1759
- const start = new Date(now)
1760
- start.setDate(start.getDate() - n)
1761
- return { startMs: start.getTime(), endMs: now.getTime() }
1762
- }
1763
- const rangeMatch = period.match(/^(\d{4}-\d{2}-\d{2})~(\d{4}-\d{2}-\d{2})$/)
1764
- if (rangeMatch) {
1765
- return {
1766
- startMs: Date.parse(rangeMatch[1] + 'T00:00:00'),
1767
- endMs: Date.parse(rangeMatch[2] + 'T23:59:59.999'),
1768
- }
1769
- }
1770
- const dateMatch = period.match(/^(\d{4}-\d{2}-\d{2})$/)
1771
- if (dateMatch) {
1772
- return {
1773
- startMs: Date.parse(dateMatch[1] + 'T00:00:00'),
1774
- endMs: Date.parse(dateMatch[1] + 'T23:59:59.999'),
1775
- exact: true,
1776
- }
1777
- }
1778
- return null
1779
- }
1780
-
1781
- function formatTs(tsMs) {
1782
- const n = Number(tsMs)
1783
- if (Number.isFinite(n) && n > 1e12) {
1784
- return new Date(n).toLocaleString('sv-SE').slice(0, 16)
1785
- }
1786
- return String(tsMs ?? '').slice(0, 16)
1787
- }
1788
-
1789
- const CORE_RECALL_STOPWORDS = new Set([
1790
- 'about', 'after', 'again', 'before', 'check', 'color', 'decision', 'decided',
1791
- 'earlier', 'memory', 'previous', 'routing', 'stored', 'tell', 'what',
1792
- ])
1793
-
1794
- function coreRecallTerms(query) {
1795
- return [...new Set(String(query || '').toLowerCase().match(/[\p{L}\p{N}_-]{4,}/gu) || [])]
1796
- .filter((term) => !CORE_RECALL_STOPWORDS.has(term))
1797
- .slice(0, 8)
1798
- }
1799
-
1800
- function normalizeRecallProjectScope(projectScope) {
1801
- const raw = String(projectScope || 'common').trim()
1802
- if (!raw || raw.toLowerCase() === 'common') return null
1803
- if (raw.toLowerCase() === 'all') return '*'
1804
- return raw
1805
- }
1806
-
1807
- async function recallCoreRows(query, { projectScope, category, limit } = {}) {
1808
- const terms = coreRecallTerms(query)
1809
- if (terms.length === 0) return []
1810
-
1811
- const params = []
1812
- const where = []
1813
- const scope = normalizeRecallProjectScope(projectScope)
1814
- if (scope === null) {
1815
- where.push('project_id IS NULL')
1816
- } else if (scope !== '*') {
1817
- params.push(scope)
1818
- where.push(`(project_id IS NULL OR project_id = $${params.length})`)
1819
- }
1820
- if (category != null) {
1821
- const cats = (Array.isArray(category) ? category : [category])
1822
- .map((value) => String(value || '').trim().toLowerCase())
1823
- .filter(Boolean)
1824
- if (cats.length > 0) {
1825
- const placeholders = cats.map((cat) => {
1826
- params.push(cat)
1827
- return `$${params.length}`
1828
- })
1829
- where.push(`category IN (${placeholders.join(', ')})`)
1830
- }
1831
- }
1832
-
1833
- const textExpr = `lower(coalesce(element, '') || ' ' || coalesce(summary, ''))`
1834
- const termClauses = terms.map((term) => {
1835
- params.push(`%${term}%`)
1836
- return `${textExpr} LIKE $${params.length}`
1837
- })
1838
- where.push(`(${termClauses.join(' OR ')})`)
1839
- const hitExpr = termClauses.map((clause) => `CASE WHEN ${clause} THEN 1 ELSE 0 END`).join(' + ')
1840
- const rowLimit = Math.max(1, Math.min(10, Number(limit) || 5))
1841
- params.push(rowLimit)
1842
-
1843
- const rows = (await db.query(`
1844
- SELECT id, element, summary, category, project_id, created_at, updated_at,
1845
- (${hitExpr}) AS hit_count
1846
- FROM core_entries
1847
- WHERE ${where.join(' AND ')}
1848
- ORDER BY hit_count DESC, updated_at DESC, id ASC
1849
- LIMIT $${params.length}
1850
- `, params)).rows
1851
-
1852
- return rows.map((row) => ({
1853
- ...row,
1854
- id: `core:${row.id}`,
1855
- ts: row.updated_at || row.created_at || Date.now(),
1856
- is_root: 1,
1857
- }))
1858
- }
1859
-
1860
- // Session-scoped full drain before a recall(query) search. Chunking is
1861
- // partitioned by session_id (memory-cycle1.mjs:409-431 selected_sessions
1862
- // GROUP BY session_id + ROW_NUMBER PARTITION BY session_id), so this drains
1863
- // ONLY the calling session's backlog via the same session_id override
1864
- // (memory-cycle1.mjs:394) — other sessions' pending rows are left for the
1865
- // periodic cycle1 sweep. Reuses recall-fasttrack's drainSessionCycle1
1866
- // (session/compact.mjs:504) which loops cycle1 until raw rows hit 0 or a
1867
- // no-progress pass. Safety limits: a 2,000-row hard cap (both in the pending
1868
- // pre-check and as the per-call cycle1 batch ceiling) and cooperative
1869
- // abort-signal propagation through handleMemoryAction — no time budget.
1870
- //
1871
- // Multi-terminal safety: the memory daemon is one shared instance across
1872
- // terminals (active-instance.json memory_port). Concurrent recall(query)
1873
- // calls for the SAME session share one in-flight drain promise (dedup);
1874
- // different sessions drain independently in parallel (cycle1's own
1875
- // advisory-lock/coalesce guard in memory-cycle1.mjs still serializes actual
1876
- // DB writes). A drain only gates recall for its OWN session — every other
1877
- // terminal's recall (different session, or no session) proceeds unblocked.
1878
- const RECALL_DRAIN_HARD_CAP_ROWS = 2000
1879
- async function _drainSessionForRecallQuery(sessionId, signal) {
1880
- if (!sessionId || signal?.aborted) return
1881
- const existing = _recallDrainInFlight.get(sessionId)
1882
- if (existing) { try { await existing } catch {} return }
1883
- let pendingCount = 0
1884
- try {
1885
- const r = await db.query(
1886
- `SELECT COUNT(*) c FROM entries WHERE chunk_root IS NULL AND session_id = $1`,
1887
- [sessionId],
1888
- )
1889
- pendingCount = Number(r.rows?.[0]?.c ?? 0)
1890
- } catch { return }
1891
- if (pendingCount <= 0) return
1892
- const runTool = async (_name, toolArgs) => {
1893
- const result = await handleMemoryAction(toolArgs, signal)
1894
- return result?.text ?? ''
1895
- }
1896
- const dumpArgs = {
1897
- action: 'dump_session_roots',
1898
- sessionId,
1899
- includeRaw: true,
1900
- limit: Math.min(RECALL_DRAIN_HARD_CAP_ROWS, Math.max(1000, pendingCount)),
1901
- }
1902
- const drainPromise = (async () => {
1903
- try {
1904
- return await drainSessionCycle1(runTool, {
1905
- sessionId,
1906
- dumpArgs,
1907
- deadlineMs: 0, // no time budget — drive the session's backlog to 0
1908
- maxPasses: 20, // safety stopper; drainSessionCycle1 also self-stops on no-progress
1909
- cycleArgs: {
1910
- min_batch: 1,
1911
- session_cap: 1,
1912
- batch_size: RECALL_DRAIN_HARD_CAP_ROWS,
1913
- rows_per_session: RECALL_DRAIN_HARD_CAP_ROWS,
1914
- window_size: 20,
1915
- concurrency: 4,
1916
- },
1917
- })
1918
- } catch (err) {
1919
- __mixdogMemoryLog(`[recall] session drain aborted/failed (sess=${sessionId}): ${err?.message || err}\n`)
1920
- return null
1921
- }
1922
- })()
1923
- _recallDrainInFlight.set(sessionId, drainPromise)
1924
- try {
1925
- await drainPromise
1926
- } finally {
1927
- if (_recallDrainInFlight.get(sessionId) === drainPromise) _recallDrainInFlight.delete(sessionId)
1928
- }
1929
- }
1930
-
1931
- async function handleSearch(args, signal) {
1932
- // Cooperative abort check: throw early if the caller already aborted
1933
- // (IPC cancel handler signals the AbortController before re-entry).
1934
- if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1935
- // Drain this session's unchunked backlog to zero BEFORE searching, so a
1936
- // query recall never returns raw/unclassified transcript tail instead of
1937
- // the chunked summary. Only runs when both a session and a query are
1938
- // present; a bare recent-browsing call (no query) skips this entirely.
1939
- {
1940
- const _drainSessionId = String(args?.sessionId || args?.session_id || '').trim()
1941
- const _drainHasQuery = Array.isArray(args?.query)
1942
- ? args.query.some((v) => String(v || '').trim())
1943
- : String(args?.query ?? '').trim() !== ''
1944
- if (_drainSessionId && _drainHasQuery) {
1945
- await _drainSessionForRecallQuery(_drainSessionId, signal)
1946
- }
1947
- }
1948
- if (args?.currentSession === true || args?.sessionId || args?.session_id) {
1949
- return await recallSessionRows(args)
1950
- }
1951
- // id mode (follow-up lookup): caller passed `#N` markers from a prior
1952
- // recall result. Fetch those rows directly + their chunk members,
1953
- // bypassing hybrid search entirely. Output reuses renderEntryLines so
1954
- // the shape stays identical to the search path (chunk members first,
1955
- // root summary fallback).
1956
- if (Array.isArray(args.ids) && args.ids.length > 0) {
1957
- const ids = args.ids
1958
- .map(v => Number(v))
1959
- .filter(v => Number.isFinite(v) && v > 0)
1960
- if (ids.length === 0) return { text: '(no valid ids)' }
1961
- const includeArchived = args.includeArchived !== false
1962
- const category = args.category
1963
- const period = String(args.period ?? '').trim() || undefined
1964
- const temporal = parsePeriod(period, false)
1965
- let projectScope
1966
- if (typeof args.projectScope === 'string' && args.projectScope) {
1967
- projectScope = args.projectScope
1968
- } else {
1969
- const projectId = resolveProjectScope(typeof args.cwd === 'string' && args.cwd ? args.cwd : null)
1970
- projectScope = projectId !== null ? projectId : 'common'
1971
- }
1972
- const excludeStatuses = includeArchived ? [] : ['archived']
1973
- const rows = await fetchEntriesByIdsScoped(db, ids, {
1974
- ts_from: temporal?.startMs,
1975
- ts_to: temporal?.endMs,
1976
- excludeStatuses,
1977
- category,
1978
- projectScope,
1979
- })
1980
- if (rows.length === 0) return { text: '(no results)' }
1981
- // Members for any root rows in the result set.
1982
- const rootIds = rows.filter(r => r.is_root === 1).map(r => Number(r.id))
1983
- const memberLeafIds = new Set()
1984
- if (rootIds.length > 0) {
1985
- const { rows: memberRows } = await db.query(
1986
- `SELECT id, ts, role, content, chunk_root
1987
- FROM entries WHERE chunk_root = ANY($1::bigint[]) AND is_root = 0
1988
- ORDER BY ts ASC, id ASC`,
1989
- [rootIds],
1990
- )
1991
- const membersByRoot = new Map()
1992
- for (const m of memberRows) {
1993
- const k = Number(m.chunk_root)
1994
- if (!membersByRoot.has(k)) membersByRoot.set(k, [])
1995
- membersByRoot.get(k).push(m)
1996
- memberLeafIds.add(Number(m.id))
1997
- }
1998
- for (const r of rows) {
1999
- if (r.is_root === 1) r.members = membersByRoot.get(Number(r.id)) ?? []
2000
- }
2001
- }
2002
- // Preserve caller-supplied id order; drop leaves already inlined as a
2003
- // root's chunk member to prevent double emission when the caller names
2004
- // a root and one of its leaves in the same batch.
2005
- const byId = new Map(rows.map(r => [Number(r.id), r]))
2006
- const ordered = ids
2007
- .map(id => byId.get(id))
2008
- .filter(Boolean)
2009
- .filter(r => !(r.is_root === 0 && memberLeafIds.has(Number(r.id))))
2010
- return { text: renderEntryLines(ordered) }
2011
- }
2012
- // Array query — fan out in parallel, each query runs its own hybrid search
2013
- // path, and results are grouped in the response so the caller sees one
2014
- // ranked list per angle. Collapses what would otherwise be N sequential
2015
- // tool calls into a single invocation.
2016
- if (Array.isArray(args.query)) {
2017
- // Dedup + fan-out cap. The cap protects the result envelope from
2018
- // over-eager callers (20+ near-duplicate queries N× the IO) without
2019
- // silently swallowing the caller's intent: when the input exceeds
2020
- // QUERIES_CAP, prepend a one-line note so the caller can see the
2021
- // truncation and re-shape their query list.
2022
- const QUERIES_CAP = 5
2023
- const dedup = [...new Set(args.query.map(q => String(q || '').trim()).filter(Boolean))]
2024
- if (dedup.length === 0) return { text: '' }
2025
- const queries = dedup.slice(0, QUERIES_CAP)
2026
- const dropped = dedup.length - queries.length
2027
- const rest = { ...args }
2028
- delete rest.query
2029
- const deadlineSec = Math.max(1, Number(process.env.MEMORY_FANOUT_DEADLINE_S) || 180)
2030
- const deadlineMs = deadlineSec * 1000
2031
- const fanOutAbort = new AbortController()
2032
- let deadlineTimer
2033
- const deadlineRace = new Promise((_res, rej) => {
2034
- deadlineTimer = setTimeout(() => {
2035
- fanOutAbort.abort(new Error(`memory fan-out deadline exceeded (${deadlineSec}s)`))
2036
- rej(Object.assign(new Error(`memory fan-out deadline exceeded (${deadlineSec}s)`), { _deadline: true }))
2037
- }, deadlineMs)
2038
- })
2039
- let settled
2040
- try {
2041
- // Pre-warm only when the embedding model is already resident. If the
2042
- // process is still cold, keep recall responsive and let the background
2043
- // warmup finish independently instead of making the first query pay the
2044
- // ONNX session-create cost.
2045
- if (isEmbeddingModelReady()) {
2046
- // Race against the same deadline as the fan-out itself: a stuck
2047
- // embedding worker would previously park here indefinitely because
2048
- // the timer hadn't been started yet from the fan-out's perspective.
2049
- await Promise.race([embedTexts(queries), deadlineRace])
2050
- } else if (embeddingWarmupCanStart()) {
2051
- void warmupEmbeddingProvider().catch((err) => {
2052
- __mixdogMemoryLog(`[memory-service] embedding warmup after cold fan-out skipped dense search: ${err?.message || err}\n`)
2053
- })
2054
- }
2055
- settled = await Promise.race([
2056
- Promise.all(queries.map(async (q) => {
2057
- if (fanOutAbort.signal.aborted) throw fanOutAbort.signal.reason
2058
- if (signal?.aborted) throw signal.reason ?? new Error('aborted')
2059
- const sub = await handleSearch({ ...rest, query: q }, signal)
2060
- return `[${q}]\n${sub.text || '(no results)'}`
2061
- })),
2062
- deadlineRace,
2063
- ])
2064
- } finally {
2065
- clearTimeout(deadlineTimer)
2066
- }
2067
- const parts = settled
2068
- const header = dropped > 0
2069
- ? `note: ${dedup.length} queries received, ${queries.length} processed, ${dropped} dropped (cap ${QUERIES_CAP})\n\n`
2070
- : ''
2071
- return { text: header + parts.join('\n\n') }
2072
- }
2073
- const query = String(args.query ?? '').trim()
2074
- let period = String(args.period ?? '').trim() || undefined
2075
- // Period and sort are caller-supplied only. Lead is responsible for
2076
- // mapping vague time phrases / chronological intent into the period
2077
- // argument before calling; the engine does not infer them from query
2078
- // text.
2079
- const RECALL_LIMIT_CAP = 100
2080
- const RECALL_OFFSET_CAP = 500
2081
- const requestedLimit = Number(args.limit)
2082
- const requestedOffset = Number(args.offset)
2083
- let limit = Math.max(1, Number.isFinite(requestedLimit) ? requestedLimit : 10)
2084
- let offset = Math.max(0, Number.isFinite(requestedOffset) ? requestedOffset : 0)
2085
- const recallCapNotes = []
2086
- if (Number.isFinite(requestedLimit) && requestedLimit > RECALL_LIMIT_CAP) {
2087
- limit = RECALL_LIMIT_CAP
2088
- recallCapNotes.push(`limit capped to ${RECALL_LIMIT_CAP} (requested ${requestedLimit})`)
2089
- } else {
2090
- limit = Math.min(RECALL_LIMIT_CAP, limit)
2091
- }
2092
- if (Number.isFinite(requestedOffset) && requestedOffset > RECALL_OFFSET_CAP) {
2093
- offset = RECALL_OFFSET_CAP
2094
- recallCapNotes.push(`offset capped to ${RECALL_OFFSET_CAP} (requested ${requestedOffset})`)
2095
- } else {
2096
- offset = Math.min(RECALL_OFFSET_CAP, offset)
2097
- }
2098
- const recallCapPrefix = recallCapNotes.length ? `${recallCapNotes.join('; ')}\n` : ''
2099
- const sort = args.sort != null ? String(args.sort) : 'importance'
2100
- // Chunk content is the primary recall output. Members default to true so
2101
- // callers receive the raw chunk leaves (the cycle1-produced semantic
2102
- // chunks) rather than just the root's cycle2-compressed summary line.
2103
- // Explicit `includeMembers:false` keeps the legacy summary-only mode.
2104
- const includeMembers = args.includeMembers !== false
2105
- const includeRaw = Boolean(args.includeRaw)
2106
- const includeArchived = args.includeArchived !== false
2107
- const category = args.category
2108
- const temporal = parsePeriod(period, Boolean(query))
2109
-
2110
- // Derive projectScope from caller cwd (falls back to process.cwd()).
2111
- // Explicit args.projectScope (string) takes priority so callers can
2112
- // override to 'all', 'common', or a specific slug.
2113
- let projectScope
2114
- if (typeof args.projectScope === 'string' && args.projectScope) {
2115
- projectScope = args.projectScope
2116
- } else {
2117
- const projectId = resolveProjectScope(typeof args.cwd === 'string' && args.cwd ? args.cwd : null)
2118
- projectScope = projectId !== null ? projectId : 'common'
2119
- }
2120
-
2121
- // R11 reviewer M4: calendar-bounded periods disable freshness decay
2122
- // so within-period ranking doesn't downgrade Mon entries vs Sun.
2123
- const CALENDAR_PERIODS = new Set(['yesterday', 'today', 'this_week', 'last_week'])
2124
- const isCalendarPeriod = period != null
2125
- && (CALENDAR_PERIODS.has(period) || /^\d{4}-\d{2}-\d{2}/.test(period))
2126
- const applyFreshness = !isCalendarPeriod
2127
-
2128
- if (query) {
2129
- const _t0 = Date.now()
2130
- if (signal?.aborted) throw signal.reason ?? new Error('aborted')
2131
- let queryVector = null
2132
- if (isEmbeddingModelReady()) {
2133
- queryVector = await embedText(query)
2134
- } else {
2135
- const now = Date.now()
2136
- if (now - _embeddingColdRecallLogAt > 10_000) {
2137
- _embeddingColdRecallLogAt = now
2138
- __mixdogMemoryLog('[recall] embedding model cold; returning lexical results while background warmup continues\n')
2139
- }
2140
- if (embeddingWarmupCanStart()) {
2141
- void warmupEmbeddingProvider().catch((err) => {
2142
- __mixdogMemoryLog(`[memory-service] embedding warmup after cold recall failed: ${err?.message || err}\n`)
2143
- })
2144
- }
2145
- }
2146
- if (signal?.aborted) throw signal.reason ?? new Error('aborted')
2147
- const _t1 = Date.now()
2148
- if (process.env.MIXDOG_DEBUG_MEMORY) {
2149
- __mixdogMemoryLog(`[search-time] embed=${_t1 - _t0}ms query="${query.slice(0, 60)}"\n`)
2150
- }
2151
- // Push ts and status filters into the hybrid candidate query so FTS / vec
2152
- // rank inside the requested window, not the whole tree. The previous post-
2153
- // filter approach silently emptied results when relevant matches sat
2154
- // outside `period` (default 30d) and could not bubble through.
2155
- // Recall is history-first: archived roots hold most prior work. Callers
2156
- // that need only live invariants can pass includeArchived:false.
2157
- const excludeStatuses = includeArchived ? [] : ['archived']
2158
- const results = await searchRelevantHybrid(db, query, {
2159
- limit: limit + offset,
2160
- queryVector: Array.isArray(queryVector) ? queryVector : null,
2161
- includeMembers,
2162
- ts_from: temporal?.startMs,
2163
- ts_to: temporal?.endMs,
2164
- applyFreshness,
2165
- projectScope,
2166
- category,
2167
- excludeStatuses,
2168
- // useHotActive was set to true here so default (no-period) calls
2169
- // routed through the mv_hot_active materialized view — a narrow
2170
- // active-roots-only pool. Live usage is dominated by vague-time
2171
- // queries ("recent / lately") where Lead callers omit the period
2172
- // filter, leaving the MV as the sole source. That hid every
2173
- // orphan leaf and every pending root — fresh work from the last 1-60
2174
- // minutes never surfaced. Now that the entries-table CTE legs run
2175
- // against broaden HNSW + GIN trgm partial indexes (the
2176
- // is_root=1 predicate was dropped in the same revision), the
2177
- // entries path is fast enough (1-2 ms ANN on ~10K rows, O(log N)
2178
- // through 1M+) to be the single source of truth. The MV is left in
2179
- // place for now but no longer routed to from search; cycle2 may stop
2180
- // refreshing it in a follow-up commit once nothing else reads it.
2181
- useHotActive: false,
2182
- })
2183
- let filtered = results
2184
- if (sort === 'date') {
2185
- // R11 reviewer L5: NaN guard — entries with null/undefined ts default
2186
- // to 0 so the comparator stays numeric and stable.
2187
- filtered.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0))
2188
- } else {
2189
- filtered.sort((a, b) => {
2190
- const sa = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0 }
2191
- return (sa(b.retrievalScore ?? b.rrf ?? 0) - sa(a.retrievalScore ?? a.rrf ?? 0))
2192
- || (sa(b.score ?? 0) - sa(a.score ?? 0))
2193
- || (sa(b.ts ?? 0) - sa(a.ts ?? 0))
2194
- || (Number(a.id ?? 0) - Number(b.id ?? 0))
2195
- })
2196
- }
2197
- if (includeRaw) {
2198
- // Raw rows (chunk_root IS NULL) carry no retrievalScore, so a naive
2199
- // append-after-hybrid under sort=importance always lands them past
2200
- // slice(offset, offset+limit) once the hybrid pool exceeds one page —
2201
- // every page beyond the first silently drops them. Fetch a wider raw
2202
- // window (bounded like the hybrid candidate pool) and spread the
2203
- // fetched raw rows evenly across the WHOLE hybrid list before slicing,
2204
- // so every offset page gets its proportional share instead of only
2205
- // page 0. Same projectScope/ts window as the hybrid leg — filter
2206
- // parity (item 3) is deliberate, not accidental.
2207
- const RAW_FETCH = Math.min(500, Math.max(20, limit + offset))
2208
- const rawRows = await readRawRowsInWindow(
2209
- db,
2210
- temporal?.startMs ?? null,
2211
- temporal?.endMs ?? Date.now(),
2212
- RAW_FETCH,
2213
- { projectScope },
2214
- )
2215
- const seenIds = new Set(filtered.map(r => r.id))
2216
- const newRaw = rawRows.filter(r => !seenIds.has(r.id))
2217
- if (sort === 'date') {
2218
- for (const r of newRaw) filtered.push(r)
2219
- filtered.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0))
2220
- } else {
2221
- // sort=importance: interleave raw rows at a fixed stride through the
2222
- // full (pre-slice) hybrid list instead of appending at the tail, so
2223
- // offset > 0 pages also draw from the raw pool proportionally.
2224
- filtered = interleaveRawRows(filtered, newRaw)
2225
- }
2226
- }
2227
- const coreRows = await recallCoreRows(query, { projectScope, category, limit })
2228
- if (coreRows.length > 0) {
2229
- filtered = [...coreRows, ...filtered]
2230
- }
2231
- const sliced = filtered.slice(offset, offset + limit)
2232
- const _t2 = Date.now()
2233
- if (process.env.MIXDOG_DEBUG_MEMORY) {
2234
- __mixdogMemoryLog(`[search-time] hybrid+sort+raw=${_t2 - _t1}ms rows=${filtered.length} sliced=${sliced.length}\n`)
2235
- }
2236
- // Emit a recall trace event so getTraceWithEntries() can correlate
2237
- // this search with the top-ranked memory entry. One event per
2238
- // handleSearch call (not per returned row) — cheapest meaningful link.
2239
- // parent_span_id left null: the agent-side span id is only known after
2240
- // the DB insert of the loop/tool events, which happens async on the
2241
- // client side and is not available here.
2242
- if (_traceDb && filtered.length > 0) {
2243
- const topHit = filtered[0]
2244
- const topId = topHit?.id != null ? Number(topHit.id) : null
2245
- if (topId !== null && Number.isFinite(topId)) {
2246
- insertTraceEvents(_traceDb, [{
2247
- ts: Date.now(),
2248
- kind: 'recall',
2249
- entry_id: topId,
2250
- payload: { query: query.slice(0, 200), hit_count: filtered.length },
2251
- }]).catch(e => __mixdogMemoryLog(`[trace] insertTraceEvents error: ${e?.message}\n`))
2252
- }
2253
- }
2254
- const out = { text: recallCapPrefix + renderEntryLines(sliced) }
2255
- if (process.env.MIXDOG_DEBUG_MEMORY) {
2256
- __mixdogMemoryLog(`[search-time] render+trace=${Date.now() - _t2}ms total=${Date.now() - _t0}ms textLen=${out.text.length}\n`)
2257
- }
2258
- return out
2259
- }
2260
-
2261
- const filters = { limit: limit + offset }
2262
- if (temporal?.startMs != null) { filters.ts_from = temporal.startMs; filters.ts_to = temporal.endMs }
2263
- if (temporal?.mode === 'last' && _bootTimestamp) {
2264
- filters.ts_to = _bootTimestamp - 1
2265
- }
2266
- filters.projectScope = projectScope
2267
- if (category != null) filters.category = category
2268
- filters.sort = sort
2269
- if (!includeArchived) filters.excludeStatuses = ['archived']
2270
- if (includeMembers) filters.includeMembers = true
2271
- const rows = await retrieveEntries(db, filters)
2272
- const sliced = rows.slice(offset, offset + limit)
2273
- return { text: recallCapPrefix + renderEntryLines(sliced) }
2274
- }
2275
-
2276
- function renderEntryLines(rows) {
2277
- if (!rows || rows.length === 0) return '(no results)'
2278
- const lines = []
2279
- // Bound total emitted lines (roots x members) so a many-member recall can't
2280
- // inject unbounded output. Per-line content is already capped at 1000 chars;
2281
- // this caps the line COUNT. Narrow the query (limit/period/projectScope) for more.
2282
- const RECALL_LINE_CAP = 200
2283
- let _capped = false
2284
- outer:
2285
- for (const r of rows) {
2286
- const hasMembers = Array.isArray(r.members) && r.members.length > 0
2287
- if (hasMembers) {
2288
- // Chunks present: emit each member as its own line. Root row is a
2289
- // grouping artifact for retrieval — the caller wants the chunk
2290
- // content (cycle1 raw), not the cycle2-compressed summary.
2291
- for (const m of r.members) {
2292
- if (lines.length >= RECALL_LINE_CAP) { _capped = true; break outer }
2293
- const mTs = formatTs(m.ts)
2294
- const role = m.role === 'user' ? 'u' : m.role === 'assistant' ? 'a' : (m.role || '?')
2295
- const content = cleanMemoryText(String(m.content ?? '')).slice(0, 1000)
2296
- lines.push(`[${mTs}] ${role}: ${content} #${m.id}`)
2297
- }
2298
- } else {
2299
- if (lines.length >= RECALL_LINE_CAP) { _capped = true; break }
2300
- // No chunks (root not yet chunked by cycle1, or orphan leaf): emit
2301
- // the row itself in the same shape. element/summary fall back to
2302
- // raw content when both are absent.
2303
- const ts = formatTs(r.ts)
2304
- const element = r.element ?? ''
2305
- const summary = r.summary ?? ''
2306
- // Standalone leaf rows (is_root=0, no parent chunks_root resolved
2307
- // into a `members` list) carry their u/a role just like inline
2308
- // chunk members — surface it so the format stays consistent across
2309
- // the two emission paths.
2310
- const rolePrefix = r.is_root === 0 && r.role
2311
- ? (r.role === 'user' ? 'u: ' : r.role === 'assistant' ? 'a: ' : `${r.role}: `)
2312
- : ''
2313
- const body = element || summary
2314
- ? `${element}${summary ? ' — ' + summary : ''}`
2315
- : cleanMemoryText(String(r.content ?? '')).slice(0, 1000)
2316
- lines.push(`[${ts}] ${rolePrefix}${body.slice(0, 1000)} #${r.id}`)
2317
- }
2318
- }
2319
- if (_capped) lines.push(`[recall truncated — showing first ${RECALL_LINE_CAP} lines; narrow the query (limit/period/projectScope) for the rest]`)
2320
- return lines.join('\n')
2321
- }
2322
-
2323
- async function dumpSessionRootChunks(args = {}) {
2324
- const sessionId = String(args.sessionId || args.session_id || '').trim()
2325
- if (!sessionId) return { text: '(no current session)', rows: [], chunks: [], isError: true }
2326
- const includeRaw = args.includeRaw !== false
2327
- const limit = Math.max(1, Math.min(1000, Number(args.limit) || 1000))
2328
- const rootRows = (await db.query(`
2329
- SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
2330
- element, category, summary, status, score, last_seen_at, project_id
2331
- FROM entries
2332
- WHERE session_id = $1 AND is_root = 1
2333
- ORDER BY COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
2334
- LIMIT $2
2335
- `, [sessionId, limit])).rows
2336
- const roots = rootRows.map((r) => ({ ...r, members: [] }))
2337
- const rootIds = roots.map((r) => Number(r.id)).filter((id) => Number.isFinite(id))
2338
- const memberRows = rootIds.length > 0
2339
- ? (await db.query(`
2340
- SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root, project_id
2341
- FROM entries
2342
- WHERE chunk_root = ANY($1::bigint[]) AND is_root = 0
2343
- ORDER BY chunk_root ASC, COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
2344
- `, [rootIds])).rows
2345
- : []
2346
- const byRoot = new Map(roots.map((r) => [Number(r.id), r]))
2347
- for (const m of memberRows) {
2348
- const root = byRoot.get(Number(m.chunk_root))
2349
- if (root) root.members.push(m)
2350
- }
2351
- let rawRows = []
2352
- if (includeRaw) {
2353
- rawRows = (await db.query(`
2354
- SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root, project_id
2355
- FROM entries
2356
- WHERE session_id = $1
2357
- AND is_root = 0
2358
- AND (chunk_root IS NULL OR chunk_root = id)
2359
- ORDER BY COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
2360
- LIMIT $2
2361
- `, [sessionId, limit])).rows
2362
- }
2363
- const chunks = []
2364
- for (const root of roots) {
2365
- const memberText = root.members
2366
- .map((m) => `${m.role === 'assistant' ? 'assistant' : m.role === 'user' ? 'user' : m.role}: ${cleanMemoryText(String(m.content ?? ''))}`)
2367
- .filter(Boolean)
2368
- .join('\n')
2369
- const summary = [root.element, root.summary].map((v) => String(v || '').trim()).filter(Boolean).join(' — ')
2370
- chunks.push({
2371
- id: Number(root.id),
2372
- kind: 'root',
2373
- ts: Number(root.ts) || 0,
2374
- sourceTurn: root.source_turn ?? null,
2375
- category: root.category || null,
2376
- summary,
2377
- text: memberText || cleanMemoryText(String(root.content ?? '')),
2378
- members: root.members,
2379
- })
2380
- }
2381
- for (const raw of rawRows) {
2382
- chunks.push({
2383
- id: Number(raw.id),
2384
- kind: 'raw',
2385
- chunkRoot: raw.chunk_root ?? null,
2386
- ts: Number(raw.ts) || 0,
2387
- sourceTurn: raw.source_turn ?? null,
2388
- category: null,
2389
- summary: '',
2390
- text: `${raw.role === 'assistant' ? 'assistant' : raw.role === 'user' ? 'user' : raw.role}: ${cleanMemoryText(String(raw.content ?? ''))}`,
2391
- members: [],
2392
- })
2393
- }
2394
- chunks.sort((a, b) => {
2395
- const at = Number.isFinite(Number(a.sourceTurn)) ? Number(a.sourceTurn) : 2147483647
2396
- const bt = Number.isFinite(Number(b.sourceTurn)) ? Number(b.sourceTurn) : 2147483647
2397
- return (at - bt) || ((a.ts || 0) - (b.ts || 0)) || ((a.id || 0) - (b.id || 0))
2398
- })
2399
- const text = chunks.length
2400
- ? chunks.map((chunk, idx) => {
2401
- const label = chunk.kind === 'root'
2402
- ? `# chunk ${idx + 1} root=${chunk.id}${chunk.category ? ` category=${chunk.category}` : ''}`
2403
- : `${chunk.chunkRoot == null ? '# raw_pending' : '# raw_terminal'} ${idx + 1} id=${chunk.id}`
2404
- const summary = chunk.summary ? `summary: ${chunk.summary}\n` : ''
2405
- return `${label}\n${summary}${chunk.text}`.trim()
2406
- }).join('\n\n')
2407
- : '(no results)'
2408
- return { text, rows: [...roots, ...rawRows], chunks }
2409
- }
2410
-
2411
- async function entryStats() {
2412
- return await db.transaction(async (tx) => {
2413
- const total = (await tx.query(`SELECT COUNT(*) c FROM entries`)).rows[0].c
2414
- const roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1`)).rows[0].c
2415
- const active_roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'active'`)).rows[0].c
2416
- const archived_roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'archived'`)).rows[0].c
2417
- const unchunked_leaves = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE chunk_root IS NULL`)).rows[0].c
2418
- const cycle2_pending_roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'pending'`)).rows[0].c
2419
- const core_entries = (await tx.query(`SELECT COUNT(*) c FROM core_entries`)).rows[0].c
2420
- const core_embed_null = (await tx.query(`SELECT COUNT(*) c FROM core_entries WHERE embedding IS NULL`)).rows[0].c
2421
- 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
2422
- const active_core_summary_missing = (await tx.query(`
2423
- SELECT COUNT(*) c
2424
- FROM entries
2425
- WHERE is_root = 1
2426
- AND status = 'active'
2427
- AND (core_summary IS NULL OR btrim(core_summary) = '')
2428
- `)).rows[0].c
2429
- const byStatus = (await tx.query(`SELECT status, COUNT(*) c FROM entries WHERE is_root = 1 GROUP BY status`)).rows
2430
- 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
2431
- const mvRows = (await tx.query(`SELECT relispopulated FROM pg_class WHERE relname = 'mv_hot_active' LIMIT 1`)).rows
2432
- const mv_hot_active_populated = mvRows.length ? Boolean(mvRows[0].relispopulated) : null
2433
- return {
2434
- total, roots, active_roots, archived_roots, unchunked_leaves, cycle2_pending_roots,
2435
- core_entries, core_embed_null, active_core_summaries, active_core_summary_missing,
2436
- mv_hot_active_populated,
2437
- byStatus, byCategory,
2438
- }
2439
- })
2440
- }
638
+ const __queryHandlers = createQueryHandlers({
639
+ getDb: () => db,
640
+ log: __mixdogMemoryLog,
641
+ resolveProjectScope,
642
+ embeddingWarmupCanStart,
643
+ getBootTimestamp: () => _bootTimestamp,
644
+ getTraceDb: () => _traceDb,
645
+ })
646
+ const {
647
+ readRawRowsInWindow,
648
+ recallSessionRows,
649
+ recallCoreRows,
650
+ handleSearch,
651
+ dumpSessionRootChunks,
652
+ entryStats,
653
+ } = __queryHandlers
2441
654
 
2442
655
  async function _handleMemCycle1(args, config, signal) {
2443
656
  const minBatchOverride = Number(args?.min_batch)
@@ -2631,7 +844,7 @@ async function _handleMemRebuild(args, config, signal) {
2631
844
  // then loop once more if one layer exposed another promise while awaiting.
2632
845
  const drainedCycle1Promises = new Set()
2633
846
  for (;;) {
2634
- const pendingCycle1Promises = [_cycle1InFlight, getInFlightCycle1(db)]
847
+ const pendingCycle1Promises = [_cycleScheduler.getCycle1InFlight(), getInFlightCycle1(db)]
2635
848
  .filter(p => p && !drainedCycle1Promises.has(p))
2636
849
  if (pendingCycle1Promises.length === 0) break
2637
850
  for (const pendingCycle1 of pendingCycle1Promises) {
@@ -2951,11 +1164,57 @@ async function handleMemoryAction(args, signal) {
2951
1164
 
2952
1165
  if (action === 'core') {
2953
1166
  const op = String(args.op ?? '').trim().toLowerCase()
2954
- if (!['add', 'edit', 'delete', 'list'].includes(op)) {
2955
- return { text: 'core requires op: "add" | "edit" | "delete" | "list"', isError: true }
1167
+ if (!['add', 'edit', 'delete', 'list', 'candidates', 'promote', 'dismiss'].includes(op)) {
1168
+ return { text: 'core requires op: "add" | "edit" | "delete" | "list" | "candidates" | "promote" | "dismiss"', isError: true }
2956
1169
  }
2957
1170
  const dataDir = (typeof DATA_DIR === 'string' ? DATA_DIR : resolvePluginData())
2958
1171
  if (!dataDir) return { text: 'core: memory data dir is not initialized', isError: true }
1172
+ // Core-candidate promotion pipeline (proposal mode). The candidate flag
1173
+ // lives on generated `entries`, which carry a project_id, so these ops MUST
1174
+ // be project-scoped just like add/edit/delete — an unscoped listing/promote
1175
+ // would leak candidates across projects. project_id resolution mirrors the
1176
+ // block below: 'common'/null → COMMON (project_id NULL), '*' → all pools
1177
+ // (candidates op only, same escape hatch as op:'list'). UI calls exactly
1178
+ // these op names.
1179
+ if (op === 'candidates' || op === 'promote' || op === 'dismiss') {
1180
+ const hasPid = Object.prototype.hasOwnProperty.call(args, 'project_id')
1181
+ const scope = (() => {
1182
+ if (!hasPid || args.project_id == null) return null
1183
+ const s = String(args.project_id).trim()
1184
+ if (s === '' || s.toLowerCase() === 'common') return null
1185
+ if (s === '*') return '*'
1186
+ return s
1187
+ })()
1188
+ try {
1189
+ if (op === 'candidates') {
1190
+ const list = await listCoreCandidates(dataDir, scope)
1191
+ if (list.length === 0) return { text: 'core candidates: none' }
1192
+ return {
1193
+ text: list.map(c =>
1194
+ // project=<pool> lets the UI thread project_id into the follow-up
1195
+ // promote/dismiss call — matters under project_id:'*' listing where
1196
+ // rows span pools. Uses the same COMMON/slug convention as op:'list'.
1197
+ `id=${c.id} project=${c.project_id == null ? 'COMMON' : c.project_id} [${c.category}] score=${c.score == null ? '-' : c.score.toFixed(2)} ${c.element} — ${String(c.summary || '').slice(0, 200)} (${c.reason})`,
1198
+ ).join('\n'),
1199
+ }
1200
+ }
1201
+ // promote/dismiss operate on a single id but are scope-guarded: the
1202
+ // candidate must belong to the resolved scope (or COMMON), never '*'.
1203
+ if (scope === '*') {
1204
+ return { text: `core ${op}: project_id "*" only valid for op="candidates"`, isError: true }
1205
+ }
1206
+ if (op === 'promote') {
1207
+ const entry = await promoteCoreCandidate(dataDir, args.id, { ...args, scope })
1208
+ const mergeNote = entry.merged_with ? ` (merged into core id=${entry.merged_with}, sim=${entry.sim})` : ''
1209
+ return { text: `core promoted candidate id=${args.id} → core id=${entry.id}${mergeNote}: [${entry.category}] ${entry.element}` }
1210
+ }
1211
+ // dismiss
1212
+ const removed = await dismissCoreCandidate(dataDir, args.id, { scope })
1213
+ return { text: `core candidate dismissed (id=${removed.id}): [${removed.category}] ${removed.element}` }
1214
+ } catch (e) {
1215
+ return { text: `core ${op} failed: ${e.message}`, isError: true }
1216
+ }
1217
+ }
2959
1218
  // Local trim helper — the manage-block trimOrNull at :1807 is scoped to
2960
1219
  // that branch and unreachable from here.
2961
1220
  // Normalize project_id: 'common' (case-insensitive) or null → null (COMMON pool); non-empty string → slug.
@@ -3190,6 +1449,9 @@ async function handleToolCall(name, args, signal) {
3190
1449
  ...(a.sessionId ? { sessionId: a.sessionId } : {}),
3191
1450
  ...(a.session_id ? { session_id: a.session_id } : {}),
3192
1451
  ...(a.currentSession !== undefined ? { currentSession: a.currentSession } : {}),
1452
+ // Hint only — never a filter. Marks the caller's own session as
1453
+ // "(current)" in the multi-session grouped browse output.
1454
+ ...(a.currentSessionId ? { currentSessionId: a.currentSessionId } : {}),
3193
1455
  }
3194
1456
  const result = await handleSearch(searchArgs, signal)
3195
1457
  return { ...result, content: [{ type: 'text', text: result.text }], isError: result.isError || false }
@@ -3222,37 +1484,6 @@ function createHttpMcpServer() {
3222
1484
  return s
3223
1485
  }
3224
1486
 
3225
- function readBody(req) {
3226
- return new Promise((resolve, reject) => {
3227
- const chunks = []
3228
- req.on('data', c => chunks.push(c))
3229
- req.on('end', () => {
3230
- const raw = Buffer.concat(chunks).toString('utf8').trim()
3231
- if (!raw) { resolve({}); return }
3232
- try { resolve(JSON.parse(raw)) }
3233
- catch (error) {
3234
- const e = new Error(`invalid JSON body: ${error.message}`)
3235
- e.statusCode = 400
3236
- reject(e)
3237
- }
3238
- })
3239
- req.on('error', reject)
3240
- })
3241
- }
3242
-
3243
- function sendJson(res, data, status = 200) {
3244
- const body = JSON.stringify(data)
3245
- res.writeHead(status, {
3246
- 'Content-Type': 'application/json; charset=utf-8',
3247
- 'Content-Length': Buffer.byteLength(body),
3248
- })
3249
- res.end(body)
3250
- }
3251
-
3252
- function sendError(res, msg, status = 500) {
3253
- sendJson(res, { error: msg }, status)
3254
- }
3255
-
3256
1487
  async function awaitRuntimeReadyForHttp(res) {
3257
1488
  if (_initialized) return true
3258
1489
  if (!_initPromise) {
@@ -3268,29 +1499,6 @@ async function awaitRuntimeReadyForHttp(res) {
3268
1499
  }
3269
1500
  }
3270
1501
 
3271
- // Origin/Referer guard for /admin/* mutation routes. Memory-service binds
3272
- // 127.0.0.1, but browser DNS-rebinding or a stray cross-origin fetch could
3273
- // still reach destructive endpoints (purge, backfill, entry mutations).
3274
- // Server-to-server callers (setup-server, hooks) issue raw http.request
3275
- // without a browser Origin/Referer, so absent headers pass; any non-loopback
3276
- // Origin/Referer is rejected. Mirrors setup-server.mjs isAllowedOrigin.
3277
- function isLocalOrigin(req) {
3278
- const LOOP = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i
3279
- const origin = req.headers.origin || ''
3280
- const referer = req.headers.referer || ''
3281
- if (origin && !LOOP.test(origin)) return false
3282
- if (referer && !LOOP.test(referer)) return false
3283
- return true
3284
- }
3285
-
3286
- function normalizeCoreProjectId(value, { allowStar = false } = {}) {
3287
- if (value == null) return null
3288
- const s = String(value).trim()
3289
- if (!s || s.toLowerCase() === 'common') return null
3290
- if (allowStar && s === '*') return '*'
3291
- return s
3292
- }
3293
-
3294
1502
  async function buildSessionCoreMemoryPayload(cwd) {
3295
1503
  const projectId = resolveProjectScope(typeof cwd === 'string' && cwd ? cwd : null)
3296
1504
  const generatedScopeClause = projectId !== null
@@ -3373,6 +1581,9 @@ const httpServer = http.createServer(async (req, res) => {
3373
1581
  active_core_summaries: stats.active_core_summaries,
3374
1582
  active_core_summary_missing: stats.active_core_summary_missing,
3375
1583
  mv_hot_active_populated: stats.mv_hot_active_populated,
1584
+ cycle_running: _cycleScheduler.getCycleRunning(),
1585
+ cycle_health: _cycleScheduler.getCycleHealth(),
1586
+ cycle_backlog: _cycleScheduler.getCycleBacklogSnapshot(),
3376
1587
  })
3377
1588
  } catch (e) { sendError(res, e.message) }
3378
1589
  return
@@ -3956,7 +2167,7 @@ const httpServer = http.createServer(async (req, res) => {
3956
2167
  }
3957
2168
  const fileSize = stat.size
3958
2169
  await ingestTranscriptFile(filePath, { cwd: body.cwd })
3959
- const off = _transcriptOffsets.get(filePath)
2170
+ const off = _transcriptIngest.getOffset(filePath)
3960
2171
  const offsetBytes = off && Number.isFinite(off.bytes) ? off.bytes : 0
3961
2172
  const complete = offsetBytes >= fileSize
3962
2173
  sendJson(res, { ok: true, offsetBytes, fileSize, complete })
@@ -4018,7 +2229,7 @@ export async function stop() {
4018
2229
  }
4019
2230
  _periodicAdvertiseInstalled = false
4020
2231
  _currentAdvertisedPort = null
4021
- _pendingEmbeddingWarmup = null
2232
+ _embeddingWarmup.reset()
4022
2233
  if (_idleShutdownTimer) {
4023
2234
  try { clearTimeout(_idleShutdownTimer) } catch {}
4024
2235
  _idleShutdownTimer = null
@@ -4096,11 +2307,8 @@ export async function stop() {
4096
2307
  _initialized = false
4097
2308
  _initPromise = null
4098
2309
  _bootTimestamp = null
4099
- _transcriptOffsets = new Map()
4100
- _cycle1InFlight = null
4101
- _cycle2InFlight = false
4102
- _cycle3InFlight = false
4103
- _checkCyclesInFlight = false
2310
+ _transcriptIngest.resetOffsets()
2311
+ _cycleScheduler.resetInFlight()
4104
2312
  releaseLock()
4105
2313
  })().finally(() => {
4106
2314
  _stopPromise = null