mixdog 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (285) hide show
  1. package/README.md +47 -23
  2. package/package.json +33 -27
  3. package/scripts/_test-folder-dialog.mjs +30 -0
  4. package/scripts/agent-parallel-smoke.mjs +388 -0
  5. package/scripts/agent-tag-reuse-smoke.mjs +183 -0
  6. package/scripts/background-task-meta-smoke.mjs +38 -0
  7. package/scripts/boot-smoke.mjs +52 -9
  8. package/scripts/build-runtime-linux.sh +348 -0
  9. package/scripts/build-runtime-macos.sh +217 -0
  10. package/scripts/build-runtime-windows.ps1 +242 -0
  11. package/scripts/compact-active-turn-test.mjs +68 -0
  12. package/scripts/compact-smoke.mjs +859 -129
  13. package/scripts/compact-trigger-migration-smoke.mjs +187 -0
  14. package/scripts/fix-brief-fn.mjs +35 -0
  15. package/scripts/fix-format-tool-surface.mjs +24 -0
  16. package/scripts/fix-tool-exec-visible.mjs +42 -0
  17. package/scripts/generate-runtime-manifest.mjs +166 -0
  18. package/scripts/hook-bus-test.mjs +330 -0
  19. package/scripts/lead-workflow-smoke.mjs +33 -39
  20. package/scripts/live-worker-smoke.mjs +43 -37
  21. package/scripts/llm-trace-summary.mjs +315 -0
  22. package/scripts/memory-meta-concurrency-test.mjs +20 -0
  23. package/scripts/output-style-smoke.mjs +56 -15
  24. package/scripts/parent-abort-link-test.mjs +44 -0
  25. package/scripts/patch-agent-brief.mjs +48 -0
  26. package/scripts/patch-app.mjs +21 -0
  27. package/scripts/patch-app2.mjs +18 -0
  28. package/scripts/patch-dist-brief.mjs +96 -0
  29. package/scripts/patch-tool-exec.mjs +70 -0
  30. package/scripts/pretool-ask-runtime-test.mjs +54 -0
  31. package/scripts/provider-toolcall-test.mjs +376 -0
  32. package/scripts/reactive-compact-persist-smoke.mjs +124 -0
  33. package/scripts/sanitize-tool-pairs-test.mjs +260 -0
  34. package/scripts/session-context-bench.mjs +344 -0
  35. package/scripts/session-ingest-smoke.mjs +177 -0
  36. package/scripts/set-effort-config-test.mjs +41 -0
  37. package/scripts/smoke-runtime-negative.ps1 +106 -0
  38. package/scripts/smoke-runtime-negative.sh +97 -0
  39. package/scripts/smoke.mjs +25 -0
  40. package/scripts/tool-result-hook-test.mjs +48 -0
  41. package/scripts/tool-smoke.mjs +1223 -95
  42. package/scripts/toolcall-args-test.mjs +150 -0
  43. package/scripts/tui-background-failure-smoke.mjs +73 -0
  44. package/scripts/usage-metrics-epoch-smoke.mjs +114 -0
  45. package/src/agents/debugger/AGENT.md +8 -0
  46. package/src/agents/explore/AGENT.md +4 -0
  47. package/src/agents/heavy-worker/AGENT.md +9 -3
  48. package/src/agents/maintainer/AGENT.md +4 -0
  49. package/src/agents/reviewer/AGENT.md +8 -0
  50. package/src/agents/scheduler-task/AGENT.md +12 -0
  51. package/src/agents/scheduler-task/agent.json +6 -0
  52. package/src/agents/webhook-handler/AGENT.md +12 -0
  53. package/src/agents/webhook-handler/agent.json +6 -0
  54. package/src/agents/worker/AGENT.md +9 -3
  55. package/src/app.mjs +77 -3
  56. package/src/defaults/hidden-roles.json +17 -12
  57. package/src/headless-role.mjs +117 -0
  58. package/src/help.mjs +30 -0
  59. package/src/hooks/lib/permission-evaluator.cjs +11 -475
  60. package/src/lib/keychain-cjs.cjs +9 -1
  61. package/src/lib/mixdog-debug.cjs +0 -7
  62. package/src/lib/rules-builder.cjs +240 -96
  63. package/src/lib/text-utils.cjs +1 -1
  64. package/src/mixdog-session-runtime.mjs +2325 -450
  65. package/src/output-styles/default.md +12 -28
  66. package/src/output-styles/extreme-simple.md +9 -6
  67. package/src/output-styles/simple.md +22 -9
  68. package/src/repl.mjs +118 -59
  69. package/src/rules/agent/00-common.md +15 -0
  70. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  71. package/src/rules/agent/30-explorer.md +22 -0
  72. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  73. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  75. package/src/rules/lead/01-general.md +9 -5
  76. package/src/rules/lead/04-workflow.md +51 -12
  77. package/src/rules/lead/lead-tool.md +6 -0
  78. package/src/rules/shared/01-tool.md +12 -1
  79. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  80. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  81. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  82. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  83. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  85. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  86. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  87. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  88. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  89. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  90. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  91. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  92. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  93. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  94. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +377 -243
  95. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +146 -93
  96. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  97. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  99. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  100. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  101. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  102. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  103. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  104. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  106. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +221 -70
  107. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +477 -147
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -496
  109. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  110. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  111. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +31 -11
  112. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  113. package/src/runtime/agent/orchestrator/session/cache/post-edit-marks.mjs +4 -4
  114. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +1 -1
  115. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  116. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  117. package/src/runtime/agent/orchestrator/session/loop.mjs +851 -674
  118. package/src/runtime/agent/orchestrator/session/manager.mjs +1593 -466
  119. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  120. package/src/runtime/agent/orchestrator/session/store.mjs +291 -46
  121. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  122. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  123. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  124. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +34 -7
  125. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +1 -1
  126. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -0
  127. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +9 -9
  128. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  129. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  130. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  131. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  132. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  133. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  138. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  139. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  140. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  141. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  142. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  143. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  144. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +1 -1
  145. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  146. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  147. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  148. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  149. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  150. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  151. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  152. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  153. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  154. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  155. package/src/runtime/channels/index.mjs +152 -24
  156. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  157. package/src/runtime/channels/lib/session-discovery.mjs +3 -2
  158. package/src/runtime/channels/lib/tool-format.mjs +0 -2
  159. package/src/runtime/channels/lib/transcript-discovery.mjs +3 -2
  160. package/src/runtime/channels/lib/webhook.mjs +1 -1
  161. package/src/runtime/channels/tool-defs.mjs +29 -29
  162. package/src/runtime/memory/index.mjs +635 -107
  163. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  164. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  165. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  166. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  167. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  168. package/src/runtime/memory/lib/memory-cycle1.mjs +38 -17
  169. package/src/runtime/memory/lib/memory-cycle2.mjs +6 -7
  170. package/src/runtime/memory/lib/memory-cycle3.mjs +4 -4
  171. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -1
  172. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  173. package/src/runtime/memory/lib/memory.mjs +88 -9
  174. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  175. package/src/runtime/memory/lib/pg/adapter.mjs +15 -1
  176. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  177. package/src/runtime/memory/lib/runtime-fetcher.mjs +37 -3
  178. package/src/runtime/memory/lib/session-ingest.mjs +194 -0
  179. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  180. package/src/runtime/memory/tool-defs.mjs +46 -37
  181. package/src/runtime/search/index.mjs +102 -466
  182. package/src/runtime/search/lib/web-tools.mjs +45 -25
  183. package/src/runtime/search/tool-defs.mjs +16 -23
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/atomic-file.mjs +4 -3
  186. package/src/runtime/shared/background-tasks.mjs +122 -11
  187. package/src/runtime/shared/child-spawn-gate.mjs +145 -0
  188. package/src/runtime/shared/config.mjs +7 -4
  189. package/src/runtime/shared/err-text.mjs +131 -4
  190. package/src/runtime/shared/llm/cost.mjs +2 -2
  191. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  192. package/src/runtime/shared/llm/index.mjs +34 -11
  193. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  194. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  195. package/src/runtime/shared/singleton-owner.mjs +104 -0
  196. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  197. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  198. package/src/runtime/shared/tool-surface.mjs +624 -98
  199. package/src/runtime/shared/user-data-guard.mjs +0 -2
  200. package/src/standalone/agent-task-status.mjs +203 -0
  201. package/src/standalone/agent-task-status.test.mjs +76 -0
  202. package/src/standalone/agent-tool.mjs +1913 -0
  203. package/src/standalone/channel-worker.mjs +370 -14
  204. package/src/standalone/explore-tool.mjs +165 -70
  205. package/src/standalone/folder-dialog.mjs +314 -0
  206. package/src/standalone/hook-bus.mjs +898 -22
  207. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  208. package/src/standalone/projects.mjs +226 -0
  209. package/src/standalone/provider-admin.mjs +41 -24
  210. package/src/standalone/seeds.mjs +2 -69
  211. package/src/standalone/usage-dashboard.mjs +96 -8
  212. package/src/tui/App.jsx +4798 -2153
  213. package/src/tui/components/AnsiText.jsx +39 -28
  214. package/src/tui/components/ContextPanel.jsx +87 -29
  215. package/src/tui/components/Markdown.jsx +43 -77
  216. package/src/tui/components/MarkdownTable.jsx +9 -184
  217. package/src/tui/components/Message.jsx +28 -11
  218. package/src/tui/components/Picker.jsx +95 -56
  219. package/src/tui/components/PromptInput.jsx +367 -239
  220. package/src/tui/components/QueuedCommands.jsx +1 -1
  221. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  222. package/src/tui/components/Spinner.jsx +67 -38
  223. package/src/tui/components/StatusLine.jsx +606 -38
  224. package/src/tui/components/TextEntryPanel.jsx +128 -9
  225. package/src/tui/components/ToolExecution.jsx +617 -368
  226. package/src/tui/components/TurnDone.jsx +3 -3
  227. package/src/tui/components/UsagePanel.jsx +3 -5
  228. package/src/tui/components/tool-output-format.mjs +365 -0
  229. package/src/tui/components/tool-output-format.test.mjs +220 -0
  230. package/src/tui/dist/index.mjs +8915 -2418
  231. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  232. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  233. package/src/tui/engine.mjs +1455 -279
  234. package/src/tui/figures.mjs +21 -40
  235. package/src/tui/index.jsx +75 -31
  236. package/src/tui/input-editing.mjs +25 -0
  237. package/src/tui/markdown/format-token.mjs +511 -68
  238. package/src/tui/markdown/format-token.test.mjs +216 -0
  239. package/src/tui/markdown/render-ansi.mjs +94 -0
  240. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  241. package/src/tui/markdown/stream-fence.mjs +34 -0
  242. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  243. package/src/tui/markdown/table-layout.mjs +250 -0
  244. package/src/tui/paste-attachments.mjs +0 -7
  245. package/src/tui/spinner-verbs.mjs +1 -2
  246. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  247. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  248. package/src/tui/theme.mjs +746 -24
  249. package/src/tui/time-format.mjs +1 -1
  250. package/src/tui/transcript-tool-failures.mjs +67 -0
  251. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  252. package/src/ui/ansi.mjs +1 -2
  253. package/src/ui/markdown.mjs +85 -26
  254. package/src/ui/markdown.test.mjs +70 -0
  255. package/src/ui/model-display.mjs +121 -0
  256. package/src/ui/session-stats.mjs +44 -0
  257. package/src/ui/statusline-context-label.test.mjs +15 -0
  258. package/src/ui/statusline.mjs +386 -178
  259. package/src/ui/tool-card.mjs +3 -16
  260. package/src/vendor/statusline/bin/statusline-lib.mjs +8 -4
  261. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  262. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  263. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  264. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  265. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  266. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  267. package/src/workflows/default/WORKFLOW.md +12 -5
  268. package/src/workflows/default/workflow.json +0 -1
  269. package/src/workflows/solo/WORKFLOW.md +15 -0
  270. package/src/workflows/solo/workflow.json +7 -0
  271. package/vendor/ink/build/output.js +6 -1
  272. package/src/agents/scheduler-task.md +0 -3
  273. package/src/agents/web-researcher/AGENT.md +0 -3
  274. package/src/agents/web-researcher/agent.json +0 -6
  275. package/src/agents/webhook-handler.md +0 -3
  276. package/src/rules/bridge/00-common.md +0 -5
  277. package/src/rules/bridge/30-explorer.md +0 -4
  278. package/src/rules/lead/00-tool-lead.md +0 -5
  279. package/src/rules/shared/00-language.md +0 -3
  280. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  281. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  282. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  283. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  284. package/src/standalone/bridge-tool.mjs +0 -1414
  285. package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
@@ -81,13 +81,24 @@ import {
81
81
  isBootstrapComplete,
82
82
  getMetaValue,
83
83
  setMetaValue,
84
+ mergeMetaValue,
84
85
  cleanMemoryText,
85
86
  } from './lib/memory.mjs'
86
- import { configureEmbedding, embedText, embedTexts, getEmbeddingDims, getEmbeddingModelId, getKnownDimsForCurrentModel, primeEmbeddingDims, warmupEmbeddingProvider } from './lib/embedding-provider.mjs'
87
+ import {
88
+ normalizeIngestRole,
89
+ firstTextContent,
90
+ stableSessionSourceRef,
91
+ sessionMessageContent,
92
+ createIngestTurnAllocator,
93
+ } from './lib/session-ingest.mjs'
94
+ import { configureEmbedding, embedText, embedTexts, getEmbeddingDims, getEmbeddingDtype, getEmbeddingModelId, getKnownDimsForCurrentModel, isEmbeddingModelReady, primeEmbeddingDims, warmupEmbeddingProvider } from './lib/embedding-provider.mjs'
87
95
  import { startLlmWorker, stopLlmWorker } from './lib/llm-worker-host.mjs'
88
96
  import { runCycle1, runCycle2, runCycle3, runUnifiedGate, parseInterval, syncRootEmbedding, applySimpleStatus, applyUpdate, applyMerge, CYCLE2_ACTIVE_TARGET_CAP } from './lib/memory-cycle.mjs'
97
+ import { loadConfig as loadAgentConfig } from '../agent/orchestrator/config.mjs'
98
+ import { initProviders } from '../agent/orchestrator/providers/registry.mjs'
99
+ import { makeAgentDispatch } from '../agent/orchestrator/agent-runtime/agent-dispatch.mjs'
89
100
  import { getInFlightCycle1 } from './lib/memory-cycle1.mjs'
90
- import { claimAndMarkScheduledCycle, makeCycleRequestSignature, scheduleCoalescedCycleRetry } from './lib/memory-cycle-requests.mjs'
101
+ import { claimAndMarkScheduledCycle, makeCycleRequestSignature, resolveCoalesceMaxRetries, scheduleCoalescedCycleRetry } from './lib/memory-cycle-requests.mjs'
91
102
  import { searchRelevantHybrid } from './lib/memory-recall-store.mjs'
92
103
  import { fetchEntriesByIdsScoped } from './lib/memory-recall-id-patch.mjs'
93
104
  import { retrieveEntries } from './lib/memory-retrievers.mjs'
@@ -96,10 +107,12 @@ import { computeEntryScore } from './lib/memory-score.mjs'
96
107
  import { runFullBackfill } from './lib/memory-ops-policy.mjs'
97
108
  import { listCore, addCore, editCore, deleteCore, compactCoreIds, CORE_SUMMARY_MAX } from './lib/core-memory-store.mjs'
98
109
  import { resolveProjectId, resolveProjectScope } from './lib/project-id-resolver.mjs'
99
- import { openTraceDatabase, insertTraceEvents, enqueueTraceEvents, insertBridgeCalls, registerTraceExitDrain } from './lib/trace-store.mjs'
110
+ import { openTraceDatabase, closeTraceDatabase, insertTraceEvents, enqueueTraceEvents, insertAgentCalls, registerTraceExitDrain } from './lib/trace-store.mjs'
100
111
  import { updateJsonAtomicSync, writeJsonAtomicSync } from '../shared/atomic-file.mjs'
101
- import { resolvePluginData } from '../shared/plugin-paths.mjs'
102
- const DATA_DIR = process.argv[2] || resolvePluginData()
112
+ import { resolvePluginData, mixdogHome } from '../shared/plugin-paths.mjs'
113
+ const IS_MEMORY_ENTRY = !!process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
114
+ const USE_ARG_DATA_DIR = IS_MEMORY_ENTRY || process.env.MIXDOG_WORKER_MODE === '1'
115
+ const DATA_DIR = process.env.MIXDOG_DATA_DIR || (USE_ARG_DATA_DIR ? process.argv[2] : '') || resolvePluginData()
103
116
  if (!DATA_DIR) {
104
117
  __mixdogMemoryLog('[memory-service] memory data dir not set and no explicit data dir provided\n')
105
118
  process.exit(1)
@@ -113,6 +126,7 @@ const RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
113
126
  : path.join(os.tmpdir(), 'mixdog')
114
127
 
115
128
  let _periodicAdvertiseInstalled = false
129
+ let _periodicAdvertiseTimer = null
116
130
  // Track the most recently advertised port so the periodic tick re-reads it
117
131
  // every interval. Without this the setInterval closure binds the FIRST port
118
132
  // (the upstream we proxied to) and keeps re-advertising the dead upstream
@@ -124,7 +138,28 @@ function parsePositivePid(value) {
124
138
  return Number.isFinite(pid) && pid > 0 ? pid : null
125
139
  }
126
140
 
127
- const MEMORY_SERVER_PID = parsePositivePid(process.env.MIXDOG_SERVER_PID)
141
+ const MEMORY_SERVER_PID = parsePositivePid(process.env.MIXDOG_SERVER_PID) ?? process.pid
142
+ const MEMORY_DAEMON_MODE = process.env.MIXDOG_MEMORY_DAEMON === '1'
143
+ const MEMORY_IDLE_TTL_MS = Math.max(0, Number(process.env.MIXDOG_MEMORY_IDLE_TTL_MS) || 10 * 60_000)
144
+ let _idleShutdownTimer = null
145
+
146
+ function touchDaemonIdleTimer(reason = 'activity') {
147
+ if (!MEMORY_DAEMON_MODE || MEMORY_IDLE_TTL_MS <= 0) return
148
+ if (_idleShutdownTimer) {
149
+ try { clearTimeout(_idleShutdownTimer) } catch {}
150
+ _idleShutdownTimer = null
151
+ }
152
+ _idleShutdownTimer = setTimeout(() => {
153
+ __mixdogMemoryLog(`[memory-service] daemon idle TTL elapsed after ${reason}; shutting down\n`)
154
+ stop()
155
+ .then(() => process.exit(0))
156
+ .catch((e) => {
157
+ __mixdogMemoryLog(`[memory-service] daemon idle shutdown failed: ${e?.message || e}\n`)
158
+ process.exit(1)
159
+ })
160
+ }, MEMORY_IDLE_TTL_MS)
161
+ _idleShutdownTimer.unref?.()
162
+ }
128
163
 
129
164
  function advertiseMemoryPort(boundPort, attempt = 0) {
130
165
  if (!Number.isFinite(boundPort) || boundPort <= 0) return
@@ -156,18 +191,20 @@ function advertiseMemoryPort(boundPort, attempt = 0) {
156
191
  }, { compact: true, fsyncDir: true })
157
192
  if (!_periodicAdvertiseInstalled) {
158
193
  _periodicAdvertiseInstalled = true
159
- setInterval(() => {
194
+ _periodicAdvertiseTimer = setInterval(() => {
160
195
  try {
161
196
  if (_currentAdvertisedPort != null) {
162
197
  advertiseMemoryPort(_currentAdvertisedPort)
163
198
  }
164
199
  } catch {}
165
- }, 30_000).unref()
200
+ }, 30_000)
201
+ _periodicAdvertiseTimer.unref?.()
166
202
  }
167
203
  } catch (e) {
168
204
  const transient = e?.code === 'EPERM' || e?.code === 'EBUSY' || e?.code === 'EACCES'
169
205
  if (transient && attempt < 3) {
170
- setTimeout(() => advertiseMemoryPort(boundPort, attempt + 1), 50 * (attempt + 1))
206
+ const retryTimer = setTimeout(() => advertiseMemoryPort(boundPort, attempt + 1), 50 * (attempt + 1))
207
+ retryTimer.unref?.()
171
208
  return
172
209
  }
173
210
  __mixdogMemoryLog(`[memory-service] active-instance memory_port advertise failed: ${e?.message || e}\n`)
@@ -363,8 +400,12 @@ let _startupTimeout = null
363
400
  let _cycle1InFlight = null // shared cycle1 promise (outer coalesce layer)
364
401
  let _initialized = false
365
402
  let _initPromise = null
403
+ let _stopPromise = null
366
404
  let _bootTimestamp = null
367
405
  let _transcriptOffsets = new Map()
406
+ /** @type {Map<string, Promise<unknown>>} */
407
+ const _ingestTranscriptTails = new Map()
408
+ let _transcriptOffsetsPersistTail = Promise.resolve()
368
409
  // Boot-edge background warmup. ONNX session creation on the embedding worker
369
410
  // thread is CPU-heavy, so it must not overlap the worker's own init (DB open,
370
411
  // schema, cycle wiring). Previously this was gated behind a fixed setTimeout —
@@ -373,6 +414,7 @@ let _transcriptOffsets = new Map()
373
414
  // so it starts the instant boot's CPU-heavy work is done — no magic-number
374
415
  // delay. MIXDOG_EMBED_WARMUP=0 disables it (model loads lazily on first use).
375
416
  let _pendingEmbeddingWarmup = null
417
+ let _embeddingColdRecallLogAt = 0
376
418
 
377
419
  const TRANSCRIPT_OFFSETS_KEY = 'state.transcript_offsets'
378
420
  const CYCLE_LAST_RUN_KEY = 'state.cycle_last_run'
@@ -382,8 +424,50 @@ function embeddingWarmupEnabled() {
382
424
  return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no')
383
425
  }
384
426
 
427
+ function envFlagEnabled(name) {
428
+ const raw = String(process.env[name] ?? '').trim().toLowerCase()
429
+ return raw === '1' || raw === 'true' || raw === 'on' || raw === 'yes'
430
+ }
431
+
432
+ function memorySecondaryMode() {
433
+ return envFlagEnabled('MIXDOG_MEMORY_SECONDARY')
434
+ }
435
+
436
+ function embeddingWarmupCanStart() {
437
+ return embeddingWarmupEnabled() && !memorySecondaryMode()
438
+ }
439
+
440
+ function memoryLlmWorkerEnabled() {
441
+ return !memorySecondaryMode() && !envFlagEnabled('MIXDOG_MEMORY_DISABLE_LLM_WORKER')
442
+ }
443
+
444
+ function memoryCyclesEnabled() {
445
+ return !memorySecondaryMode() && !envFlagEnabled('MIXDOG_MEMORY_DISABLE_CYCLES')
446
+ }
447
+
448
+ function secondaryPgAdvertised() {
449
+ if (!memorySecondaryMode()) return true
450
+ const runtimeRoot = process.env.MIXDOG_RUNTIME_ROOT
451
+ ? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
452
+ : path.join(os.tmpdir(), 'mixdog')
453
+ try {
454
+ const cur = JSON.parse(fs.readFileSync(path.join(runtimeRoot, 'active-instance.json'), 'utf8'))
455
+ const port = Number(cur?.pg_port)
456
+ const pgdata = cur?.pg_pgdata ? path.resolve(String(cur.pg_pgdata)) : ''
457
+ return Number.isInteger(port) && port > 0 && pgdata === path.resolve(path.join(DATA_DIR, 'pgdata'))
458
+ } catch {
459
+ return false
460
+ }
461
+ }
462
+
463
+ function assertSecondaryPgAttachable() {
464
+ if (!secondaryPgAdvertised()) {
465
+ throw new Error('memory-service: secondary mode requires an existing primary PG instance')
466
+ }
467
+ }
468
+
385
469
  function scheduleBackgroundEmbeddingWarmup(metaPath, metaKey) {
386
- if (!embeddingWarmupEnabled()) return
470
+ if (!embeddingWarmupCanStart()) return
387
471
  // Queue the warmup; _initRuntime fires it once boot completes.
388
472
  _pendingEmbeddingWarmup = () => {
389
473
  warmupEmbeddingProvider()
@@ -397,7 +481,6 @@ function scheduleBackgroundEmbeddingWarmup(metaPath, metaKey) {
397
481
  })
398
482
  .catch(err => {
399
483
  __mixdogMemoryLog(`[memory-service] background warmup failed: ${err?.message || err}\n`)
400
- process.exit(1)
401
484
  })
402
485
  }
403
486
  }
@@ -429,7 +512,7 @@ async function _initStore() {
429
512
  const metaKey = {
430
513
  provider: embeddingConfig?.provider ?? null,
431
514
  model: getEmbeddingModelId(),
432
- dtype: embeddingConfig?.dtype ?? null,
515
+ dtype: getEmbeddingDtype(),
433
516
  }
434
517
  let dimsResolved = null
435
518
  try {
@@ -450,12 +533,17 @@ async function _initStore() {
450
533
 
451
534
  if (dimsResolved) {
452
535
  primeEmbeddingDims(dimsResolved)
536
+ assertSecondaryPgAttachable()
453
537
  db = await openDatabase(DATA_DIR, dimsResolved)
454
538
  scheduleBackgroundEmbeddingWarmup(EMBEDDING_META_PATH, metaKey)
455
539
  } else {
540
+ if (!embeddingWarmupCanStart()) {
541
+ throw new Error('memory-service: embedding dims unavailable while warmup is disabled')
542
+ }
456
543
  // Cold path: meta missed AND model not registered. Sequential.
457
544
  await warmupEmbeddingProvider()
458
545
  dimsResolved = Number(getEmbeddingDims())
546
+ assertSecondaryPgAttachable()
459
547
  db = await openDatabase(DATA_DIR, dimsResolved)
460
548
  try {
461
549
  writeJsonAtomicSync(EMBEDDING_META_PATH, { ...metaKey, dims: dimsResolved }, { lock: true })
@@ -467,7 +555,32 @@ async function _initStore() {
467
555
  if (!await isBootstrapComplete(db)) {
468
556
  throw new Error('memory-service: bootstrap not complete after openDatabase')
469
557
  }
470
- startLlmWorker()
558
+ if (memoryLlmWorkerEnabled()) {
559
+ startLlmWorker()
560
+ } else {
561
+ __mixdogMemoryLog('[memory-service] secondary mode; skipping llm worker\n')
562
+ }
563
+ // Initialize the in-process provider registry so cycle1 can run the agent dispatch
564
+ // LLM locally (makeAgentDispatch → session manager → provider.send). In
565
+ // standalone the memory worker runs as a detached HTTP daemon whose parent
566
+ // has disconnected IPC, so the legacy callAgentDispatch() IPC path is dead on
567
+ // arrival. Mirror the channels worker boot (channels/index.mjs:
568
+ // loadAgentConfig() + initProviders) so the registry is populated before any
569
+ // cycle1 dispatch. The gate MUST match _startCycle1Run's makeAgentDispatch
570
+ // injection condition: cycle1 may dispatch in-process whenever cycles are
571
+ // enabled OR the llm worker is enabled (both exclude secondary mode), so
572
+ // registering only under the llm-worker gate would leave a hole where
573
+ // MIXDOG_MEMORY_DISABLE_LLM_WORKER=1 + cycles enabled hits an empty registry
574
+ // and fails with "Provider not found". Non-fatal: a failure here is logged
575
+ // and cycle1's own callLlm surfaces the unresolved-provider error per call.
576
+ if (memoryCyclesEnabled() || memoryLlmWorkerEnabled()) {
577
+ try {
578
+ const agentCfg = loadAgentConfig()
579
+ await initProviders(agentCfg.providers || {})
580
+ } catch (e) {
581
+ process.stderr.write(`[memory-service] initProviders failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`)
582
+ }
583
+ }
471
584
  _bootTimestamp = Date.now()
472
585
  await loadTranscriptOffsets()
473
586
  }
@@ -483,12 +596,16 @@ async function loadTranscriptOffsets() {
483
596
  }
484
597
 
485
598
  async function persistTranscriptOffsets() {
486
- try {
487
- const obj = Object.fromEntries(_transcriptOffsets)
488
- await setMetaValue(db, TRANSCRIPT_OFFSETS_KEY, JSON.stringify(obj))
489
- } catch (e) {
490
- __mixdogMemoryLog(`[memory] persist transcript offsets failed: ${e.message}\n`)
491
- }
599
+ const run = _transcriptOffsetsPersistTail.catch(() => {}).then(async () => {
600
+ try {
601
+ const obj = Object.fromEntries(_transcriptOffsets)
602
+ await setMetaValue(db, TRANSCRIPT_OFFSETS_KEY, JSON.stringify(obj))
603
+ } catch (e) {
604
+ __mixdogMemoryLog(`[memory] persist transcript offsets failed: ${e.message}\n`)
605
+ }
606
+ })
607
+ _transcriptOffsetsPersistTail = run.catch(() => {})
608
+ return run
492
609
  }
493
610
 
494
611
  async function getCycleLastRun() {
@@ -522,9 +639,7 @@ async function getCycleLastRun() {
522
639
  }
523
640
 
524
641
  async function setCycleLastRun(kind, ts) {
525
- const cur = await getCycleLastRun()
526
- cur[kind] = ts
527
- await setMetaValue(db, CYCLE_LAST_RUN_KEY, JSON.stringify(cur))
642
+ await mergeMetaValue(db, CYCLE_LAST_RUN_KEY, { [kind]: ts })
528
643
  }
529
644
 
530
645
  // Raw-row priority lookup for narrow-window queries. Raw rows (is_root=0,
@@ -590,7 +705,7 @@ async function recallSessionRows(args = {}) {
590
705
  where.push(`(${clauses.join(' OR ')})`)
591
706
  }
592
707
  params.push(limit)
593
- const rows = (await db.query(`
708
+ let rows = (await db.query(`
594
709
  SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
595
710
  element, category, summary, status, score, last_seen_at, project_id
596
711
  FROM entries
@@ -598,40 +713,133 @@ async function recallSessionRows(args = {}) {
598
713
  ORDER BY ts DESC, id DESC
599
714
  LIMIT $${params.length}
600
715
  `, params)).rows
716
+ if (rows.length < limit) {
717
+ const seen = new Set(rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id)))
718
+ const fillLimit = Math.max(0, limit - rows.length)
719
+ const fillRows = fillLimit > 0
720
+ ? (await db.query(`
721
+ SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
722
+ element, category, summary, status, score, last_seen_at, project_id
723
+ FROM entries
724
+ WHERE session_id = $1
725
+ AND id <> ALL($2::bigint[])
726
+ ORDER BY ts DESC, id DESC
727
+ LIMIT $3
728
+ `, [sessionId, [...seen], fillLimit])).rows
729
+ : []
730
+ if (fillRows.length > 0) rows = [...rows, ...fillRows]
731
+ }
732
+ if (args.includeMembers === true) {
733
+ const rootIds = rows
734
+ .filter((row) => Number(row.is_root) === 1)
735
+ .map((row) => Number(row.id))
736
+ .filter((id) => Number.isFinite(id))
737
+ if (rootIds.length > 0) {
738
+ const members = (await db.query(`
739
+ SELECT id, ts, role, content, session_id, source_turn, project_id, chunk_root
740
+ FROM entries
741
+ WHERE chunk_root = ANY($1::bigint[]) AND is_root = 0
742
+ ORDER BY chunk_root ASC, COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
743
+ `, [rootIds])).rows
744
+ const byRoot = new Map(rootIds.map((id) => [id, []]))
745
+ for (const member of members) {
746
+ const root = Number(member.chunk_root)
747
+ if (byRoot.has(root)) byRoot.get(root).push(member)
748
+ }
749
+ for (const row of rows) {
750
+ const id = Number(row.id)
751
+ if (byRoot.has(id)) row.members = byRoot.get(id)
752
+ }
753
+ }
754
+ }
601
755
  return { text: renderEntryLines(rows) }
602
756
  }
603
757
 
604
758
  async function ingestSessionMessages(args = {}) {
605
759
  const sessionId = String(args.sessionId || args.session_id || `session-${Date.now()}`).trim()
606
760
  const messages = Array.isArray(args.messages) ? args.messages : []
607
- const limit = Math.max(1, Math.min(500, Number(args.limit) || 200))
761
+ // Recall fast-track hydrates the current session before compaction; allow
762
+ // callers to ingest the full in-memory transcript instead of silently
763
+ // clipping long sessions at 500 turns. Default remains conservative.
764
+ const limit = Math.max(1, Math.min(5000, Number(args.limit) || 200))
608
765
  const start = Math.max(0, messages.length - limit)
609
766
  const projectId = resolveProjectScope(typeof args.cwd === 'string' && args.cwd ? args.cwd : null)
610
767
  let considered = 0
611
768
  let inserted = 0
769
+ // Monotonic ingest order, independent of the current (post-compaction)
770
+ // array index. source_turn used to be `i+1`, but after compaction shrinks /
771
+ // reindexes session.messages a NEWLY appended turn gets a LOW i and thus a
772
+ // LOW source_turn — and since dump_session_roots / recall order by
773
+ // source_turn first, it would sort BEFORE older pre-compaction rows. Seed a
774
+ // running counter from the current max source_turn for this session so every
775
+ // new row is assigned a turn strictly greater than all previously-ingested
776
+ // ones (true continuation order). Re-ingested (ON CONFLICT) rows keep their
777
+ // original turn and do not consume a new one.
778
+ let prevMaxTurn = 0
779
+ try {
780
+ const maxRow = await db.query(
781
+ `SELECT COALESCE(MAX(source_turn), 0) AS max_turn FROM entries WHERE session_id = $1`,
782
+ [sessionId],
783
+ )
784
+ prevMaxTurn = Number(maxRow.rows?.[0]?.max_turn) || 0
785
+ } catch { prevMaxTurn = 0 }
786
+ const turnAllocator = createIngestTurnAllocator(prevMaxTurn)
612
787
  for (let i = start; i < messages.length; i += 1) {
613
788
  const m = messages[i]
614
- if (!m || (m.role !== 'user' && m.role !== 'assistant')) continue
615
- const content = cleanMemoryText(firstTextContent(m.content))
789
+ if (!m || typeof m !== 'object') continue
790
+ const role = normalizeIngestRole(m.role)
791
+ // Persist the whole session conversation by role: user/assistant carry the
792
+ // dialogue, tool carries tool_results, system/developer carry steering
793
+ // context. Previously only user/assistant were kept, silently dropping
794
+ // recent tool/system/developer state that recall fast-track must surface.
795
+ if (!role) continue
796
+ const content = cleanMemoryText(sessionMessageContent(m))
616
797
  if (!content || !content.trim()) continue
617
798
  considered += 1
618
799
  const tsMs = parseTsToMs(m.ts ?? m.timestamp ?? (Date.now() - (messages.length - i)))
619
- const sourceRef = `session:${sessionId}#${i + 1}`
800
+ // Stable per-message identity. The previous `session:${id}#${i+1}` key was
801
+ // positional, so after compaction shrinks/reindexes session.messages a
802
+ // later turn could reuse an old index and be silently skipped by
803
+ // ON CONFLICT DO NOTHING. stableSessionSourceRef hashes only durable
804
+ // fields (role, original ts if present, tool ids, content) — never the
805
+ // synthesized tsMs fallback or the loop index.
806
+ const sourceRef = stableSessionSourceRef(sessionId, m, role, content)
807
+ // Assign the next monotonic turn; only consume it when the row is actually
808
+ // inserted (a conflicting re-ingest keeps its original source_turn).
809
+ const assignedTurn = turnAllocator.peekNext()
620
810
  const result = await db.query(`
621
811
  INSERT INTO entries(ts, role, content, source_ref, session_id, source_turn, project_id)
622
812
  VALUES ($1, $2, $3, $4, $5, $6, $7)
623
813
  ON CONFLICT DO NOTHING
624
- `, [tsMs, m.role, content, sourceRef, sessionId, i + 1, projectId])
625
- inserted += Number(result.rowCount ?? result.affectedRows ?? 0) || 0
814
+ `, [tsMs, role, content, sourceRef, sessionId, assignedTurn, projectId])
815
+ const rowInserted = Number(result.rowCount ?? result.affectedRows ?? 0) || 0
816
+ if (rowInserted > 0) {
817
+ inserted += rowInserted
818
+ turnAllocator.next()
819
+ }
626
820
  }
627
821
  return { text: `ingest_session: considered=${considered} inserted=${inserted} session=${sessionId}` }
628
822
  }
629
823
 
630
- async function ingestTranscriptFile(transcriptPath, { cwd } = {}) {
824
+ function runTranscriptIngestSerialized(transcriptPath, fn) {
825
+ const key = path.resolve(transcriptPath)
826
+ const prev = _ingestTranscriptTails.get(key) ?? Promise.resolve()
827
+ const run = prev.catch(() => {}).then(fn)
828
+ _ingestTranscriptTails.set(key, run.catch(() => {}))
829
+ return run
830
+ }
831
+
832
+ function snapshotTranscriptOffset(transcriptPath) {
833
+ const stored = _transcriptOffsets.get(transcriptPath)
834
+ if (!stored) return { bytes: 0, lineIndex: 0 }
835
+ return { bytes: Number(stored.bytes) || 0, lineIndex: Number(stored.lineIndex) || 0 }
836
+ }
837
+
838
+ async function ingestTranscriptFileImpl(transcriptPath, { cwd } = {}) {
631
839
  let stat
632
840
  try { stat = await fs.promises.stat(transcriptPath) } catch { return 0 }
633
841
  const sessionUuid = path.basename(transcriptPath, '.jsonl')
634
- const prev = _transcriptOffsets.get(transcriptPath) ?? { bytes: 0, lineIndex: 0 }
842
+ const prev = snapshotTranscriptOffset(transcriptPath)
635
843
  if (stat.size < prev.bytes) {
636
844
  prev.bytes = 0
637
845
  prev.lineIndex = 0
@@ -721,21 +929,16 @@ async function ingestTranscriptFile(transcriptPath, { cwd } = {}) {
721
929
  break
722
930
  }
723
931
  }
724
- prev.bytes = lastGoodBytes
725
- prev.lineIndex = lastGoodLineIndex
726
- _transcriptOffsets.set(transcriptPath, prev)
932
+ _transcriptOffsets.set(transcriptPath, {
933
+ bytes: lastGoodBytes,
934
+ lineIndex: lastGoodLineIndex,
935
+ })
727
936
  await persistTranscriptOffsets()
728
937
  return count
729
938
  }
730
939
 
731
- function firstTextContent(content) {
732
- if (typeof content === 'string') return content
733
- if (!Array.isArray(content)) return ''
734
- for (const item of content) {
735
- if (typeof item === 'string') return item
736
- if (item?.type === 'text' && typeof item.text === 'string') return item.text
737
- }
738
- return ''
940
+ async function ingestTranscriptFile(transcriptPath, options = {}) {
941
+ return runTranscriptIngestSerialized(transcriptPath, () => ingestTranscriptFileImpl(transcriptPath, options))
739
942
  }
740
943
 
741
944
  function parseTsToMs(value) {
@@ -776,7 +979,7 @@ function cwdFromTranscriptPath(fp) {
776
979
  }
777
980
 
778
981
  function _initTranscriptWatcher() {
779
- const projectsRoot = path.join(os.homedir(), '.mixdog', 'projects')
982
+ const projectsRoot = path.join(mixdogHome(), 'projects')
780
983
  const SAFETY_POLL_MS = 5 * 60_000
781
984
  const DEBOUNCE_MS = 500
782
985
  const watchedFiles = new Map()
@@ -963,6 +1166,83 @@ function _initTranscriptWatcher() {
963
1166
  const CYCLE1_HEALTH_OVERDUE_MS = 5 * 60_000
964
1167
  const CYCLE1_AUTO_RESTART_COOLDOWN_MS = 5 * 60_000
965
1168
 
1169
+ // In-process cycle1 LLM adapter. The memory daemon runs makeAgentDispatch()
1170
+ // locally (provider registry is initialized in _initStore), so cycle1 never
1171
+ // has to route over the dead IPC agent path (agent-ipc.mjs callAgentDispatch). The
1172
+ // factory is built once (role/taskType are fixed) and the returned function
1173
+ // is reshaped to cycle1's call signature: cycle1 invokes
1174
+ // `callLlm({ role, taskType, mode, preset, timeout, cwd }, userMessage)` and
1175
+ // expects a raw string, while makeAgentDispatch's function takes a single
1176
+ // `{ prompt }` object. The adapter maps the two — preset/cwd resolution is
1177
+ // handled inside makeAgentDispatch via role (cycle1-agent → maint.memory slot).
1178
+ let _cycle1AgentDispatch = null
1179
+ function getCycle1CallLlm() {
1180
+ if (!_cycle1AgentDispatch) {
1181
+ _cycle1AgentDispatch = makeAgentDispatch({
1182
+ role: 'cycle1-agent',
1183
+ taskType: 'maintenance',
1184
+ sourceType: 'memory-cycle',
1185
+ // cycle1 parses the full raw line-format response; the agent brief cap
1186
+ // (12KB) would truncate a large valid response and append prose, causing
1187
+ // partial parsing / omitted / invalid chunks. Opt out so the cycle1
1188
+ // no-truncation contract is preserved through makeAgentDispatch.
1189
+ brief: false,
1190
+ })
1191
+ }
1192
+ return async (opts = {}, userMessage) => {
1193
+ // Preserve cycle1's timeout contract: cycle1 derives `opts.timeout` from
1194
+ // config / caller deadline and expects it to bound the call. makeAgentDispatch
1195
+ // takes it as a per-call `idleTimeoutMs` (stale watchdog). Map it through;
1196
+ // omit when absent/0 so agent defaults apply.
1197
+ const callTimeout = Number(opts?.timeout)
1198
+ return _cycle1AgentDispatch({
1199
+ prompt: String(userMessage ?? ''),
1200
+ preset: opts?.preset || undefined,
1201
+ ...(Number.isFinite(callTimeout) && callTimeout > 0 ? { idleTimeoutMs: callTimeout } : {}),
1202
+ })
1203
+ }
1204
+ }
1205
+
1206
+ let _cycle2AgentDispatch = null
1207
+ function getCycle2CallLlm() {
1208
+ if (!_cycle2AgentDispatch) {
1209
+ _cycle2AgentDispatch = makeAgentDispatch({
1210
+ role: 'cycle2-agent',
1211
+ taskType: 'maintenance',
1212
+ sourceType: 'memory-cycle',
1213
+ brief: false,
1214
+ })
1215
+ }
1216
+ return async (opts = {}, userMessage) => {
1217
+ const callTimeout = Number(opts?.timeout)
1218
+ return _cycle2AgentDispatch({
1219
+ prompt: String(userMessage ?? ''),
1220
+ preset: opts?.preset || undefined,
1221
+ ...(Number.isFinite(callTimeout) && callTimeout > 0 ? { idleTimeoutMs: callTimeout } : {}),
1222
+ })
1223
+ }
1224
+ }
1225
+
1226
+ let _cycle3AgentDispatch = null
1227
+ function getCycle3CallLlm() {
1228
+ if (!_cycle3AgentDispatch) {
1229
+ _cycle3AgentDispatch = makeAgentDispatch({
1230
+ role: 'cycle3-agent',
1231
+ taskType: 'maintenance',
1232
+ sourceType: 'memory-cycle',
1233
+ brief: false,
1234
+ })
1235
+ }
1236
+ return async (opts = {}, userMessage) => {
1237
+ const callTimeout = Number(opts?.timeout)
1238
+ return _cycle3AgentDispatch({
1239
+ prompt: String(userMessage ?? ''),
1240
+ preset: opts?.preset || undefined,
1241
+ ...(Number.isFinite(callTimeout) && callTimeout > 0 ? { idleTimeoutMs: callTimeout } : {}),
1242
+ })
1243
+ }
1244
+ }
1245
+
966
1246
  async function recordCycle1Result(result) {
967
1247
  const now = Date.now()
968
1248
  await setCycleLastRun('cycle1_heartbeat', now)
@@ -978,6 +1258,13 @@ async function recordCycle1Result(result) {
978
1258
  }
979
1259
 
980
1260
  function _startCycle1Run(config = {}, options = {}) {
1261
+ // Default to the in-process agent dispatch so every cycle1 path — scheduled,
1262
+ // auto-restart, periodic, manual (action:cycle1), backfill drain, rebuild —
1263
+ // dispatches locally and the dead IPC fallback in memory-cycle1.mjs is never
1264
+ // reached. Explicit options.callLlm (if a caller ever passes one) wins.
1265
+ if (typeof options?.callLlm !== 'function') {
1266
+ options = { ...options, callLlm: getCycle1CallLlm() }
1267
+ }
981
1268
  _cycle1InFlight = (async () => {
982
1269
  try {
983
1270
  const result = await runCycle1(db, config, options, DATA_DIR)
@@ -1110,34 +1397,48 @@ async function enqueueScheduledCycle3(intervalMs, _reason = 'scheduled') {
1110
1397
  }
1111
1398
  }
1112
1399
 
1113
- function scheduleScheduledCycle1(config, signature) {
1400
+ function scheduleScheduledCycle1(config, signature, attempt = 0) {
1401
+ const maxRetries = resolveCoalesceMaxRetries(config, 3)
1402
+ if (attempt > maxRetries) {
1403
+ __mixdogMemoryLog('[cycle1] scheduled queue retry cap reached\n')
1404
+ return
1405
+ }
1114
1406
  scheduleCoalescedCycleRetry(db, 'cycle1', async () => {
1115
1407
  if (_cycle1InFlight) {
1116
- scheduleScheduledCycle1(config, signature)
1408
+ scheduleScheduledCycle1(config, signature, attempt + 1)
1117
1409
  return
1118
1410
  }
1119
1411
  const result = await _awaitCycle1Run(config, {
1120
1412
  coalescedRetry: true,
1121
1413
  onCoalescedSuccess: recordCycle1Result,
1122
1414
  })
1123
- if (result?.skippedInFlight) scheduleScheduledCycle1(config, signature)
1415
+ if (result?.skippedInFlight) scheduleScheduledCycle1(config, signature, attempt + 1)
1124
1416
  }, config, signature)
1125
1417
  }
1126
1418
 
1127
- function scheduleScheduledCycle2(config, signature) {
1419
+ function scheduleScheduledCycle2(config, signature, attempt = 0) {
1420
+ const maxRetries = resolveCoalesceMaxRetries(config, 3)
1421
+ if (attempt > maxRetries) {
1422
+ __mixdogMemoryLog('[cycle2] scheduled queue retry cap reached\n')
1423
+ return
1424
+ }
1128
1425
  scheduleCoalescedCycleRetry(db, 'cycle2', async () => {
1129
1426
  if (_cycle2InFlight) {
1130
- scheduleScheduledCycle2(config, signature)
1427
+ scheduleScheduledCycle2(config, signature, attempt + 1)
1131
1428
  return
1132
1429
  }
1133
1430
  _cycle2InFlight = true
1134
1431
  try {
1135
- const result = await runCycle2(db, config, {
1432
+ let c2Options = {
1136
1433
  coalescedRetry: true,
1137
1434
  onCoalescedSuccess: _finalizeCycle2Run,
1138
- }, DATA_DIR)
1435
+ }
1436
+ if (typeof c2Options?.callLlm !== 'function') {
1437
+ c2Options = { ...c2Options, callLlm: getCycle2CallLlm() }
1438
+ }
1439
+ const result = await runCycle2(db, config, c2Options, DATA_DIR)
1139
1440
  if (result?.skippedInFlight) {
1140
- scheduleScheduledCycle2(config, signature)
1441
+ scheduleScheduledCycle2(config, signature, attempt + 1)
1141
1442
  } else if (result?.coalescedRetryNoop) {
1142
1443
  __mixdogMemoryLog('[cycle2] scheduled queue noop\n')
1143
1444
  } else if (result?.ok === false) {
@@ -1151,21 +1452,30 @@ function scheduleScheduledCycle2(config, signature) {
1151
1452
  }, config, signature)
1152
1453
  }
1153
1454
 
1154
- function scheduleScheduledCycle3(config, signature) {
1455
+ function scheduleScheduledCycle3(config, signature, attempt = 0) {
1155
1456
  const retryConfig = config?.cycle3 || config
1457
+ const maxRetries = resolveCoalesceMaxRetries(retryConfig, 3)
1458
+ if (attempt > maxRetries) {
1459
+ __mixdogMemoryLog('[cycle3] scheduled queue retry cap reached\n')
1460
+ return
1461
+ }
1156
1462
  scheduleCoalescedCycleRetry(db, 'cycle3', async () => {
1157
1463
  if (_cycle3InFlight) {
1158
- scheduleScheduledCycle3(config, signature)
1464
+ scheduleScheduledCycle3(config, signature, attempt + 1)
1159
1465
  return
1160
1466
  }
1161
1467
  _cycle3InFlight = true
1162
1468
  try {
1163
- const result = await runCycle3(db, config, DATA_DIR, {
1469
+ let c3Options = {
1164
1470
  coalescedRetry: true,
1165
1471
  onCoalescedSuccess: () => setCycleLastRun('cycle3', Date.now()),
1166
- })
1472
+ }
1473
+ if (typeof c3Options?.callLlm !== 'function') {
1474
+ c3Options = { ...c3Options, callLlm: getCycle3CallLlm() }
1475
+ }
1476
+ const result = await runCycle3(db, config, DATA_DIR, c3Options)
1167
1477
  if (result?.skippedInFlight) {
1168
- scheduleScheduledCycle3(config, signature)
1478
+ scheduleScheduledCycle3(config, signature, attempt + 1)
1169
1479
  } else if (result?.coalescedRetryNoop) {
1170
1480
  __mixdogMemoryLog('[cycle3] scheduled queue noop\n')
1171
1481
  }
@@ -1320,8 +1630,12 @@ async function _initRuntime() {
1320
1630
  // increments, so deleted rows leave permanent gaps. Fast no-op when already
1321
1631
  // contiguous (or empty). Runs only here — never in cycle2/addCore/deleteCore.
1322
1632
  await compactCoreIds(DATA_DIR)
1323
- _transcriptWatcher = _initTranscriptWatcher()
1324
- _startCycles()
1633
+ if (memoryCyclesEnabled()) {
1634
+ _transcriptWatcher = _initTranscriptWatcher()
1635
+ _startCycles()
1636
+ } else {
1637
+ __mixdogMemoryLog('[memory-service] secondary mode; skipping background cycles\n')
1638
+ }
1325
1639
  _initialized = true
1326
1640
  // Boot complete — continue straight into the deferred embedding warmup.
1327
1641
  // Fire-and-forget on the embedding worker thread; never awaited so it does
@@ -1334,6 +1648,7 @@ function _beginRuntimeInit() {
1334
1648
  if (!_initPromise) {
1335
1649
  _initPromise = _initRuntime().catch((e) => {
1336
1650
  __mixdogMemoryLog(`[memory-service] runtime init failed: ${e?.stack || e?.message || e}\n`)
1651
+ _initPromise = null
1337
1652
  throw e
1338
1653
  })
1339
1654
  }
@@ -1598,15 +1913,20 @@ async function handleSearch(args, signal) {
1598
1913
  })
1599
1914
  let settled
1600
1915
  try {
1601
- // Pre-warm the per-query embedding cache with one batched ONNX run so
1602
- // each sub-search lands an embedText cache hit (~0ms) instead of
1603
- // serially queueing through the worker's single-flight inference lock.
1604
- // Replaces N sequential ~130ms inferences with one ~150-200ms batch.
1605
- //
1606
- // Race against the same deadline as the fan-out itself: a stuck
1607
- // embedding worker would previously park here indefinitely because
1608
- // the timer hadn't been started yet from the fan-out's perspective.
1609
- await Promise.race([embedTexts(queries), deadlineRace])
1916
+ // Pre-warm only when the embedding model is already resident. If the
1917
+ // process is still cold, keep recall responsive and let the background
1918
+ // warmup finish independently instead of making the first query pay the
1919
+ // ONNX session-create cost.
1920
+ if (isEmbeddingModelReady()) {
1921
+ // Race against the same deadline as the fan-out itself: a stuck
1922
+ // embedding worker would previously park here indefinitely because
1923
+ // the timer hadn't been started yet from the fan-out's perspective.
1924
+ await Promise.race([embedTexts(queries), deadlineRace])
1925
+ } else if (embeddingWarmupCanStart()) {
1926
+ void warmupEmbeddingProvider().catch((err) => {
1927
+ __mixdogMemoryLog(`[memory-service] embedding warmup after cold fan-out skipped dense search: ${err?.message || err}\n`)
1928
+ })
1929
+ }
1610
1930
  settled = await Promise.race([
1611
1931
  Promise.all(queries.map(async (q) => {
1612
1932
  if (fanOutAbort.signal.aborted) throw fanOutAbort.signal.reason
@@ -1685,7 +2005,21 @@ async function handleSearch(args, signal) {
1685
2005
  if (query) {
1686
2006
  const _t0 = Date.now()
1687
2007
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1688
- const queryVector = await embedText(query)
2008
+ let queryVector = null
2009
+ if (isEmbeddingModelReady()) {
2010
+ queryVector = await embedText(query)
2011
+ } else {
2012
+ const now = Date.now()
2013
+ if (now - _embeddingColdRecallLogAt > 10_000) {
2014
+ _embeddingColdRecallLogAt = now
2015
+ __mixdogMemoryLog('[recall] embedding model cold; returning lexical results while background warmup continues\n')
2016
+ }
2017
+ if (embeddingWarmupCanStart()) {
2018
+ void warmupEmbeddingProvider().catch((err) => {
2019
+ __mixdogMemoryLog(`[memory-service] embedding warmup after cold recall failed: ${err?.message || err}\n`)
2020
+ })
2021
+ }
2022
+ }
1689
2023
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1690
2024
  const _t1 = Date.now()
1691
2025
  if (process.env.MIXDOG_DEBUG_MEMORY) {
@@ -1776,7 +2110,7 @@ async function handleSearch(args, signal) {
1776
2110
  // Emit a recall trace event so getTraceWithEntries() can correlate
1777
2111
  // this search with the top-ranked memory entry. One event per
1778
2112
  // handleSearch call (not per returned row) — cheapest meaningful link.
1779
- // parent_span_id left null: the bridge-side span id is only known after
2113
+ // parent_span_id left null: the agent-side span id is only known after
1780
2114
  // the DB insert of the loop/tool events, which happens async on the
1781
2115
  // client side and is not available here.
1782
2116
  if (_traceDb && filtered.length > 0) {
@@ -1860,6 +2194,94 @@ function renderEntryLines(rows) {
1860
2194
  return lines.join('\n')
1861
2195
  }
1862
2196
 
2197
+ async function dumpSessionRootChunks(args = {}) {
2198
+ const sessionId = String(args.sessionId || args.session_id || '').trim()
2199
+ if (!sessionId) return { text: '(no current session)', rows: [], chunks: [], isError: true }
2200
+ const includeRaw = args.includeRaw !== false
2201
+ const limit = Math.max(1, Math.min(1000, Number(args.limit) || 1000))
2202
+ const rootRows = (await db.query(`
2203
+ SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
2204
+ element, category, summary, status, score, last_seen_at, project_id
2205
+ FROM entries
2206
+ WHERE session_id = $1 AND is_root = 1
2207
+ ORDER BY COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
2208
+ LIMIT $2
2209
+ `, [sessionId, limit])).rows
2210
+ const roots = rootRows.map((r) => ({ ...r, members: [] }))
2211
+ const rootIds = roots.map((r) => Number(r.id)).filter((id) => Number.isFinite(id))
2212
+ const memberRows = rootIds.length > 0
2213
+ ? (await db.query(`
2214
+ SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root, project_id
2215
+ FROM entries
2216
+ WHERE chunk_root = ANY($1::bigint[]) AND is_root = 0
2217
+ ORDER BY chunk_root ASC, COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
2218
+ `, [rootIds])).rows
2219
+ : []
2220
+ const byRoot = new Map(roots.map((r) => [Number(r.id), r]))
2221
+ for (const m of memberRows) {
2222
+ const root = byRoot.get(Number(m.chunk_root))
2223
+ if (root) root.members.push(m)
2224
+ }
2225
+ let rawRows = []
2226
+ if (includeRaw) {
2227
+ rawRows = (await db.query(`
2228
+ SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root, project_id
2229
+ FROM entries
2230
+ WHERE session_id = $1
2231
+ AND is_root = 0
2232
+ AND (chunk_root IS NULL OR chunk_root = id)
2233
+ ORDER BY COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
2234
+ LIMIT $2
2235
+ `, [sessionId, limit])).rows
2236
+ }
2237
+ const chunks = []
2238
+ for (const root of roots) {
2239
+ const memberText = root.members
2240
+ .map((m) => `${m.role === 'assistant' ? 'assistant' : m.role === 'user' ? 'user' : m.role}: ${cleanMemoryText(String(m.content ?? ''))}`)
2241
+ .filter(Boolean)
2242
+ .join('\n')
2243
+ const summary = [root.element, root.summary].map((v) => String(v || '').trim()).filter(Boolean).join(' — ')
2244
+ chunks.push({
2245
+ id: Number(root.id),
2246
+ kind: 'root',
2247
+ ts: Number(root.ts) || 0,
2248
+ sourceTurn: root.source_turn ?? null,
2249
+ category: root.category || null,
2250
+ summary,
2251
+ text: memberText || cleanMemoryText(String(root.content ?? '')),
2252
+ members: root.members,
2253
+ })
2254
+ }
2255
+ for (const raw of rawRows) {
2256
+ chunks.push({
2257
+ id: Number(raw.id),
2258
+ kind: 'raw',
2259
+ chunkRoot: raw.chunk_root ?? null,
2260
+ ts: Number(raw.ts) || 0,
2261
+ sourceTurn: raw.source_turn ?? null,
2262
+ category: null,
2263
+ summary: '',
2264
+ text: `${raw.role === 'assistant' ? 'assistant' : raw.role === 'user' ? 'user' : raw.role}: ${cleanMemoryText(String(raw.content ?? ''))}`,
2265
+ members: [],
2266
+ })
2267
+ }
2268
+ chunks.sort((a, b) => {
2269
+ const at = Number.isFinite(Number(a.sourceTurn)) ? Number(a.sourceTurn) : 2147483647
2270
+ const bt = Number.isFinite(Number(b.sourceTurn)) ? Number(b.sourceTurn) : 2147483647
2271
+ return (at - bt) || ((a.ts || 0) - (b.ts || 0)) || ((a.id || 0) - (b.id || 0))
2272
+ })
2273
+ const text = chunks.length
2274
+ ? chunks.map((chunk, idx) => {
2275
+ const label = chunk.kind === 'root'
2276
+ ? `# chunk ${idx + 1} root=${chunk.id}${chunk.category ? ` category=${chunk.category}` : ''}`
2277
+ : `${chunk.chunkRoot == null ? '# raw_pending' : '# raw_terminal'} ${idx + 1} id=${chunk.id}`
2278
+ const summary = chunk.summary ? `summary: ${chunk.summary}\n` : ''
2279
+ return `${label}\n${summary}${chunk.text}`.trim()
2280
+ }).join('\n\n')
2281
+ : '(no results)'
2282
+ return { text, rows: [...roots, ...rawRows], chunks }
2283
+ }
2284
+
1863
2285
  async function entryStats() {
1864
2286
  return await db.transaction(async (tx) => {
1865
2287
  const total = (await tx.query(`SELECT COUNT(*) c FROM entries`)).rows[0].c
@@ -1895,7 +2317,10 @@ async function _handleMemCycle1(args, config, signal) {
1895
2317
  const minBatchOverride = Number(args?.min_batch)
1896
2318
  const sessionCapOverride = Number(args?.session_cap)
1897
2319
  const batchSizeOverride = Number(args?.batch_size)
2320
+ const windowSizeOverride = Number(args?.window_size ?? args?.windowSize)
2321
+ const rowsPerSessionOverride = Number(args?.rows_per_session ?? args?.rowsPerSession ?? args?.max_rows_per_session ?? args?.maxRowsPerSession)
1898
2322
  const concurrencyOverride = Number(args?.concurrency)
2323
+ const sessionIdOverride = String(args?.sessionId ?? args?.session_id ?? '').trim()
1899
2324
  const baseCycle1 = config?.cycle1 || {}
1900
2325
  let cycle1Config = baseCycle1
1901
2326
  // _runCycle1Impl reads `config?.min_batch ?? config?.cycle1?.min_batch ??
@@ -1909,12 +2334,24 @@ async function _handleMemCycle1(args, config, signal) {
1909
2334
  if (Number.isFinite(batchSizeOverride) && batchSizeOverride > 0) {
1910
2335
  cycle1Config = { ...cycle1Config, batch_size: batchSizeOverride }
1911
2336
  }
2337
+ if (Number.isFinite(windowSizeOverride) && windowSizeOverride > 0) {
2338
+ cycle1Config = { ...cycle1Config, window_size: windowSizeOverride }
2339
+ }
2340
+ if (Number.isFinite(rowsPerSessionOverride) && rowsPerSessionOverride > 0) {
2341
+ cycle1Config = { ...cycle1Config, rows_per_session: rowsPerSessionOverride }
2342
+ }
2343
+ if (sessionIdOverride) {
2344
+ cycle1Config = { ...cycle1Config, session_id: sessionIdOverride }
2345
+ }
1912
2346
  if (Number.isFinite(concurrencyOverride) && concurrencyOverride > 0) {
1913
2347
  cycle1Config = { ...cycle1Config, concurrency: Math.min(8, Math.floor(concurrencyOverride)) }
1914
2348
  }
1915
2349
  const callerDeadlineMs = Number(args?._callerDeadlineMs) || 0
1916
2350
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1917
2351
  const cycle1Options = callerDeadlineMs > 0 ? { callerDeadlineMs, signal } : { signal }
2352
+ if (typeof args?._callLlm === 'function') {
2353
+ cycle1Options.callLlm = args._callLlm
2354
+ }
1918
2355
  const result = await _awaitCycle1Run(
1919
2356
  cycle1Config,
1920
2357
  cycle1Options,
@@ -1928,6 +2365,7 @@ async function _handleMemCycle1(args, config, signal) {
1928
2365
  const failedRows = Array.isArray(result?.failed_row_ids) ? result.failed_row_ids.length : Number(result?.quality?.failed_rows || 0)
1929
2366
  const invalidChunks = Array.isArray(result?.invalid_chunks) ? result.invalid_chunks.length : Number(result?.quality?.invalid_chunks || 0)
1930
2367
  return {
2368
+ ...result,
1931
2369
  text: `cycle1: chunks=${result.chunks} processed=${result.processed} skipped_chunks=${result.skipped}` +
1932
2370
  ` omitted=${omitted} prefiltered=${prefiltered} failed_rows=${failedRows} invalid_chunks=${invalidChunks}` +
1933
2371
  ` pending=${pendingStr} inFlight=${inFlightStr}${timedOutPart}`,
@@ -1936,7 +2374,11 @@ async function _handleMemCycle1(args, config, signal) {
1936
2374
 
1937
2375
  async function _handleMemCycle2(args, config, signal) {
1938
2376
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1939
- const result = await runCycle2(db, config?.cycle2 || {}, { signal }, DATA_DIR)
2377
+ let c2Options = { signal }
2378
+ if (typeof c2Options?.callLlm !== 'function') {
2379
+ c2Options = { ...c2Options, callLlm: getCycle2CallLlm() }
2380
+ }
2381
+ const result = await runCycle2(db, config?.cycle2 || {}, c2Options, DATA_DIR)
1940
2382
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1941
2383
  await _finalizeCycle2Run(result)
1942
2384
  const counts = {
@@ -1973,7 +2415,11 @@ async function _handleMemCycle3(args, config, signal) {
1973
2415
  : (requestedMode === 'proposal' || requestedMode === 'dry-run' || requestedMode === 'dryrun')
1974
2416
  ? 'proposal'
1975
2417
  : 'conservative'
1976
- const result = await runCycle3(db, config || {}, DATA_DIR, { signal, apply: confirmed ? true : undefined, applyMode })
2418
+ let c3Options = { signal, apply: confirmed ? true : undefined, applyMode }
2419
+ if (typeof c3Options?.callLlm !== 'function') {
2420
+ c3Options = { ...c3Options, callLlm: getCycle3CallLlm() }
2421
+ }
2422
+ const result = await runCycle3(db, config || {}, DATA_DIR, c3Options)
1977
2423
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1978
2424
  const parts = ['reviewed', 'kept', 'updated', 'merged', 'deleted']
1979
2425
  .map(k => `${k}=${result?.[k] || 0}`)
@@ -1998,7 +2444,11 @@ async function _handleMemFlush(args, config, signal) {
1998
2444
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1999
2445
  const r1 = await _awaitCycle1Run(config?.cycle1 || {}, { signal })
2000
2446
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
2001
- const r2 = await runCycle2(db, config?.cycle2 || {}, { signal }, DATA_DIR)
2447
+ let flushC2Options = { signal }
2448
+ if (typeof flushC2Options?.callLlm !== 'function') {
2449
+ flushC2Options = { ...flushC2Options, callLlm: getCycle2CallLlm() }
2450
+ }
2451
+ const r2 = await runCycle2(db, config?.cycle2 || {}, flushC2Options, DATA_DIR)
2002
2452
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
2003
2453
  await _finalizeCycle2Run(r2)
2004
2454
  return { text: `flush: cycle1 chunks=${r1.chunks} processed=${r1.processed}, cycle2 ${JSON.stringify(r2)}` }
@@ -2102,7 +2552,11 @@ async function _handleMemRebuild(args, config, signal) {
2102
2552
  // inside _awaitCycle1Run and guarantees the newly demoted rows are read.
2103
2553
  const r1 = await _startCycle1Run(config?.cycle1 || {}, { signal })
2104
2554
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
2105
- const r2 = await runCycle2(db, config?.cycle2 || {}, { signal }, DATA_DIR)
2555
+ let rebuildC2Options = { signal }
2556
+ if (typeof rebuildC2Options?.callLlm !== 'function') {
2557
+ rebuildC2Options = { ...rebuildC2Options, callLlm: getCycle2CallLlm() }
2558
+ }
2559
+ const r2 = await runCycle2(db, config?.cycle2 || {}, rebuildC2Options, DATA_DIR)
2106
2560
  await _finalizeCycle2Run(r2)
2107
2561
  return { text: `rebuild: cycle1 chunks=${r1.chunks} processed=${r1.processed}, cycle2 ${JSON.stringify(r2)}` }
2108
2562
  }
@@ -2152,7 +2606,11 @@ async function _handleMemBackfill(args, config, signal) {
2152
2606
  },
2153
2607
  runCycle2: async (dbArg, c2Config, c2Options, c2DataDir) => {
2154
2608
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
2155
- const r2 = await runCycle2(dbArg, c2Config, { ...c2Options, signal }, c2DataDir)
2609
+ let backfillC2Options = { ...c2Options, signal }
2610
+ if (typeof backfillC2Options?.callLlm !== 'function') {
2611
+ backfillC2Options = { ...backfillC2Options, callLlm: getCycle2CallLlm() }
2612
+ }
2613
+ const r2 = await runCycle2(dbArg, c2Config, backfillC2Options, c2DataDir)
2156
2614
  _capturedCycle2 = r2
2157
2615
  return r2
2158
2616
  },
@@ -2224,6 +2682,10 @@ async function handleMemoryAction(args, signal) {
2224
2682
  return ingestSessionMessages(args)
2225
2683
  }
2226
2684
 
2685
+ if (action === 'dump_session_roots') {
2686
+ return dumpSessionRootChunks(args)
2687
+ }
2688
+
2227
2689
  if (action === 'manage') {
2228
2690
  const op = String(args.op ?? '').trim().toLowerCase()
2229
2691
  if (!['add', 'edit', 'delete'].includes(op)) {
@@ -2570,7 +3032,7 @@ async function handleToolCall(name, args, signal) {
2570
3032
  try {
2571
3033
  if (name === 'search_memories') {
2572
3034
  const result = await handleSearch(args || {}, signal)
2573
- return { content: [{ type: 'text', text: result.text }], isError: result.isError || false }
3035
+ return { ...result, content: [{ type: 'text', text: result.text }], isError: result.isError || false }
2574
3036
  }
2575
3037
  if (name === 'recall') {
2576
3038
  // recall is aiWrapped in the unified build; in standalone mode map it to
@@ -2604,11 +3066,11 @@ async function handleToolCall(name, args, signal) {
2604
3066
  ...(a.currentSession !== undefined ? { currentSession: a.currentSession } : {}),
2605
3067
  }
2606
3068
  const result = await handleSearch(searchArgs, signal)
2607
- return { content: [{ type: 'text', text: result.text }], isError: result.isError || false }
3069
+ return { ...result, content: [{ type: 'text', text: result.text }], isError: result.isError || false }
2608
3070
  }
2609
3071
  if (name === 'memory') {
2610
3072
  const result = await handleMemoryAction(args || {}, signal)
2611
- return { content: [{ type: 'text', text: result.text }], isError: result.isError || false }
3073
+ return { ...result, content: [{ type: 'text', text: result.text }], isError: result.isError || false }
2612
3074
  }
2613
3075
  return { content: [{ type: 'text', text: `unknown tool: ${name}` }], isError: true }
2614
3076
  } catch (err) {
@@ -2748,6 +3210,7 @@ let _backfillInFlight = null
2748
3210
  const _ownerInFlightHttpCalls = new Map()
2749
3211
 
2750
3212
  const httpServer = http.createServer(async (req, res) => {
3213
+ touchDaemonIdleTimer(`${req.method || 'HTTP'} ${req.url || '/'}`)
2751
3214
  if (req.method === 'POST' && req.url === '/session-reset') {
2752
3215
  _bootTimestamp = Date.now()
2753
3216
  sendJson(res, { ok: true, bootTimestamp: _bootTimestamp })
@@ -2969,9 +3432,9 @@ const httpServer = http.createServer(async (req, res) => {
2969
3432
  enqueueTraceEvents(_traceDb, body.events)
2970
3433
  // Use `queued` — events are async; `inserted` would imply durability.
2971
3434
  sendJson(res, { ok: true, queued: body.events.length })
2972
- // Fire-and-forget into focused bridge analytic tables.
2973
- insertBridgeCalls(_traceDb, body.events).catch(e =>
2974
- __mixdogMemoryLog(`[trace] insertBridgeCalls error: ${e?.message}\n`)
3435
+ // Fire-and-forget into focused agent analytic tables.
3436
+ insertAgentCalls(_traceDb, body.events).catch(e =>
3437
+ __mixdogMemoryLog(`[trace] insertAgentCalls error: ${e?.message}\n`)
2975
3438
  )
2976
3439
  } catch (e) {
2977
3440
  sendJson(res, { ok: false, error: e.message }, 500)
@@ -3014,11 +3477,11 @@ const httpServer = http.createServer(async (req, res) => {
3014
3477
  // DEV-ONLY cycle1 chunking bench. Gated by env MIXDOG_DEV_BENCH=1 so
3015
3478
  // production is untouched (route returns 404 when unset). Mirrors cycle1's
3016
3479
  // exact fetch query + per-session windowing, then runs each window through
3017
- // buildCycle1ChunkPrompt + callBridgeLlm + parseCycle1LineFormat. STRICT
3480
+ // buildCycle1ChunkPrompt + callAgentDispatch + parseCycle1LineFormat. STRICT
3018
3481
  // read-only — no UPDATE, no transaction, no commit.
3019
3482
  if (req.method === 'POST' && req.url === '/dev/cycle1-bench') {
3020
3483
  // Gate: env MIXDOG_DEV_BENCH=1 OR a runtime flag file, so it can be
3021
- // toggled without restarting Claude Code (env only reaches the worker
3484
+ // toggled without restarting the host agent (env only reaches the worker
3022
3485
  // on a full CC restart, not via dev-sync full-restart).
3023
3486
  const _devBenchOn = process.env.MIXDOG_DEV_BENCH === '1'
3024
3487
  || (DATA_DIR && fs.existsSync(path.join(DATA_DIR, '.dev-bench-enabled')))
@@ -3044,11 +3507,14 @@ const httpServer = http.createServer(async (req, res) => {
3044
3507
  : null
3045
3508
 
3046
3509
  // Lazy-load LLM + chunking helpers so production boot pays nothing.
3047
- const [{ buildCycle1ChunkPrompt, parseCycle1LineFormat }, { callBridgeLlm }, { resolveMaintenancePreset }] = await Promise.all([
3510
+ // Use the same in-process agent dispatch adapter as real cycle1 the legacy
3511
+ // agent-ipc callAgentDispatch() path is dead in the detached standalone
3512
+ // memory daemon (no connected IPC), so the dev bench must mirror prod.
3513
+ const [{ buildCycle1ChunkPrompt, parseCycle1LineFormat }, { resolveMaintenancePreset }] = await Promise.all([
3048
3514
  import('./lib/memory-cycle1.mjs'),
3049
- import('./lib/agent-ipc.mjs'),
3050
3515
  import('../shared/llm/index.mjs'),
3051
3516
  ])
3517
+ const benchCallLlm = getCycle1CallLlm()
3052
3518
 
3053
3519
  const CYCLE1_MIN_BATCH = 3
3054
3520
  const CYCLE1_SESSION_CAP = 10
@@ -3119,13 +3585,9 @@ const httpServer = http.createServer(async (req, res) => {
3119
3585
  const t0 = Date.now()
3120
3586
  let raw, error
3121
3587
  try {
3122
- raw = await callBridgeLlm({
3123
- role: 'cycle1-agent',
3124
- taskType: 'maintenance',
3125
- mode: 'cycle1',
3588
+ raw = await benchCallLlm({
3126
3589
  preset,
3127
3590
  timeout: TIMEOUT_MS,
3128
- cwd: null,
3129
3591
  }, userMessage)
3130
3592
  } catch (e) {
3131
3593
  error = e?.message ?? String(e)
@@ -3424,34 +3886,94 @@ export async function init() {
3424
3886
  process.on('exit', releaseMemoryOwnerLock)
3425
3887
  }
3426
3888
  const runtimeReady = _beginRuntimeInit()
3427
- const boundPort = await _startHttpServer()
3428
- await runtimeReady
3429
- advertiseMemoryPort(boundPort)
3889
+ let boundPort = null
3890
+ if (!memorySecondaryMode()) {
3891
+ boundPort = await _startHttpServer()
3892
+ await runtimeReady
3893
+ advertiseMemoryPort(boundPort)
3894
+ } else {
3895
+ await runtimeReady
3896
+ }
3430
3897
  if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
3431
3898
  __mixdogMemoryLog(`[boot-time] tag=memory-ready tMs=${Date.now()}\n`)
3432
3899
  process.send({ type: 'ready', port: boundPort })
3433
3900
  }
3434
3901
  __mixdogMemoryLog(`[memory-service] init() complete (entries unified mode, version=${PLUGIN_VERSION})\n`)
3902
+ touchDaemonIdleTimer('init')
3435
3903
  }
3436
3904
 
3437
3905
  export async function stop() {
3438
- _stopCycles()
3439
- await stopLlmWorker()
3440
- if (httpServer) await new Promise(resolve => httpServer.close(resolve))
3441
- await closeDatabase(DATA_DIR)
3442
- // Stop the PG postmaster after the connection pool has been drained.
3443
- // closeDatabase() only ends the client pool; without this the child
3444
- // postmaster keeps running after the memory service exits.
3445
- try {
3446
- const { stopPgForShutdown } = await import('./lib/pg/supervisor.mjs')
3447
- await stopPgForShutdown()
3448
- } catch {}
3449
- releaseLock()
3906
+ if (_stopPromise) return _stopPromise
3907
+ _stopPromise = (async () => {
3908
+ _stopCycles()
3909
+ if (_periodicAdvertiseTimer) {
3910
+ try { clearInterval(_periodicAdvertiseTimer) } catch {}
3911
+ _periodicAdvertiseTimer = null
3912
+ }
3913
+ _periodicAdvertiseInstalled = false
3914
+ _currentAdvertisedPort = null
3915
+ _pendingEmbeddingWarmup = null
3916
+ if (_idleShutdownTimer) {
3917
+ try { clearTimeout(_idleShutdownTimer) } catch {}
3918
+ _idleShutdownTimer = null
3919
+ }
3920
+ await stopLlmWorker()
3921
+ resetHttpListenErrorHandler()
3922
+ if (_httpBoundPort != null || _httpReadyPromise) {
3923
+ await new Promise(resolve => {
3924
+ try {
3925
+ httpServer.close(() => resolve())
3926
+ } catch {
3927
+ resolve()
3928
+ }
3929
+ })
3930
+ }
3931
+ _httpReadyPromise = null
3932
+ _httpBoundPort = null
3933
+ activePort = BASE_PORT
3934
+ if (_traceDb) {
3935
+ try { await closeTraceDatabase(DATA_DIR) } catch {}
3936
+ _traceDb = null
3937
+ }
3938
+ await closeDatabase(DATA_DIR)
3939
+ // Stop the PG postmaster after the connection pools have been drained.
3940
+ // closeDatabase() only ends the client pool; without this the child
3941
+ // postmaster keeps running after the memory service exits.
3942
+ if (!memorySecondaryMode()) {
3943
+ try {
3944
+ const { stopPgForShutdown } = await import('./lib/pg/supervisor.mjs')
3945
+ await stopPgForShutdown()
3946
+ } catch {}
3947
+ } else {
3948
+ __mixdogMemoryLog('[memory-service] secondary mode; leaving shared PG running\n')
3949
+ }
3950
+ db = null
3951
+ mainConfig = null
3952
+ _initialized = false
3953
+ _initPromise = null
3954
+ _bootTimestamp = null
3955
+ _transcriptOffsets = new Map()
3956
+ _cycle1InFlight = null
3957
+ _cycle2InFlight = false
3958
+ _cycle3InFlight = false
3959
+ _checkCyclesInFlight = false
3960
+ releaseLock()
3961
+ })().finally(() => {
3962
+ _stopPromise = null
3963
+ })
3964
+ return _stopPromise
3450
3965
  }
3451
3966
 
3452
3967
  let activePort = BASE_PORT
3453
3968
  let _httpReadyPromise = null
3454
3969
  let _httpBoundPort = null
3970
+ let _httpListenErrorHandler = null
3971
+
3972
+ function resetHttpListenErrorHandler() {
3973
+ if (!_httpListenErrorHandler) return
3974
+ try { httpServer.off('error', _httpListenErrorHandler) } catch {}
3975
+ _httpListenErrorHandler = null
3976
+ }
3455
3977
 
3456
3978
  function _startHttpServer() {
3457
3979
  if (_httpBoundPort != null) return Promise.resolve(_httpBoundPort)
@@ -3466,7 +3988,11 @@ function _startHttpServer() {
3466
3988
  resolve(boundPort)
3467
3989
  })
3468
3990
  }
3469
- httpServer.on('error', (err) => {
3991
+ _httpListenErrorHandler = (err) => {
3992
+ if (_httpBoundPort != null) {
3993
+ __mixdogMemoryLog(`[memory-service] HTTP error: ${err?.message || err}\n`)
3994
+ return
3995
+ }
3470
3996
  if (err.code === 'EADDRINUSE' && activePort < MAX_PORT) {
3471
3997
  activePort++
3472
3998
  tryListen()
@@ -3476,9 +4002,11 @@ function _startHttpServer() {
3476
4002
  tryListen()
3477
4003
  } else {
3478
4004
  __mixdogMemoryLog(`[memory-service] HTTP fatal: ${err.message}\n`)
4005
+ resetHttpListenErrorHandler()
3479
4006
  reject(err)
3480
4007
  }
3481
- })
4008
+ }
4009
+ httpServer.on('error', _httpListenErrorHandler)
3482
4010
  tryListen()
3483
4011
  })
3484
4012
  return _httpReadyPromise
@@ -3582,7 +4110,7 @@ if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
3582
4110
  // server with acquireLock + StdioServerTransport. Server-main spawnWorker
3583
4111
  // also forks this file with MIXDOG_WORKER_MODE='1'; that path uses the IPC
3584
4112
  // handler block above and acquireLock/init() as the single memory owner.
3585
- if (import.meta.url === pathToFileURL(process.argv[1] || '').href && process.env.MIXDOG_WORKER_MODE !== '1') {
4113
+ if (IS_MEMORY_ENTRY && process.env.MIXDOG_WORKER_MODE !== '1') {
3586
4114
  ;(async () => {
3587
4115
  acquireLock()
3588
4116
  process.on('exit', releaseLock)