mixdog 0.9.147 → 0.9.149

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 (265) hide show
  1. package/package.json +23 -3
  2. package/scripts/build-voice-runtime-unix.sh +101 -0
  3. package/scripts/embedding-quality-probe.mjs +96 -0
  4. package/scripts/generate-voice-runtime-manifest.mjs +300 -0
  5. package/scripts/generate-voice-runtime-manifest.test.mjs +135 -0
  6. package/scripts/native-binary-arch.mjs +30 -13
  7. package/scripts/native-binary-arch.test.mjs +40 -4
  8. package/scripts/plan-voice-runtime-release.mjs +73 -0
  9. package/scripts/prepare-native-assets.mjs +50 -9
  10. package/scripts/prune-embedding-runtime.mjs +3 -3
  11. package/scripts/sync-voice-runtime-manifest.mjs +46 -0
  12. package/scripts/verify-release-assets.mjs +106 -6
  13. package/scripts/verify-voice-runtime-release.mjs +89 -0
  14. package/scripts/voice-runtime-config.json +52 -0
  15. package/src/app.mjs +42 -5
  16. package/src/headless-exec.mjs +58 -32
  17. package/src/headless-exec.test.mjs +74 -13
  18. package/src/rules/lead/01-general.md +2 -0
  19. package/src/rules/shared/10-tool-workflow.md +7 -4
  20. package/src/rules/shared/30-exploration.md +5 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +28 -9
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +6 -1
  23. package/src/runtime/agent/orchestrator/config.mjs +53 -18
  24. package/src/runtime/agent/orchestrator/mcp/cancellation.test.mjs +139 -0
  25. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +34 -5
  26. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -10
  27. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +5 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +98 -0
  29. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.test.mjs +90 -0
  30. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +67 -16
  31. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +4 -82
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +0 -5
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -121
  34. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +48 -0
  35. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.test.mjs +215 -0
  36. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +44 -48
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +54 -31
  38. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +56 -2
  39. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +34 -2
  40. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +61 -1
  41. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +6 -1
  42. package/src/runtime/agent/orchestrator/providers/gemini.mjs +61 -15
  43. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +120 -1
  44. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +9 -0
  45. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +0 -3
  46. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +187 -84
  47. package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +32 -9
  48. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +78 -33
  49. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +21 -0
  50. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +36 -36
  51. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +16 -6
  52. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +50 -213
  53. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +135 -126
  54. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +20 -11
  55. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +112 -36
  56. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +220 -50
  57. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +112 -9
  58. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -0
  59. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +130 -60
  60. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
  61. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +28 -10
  62. package/src/runtime/agent/orchestrator/providers/registry.mjs +122 -95
  63. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +69 -3
  64. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +4 -22
  65. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +7 -14
  66. package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +128 -0
  67. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +5 -0
  68. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +27 -48
  69. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +19 -14
  70. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -43
  71. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +5 -1
  72. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +43 -5
  73. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -2
  74. package/src/runtime/agent/orchestrator/session/manager/pending-messages-persistence.test.mjs +325 -0
  75. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +599 -195
  76. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +22 -0
  77. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +9 -2
  78. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +2 -0
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  80. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +26 -16
  81. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +23 -11
  82. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +118 -3
  83. package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +332 -0
  84. package/src/runtime/agent/orchestrator/session/store.mjs +38 -1
  85. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +27 -6
  86. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -20
  87. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +291 -128
  88. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +46 -9
  89. package/src/runtime/agent/orchestrator/tools/builtin/edit-encoding-eol.test.mjs +224 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +53 -3
  91. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +255 -25
  92. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +87 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +1 -1
  94. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +132 -9
  95. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +10 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +29 -3
  97. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +14 -0
  99. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +180 -31
  100. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -1
  101. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +32 -5
  102. package/src/runtime/agent/orchestrator/tools/builtin/native-search-health.test.mjs +5 -2
  103. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +10 -1
  104. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +38 -10
  105. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +12 -0
  106. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +31 -1
  107. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +11 -1
  108. package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +22 -4
  109. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +6 -8
  110. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +25 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +152 -133
  112. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +114 -42
  113. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +129 -15
  114. package/src/runtime/agent/orchestrator/tools/code-graph/loose-file-target.test.mjs +72 -0
  115. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +7 -8
  116. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +4 -4
  117. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +201 -19
  118. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +26 -3
  119. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +10 -1
  120. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-search-scope.test.mjs +55 -0
  121. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +115 -25
  122. package/src/runtime/agent/orchestrator/tools/code-graph/text-span-mask.test.mjs +268 -0
  123. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -3
  124. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +296 -46
  125. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.test.mjs +104 -0
  126. package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +136 -0
  127. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +127 -38
  128. package/src/runtime/agent/orchestrator/tools/patch/engine-contract-gate.test.mjs +661 -0
  129. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +350 -9
  130. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +418 -44
  131. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -3
  132. package/src/runtime/agent/orchestrator/tools/patch/patch-codec-eol.test.mjs +552 -0
  133. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -15
  134. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  135. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +284 -109
  136. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +51 -21
  137. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +44 -27
  138. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +6 -1
  139. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  140. package/src/runtime/attachments/store.mjs +11 -3
  141. package/src/runtime/channels/data/voice-runtime-manifest.json +46 -70
  142. package/src/runtime/channels/lib/event-queue.mjs +51 -41
  143. package/src/runtime/channels/lib/scheduler.mjs +23 -28
  144. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -17
  145. package/src/runtime/channels/lib/voice-runtime-fetcher.test.mjs +17 -0
  146. package/src/runtime/channels/lib/webhook.mjs +39 -20
  147. package/src/runtime/channels/lib/whisper-server.mjs +46 -1
  148. package/src/runtime/channels/lib/worker-main.mjs +3 -1
  149. package/src/runtime/media/adapters/gemini-video.mjs +20 -4
  150. package/src/runtime/media/jobs.mjs +13 -0
  151. package/src/runtime/media/renditions.mjs +7 -1
  152. package/src/runtime/media/store.mjs +42 -22
  153. package/src/runtime/memory/index.mjs +4 -9
  154. package/src/runtime/memory/lib/agent-ipc.mjs +1 -6
  155. package/src/runtime/memory/lib/archive-security.test.mjs +21 -1
  156. package/src/runtime/memory/lib/core-memory-store.mjs +28 -50
  157. package/src/runtime/memory/lib/embedding-model-config.mjs +37 -1
  158. package/src/runtime/memory/lib/embedding-model-config.test.mjs +33 -0
  159. package/src/runtime/memory/lib/embedding-provider.mjs +34 -12
  160. package/src/runtime/memory/lib/embedding-worker.mjs +37 -5
  161. package/src/runtime/memory/lib/ko-morph.mjs +46 -15
  162. package/src/runtime/memory/lib/memory-action-handlers.mjs +18 -1
  163. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -18
  164. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -18
  165. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +98 -45
  166. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +71 -0
  167. package/src/runtime/memory/lib/memory-cycle2-shared.test.mjs +78 -0
  168. package/src/runtime/memory/lib/memory-cycle2.mjs +37 -3
  169. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  170. package/src/runtime/memory/lib/memory-embedding-migration.test.mjs +50 -0
  171. package/src/runtime/memory/lib/memory-recall-store.mjs +9 -24
  172. package/src/runtime/memory/lib/memory.mjs +50 -12
  173. package/src/runtime/memory/lib/pg/process.mjs +1 -10
  174. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -9
  175. package/src/runtime/memory/lib/query-handlers.mjs +2 -4
  176. package/src/runtime/memory/lib/session-ingest-runtime.mjs +2 -2
  177. package/src/runtime/shared/background-tasks.mjs +15 -0
  178. package/src/runtime/shared/background-tasks.test.mjs +78 -0
  179. package/src/runtime/shared/child-guardian.mjs +2 -10
  180. package/src/runtime/shared/pid-liveness.mjs +27 -0
  181. package/src/runtime/shared/pristine-execution-contract.json +0 -1
  182. package/src/runtime/shared/runtime-root.mjs +1 -11
  183. package/src/runtime/shared/service-discovery.mjs +1 -17
  184. package/src/runtime/shared/singleton-owner.mjs +1 -11
  185. package/src/runtime/shared/staged-update.mjs +2 -12
  186. package/src/runtime/shared/tool-status.mjs +5 -1
  187. package/src/runtime/shared/tool-surface.mjs +15 -7
  188. package/src/runtime/shared/turn-snapshot-store.mjs +168 -0
  189. package/src/runtime/shared/turn-snapshot.mjs +137 -13
  190. package/src/runtime/shared/turn-worktree-snapshot.mjs +110 -18
  191. package/src/runtime/shared/webhook-session-run.mjs +16 -2
  192. package/src/runtime/web-search/lib/http-fetch.mjs +34 -4
  193. package/src/runtime/web-search/lib/state.mjs +15 -15
  194. package/src/runtime/web-search/lib/web-tools.mjs +46 -5
  195. package/src/session-runtime/context-status.mjs +4 -12
  196. package/src/session-runtime/runtime-core.mjs +20 -1
  197. package/src/session-runtime/session-lifecycle.mjs +9 -0
  198. package/src/standalone/agent-dispatch-broker.mjs +33 -2
  199. package/src/standalone/agent-tool/job-task-reconcile.mjs +78 -0
  200. package/src/standalone/agent-tool/notify.mjs +3 -1
  201. package/src/standalone/agent-tool/spawn-flow.mjs +52 -70
  202. package/src/standalone/agent-tool/tag-registry.mjs +116 -33
  203. package/src/standalone/agent-tool/tool-def.mjs +2 -2
  204. package/src/standalone/agent-tool/worker-index.mjs +8 -11
  205. package/src/standalone/agent-tool/worker-rows.mjs +4 -1
  206. package/src/standalone/agent-tool.mjs +20 -55
  207. package/src/standalone/agent-watchdog-registry.mjs +28 -4
  208. package/src/standalone/channel-client.mjs +47 -15
  209. package/src/standalone/channel-transport.mjs +44 -25
  210. package/src/standalone/daemon.mjs +31 -2
  211. package/src/standalone/hook-bus/config.mjs +25 -9
  212. package/src/standalone/hook-bus/handlers.mjs +16 -52
  213. package/src/standalone/hook-bus.mjs +29 -23
  214. package/src/standalone/memory-runtime-proxy.mjs +1 -16
  215. package/src/standalone/session-client.mjs +86 -23
  216. package/src/standalone/session-protocol.mjs +0 -2
  217. package/src/standalone/session-runtime-host-health.test.mjs +5 -4
  218. package/src/standalone/session-runtime-host.mjs +101 -6
  219. package/src/standalone/session-runtime-record.mjs +1 -5
  220. package/src/standalone/session-runtime-worker.mjs +52 -57
  221. package/src/standalone/session-service.mjs +4 -1
  222. package/src/standalone/session-transport.mjs +56 -25
  223. package/src/tui/App.jsx +40 -49
  224. package/src/tui/app/app-view.jsx +14 -21
  225. package/src/tui/app/core-memory-picker.mjs +29 -16
  226. package/src/tui/app/create-app-pickers.mjs +15 -36
  227. package/src/tui/app/extension-pickers.mjs +60 -49
  228. package/src/tui/app/maintenance-pickers.mjs +100 -57
  229. package/src/tui/app/message-selector.mjs +6 -5
  230. package/src/tui/app/model-picker.mjs +30 -13
  231. package/src/tui/app/onboarding-steps.mjs +21 -12
  232. package/src/tui/app/panel-epoch.mjs +56 -0
  233. package/src/tui/app/panel-epoch.test.mjs +1009 -0
  234. package/src/tui/app/panel-surface.mjs +98 -0
  235. package/src/tui/app/project-picker.mjs +30 -29
  236. package/src/tui/app/prompt-submit.mjs +192 -97
  237. package/src/tui/app/prompt-submit.test.mjs +206 -0
  238. package/src/tui/app/provider-setup-picker.mjs +74 -43
  239. package/src/tui/app/resume-picker.mjs +11 -8
  240. package/src/tui/app/route-pickers.mjs +45 -28
  241. package/src/tui/app/settings-picker.mjs +69 -18
  242. package/src/tui/app/shell-layout.mjs +1 -3
  243. package/src/tui/app/slash-commands.mjs +0 -2
  244. package/src/tui/app/slash-dispatch.mjs +4 -2
  245. package/src/tui/app/text-entry-policy.mjs +25 -0
  246. package/src/tui/app/theme-effort-pickers.mjs +11 -16
  247. package/src/tui/app/usage-context-panels.mjs +21 -25
  248. package/src/tui/app/use-global-key-input.mjs +3 -2
  249. package/src/tui/app/use-prompt-draft-flow.mjs +6 -18
  250. package/src/tui/app/use-prompt-handlers.mjs +3 -2
  251. package/src/tui/app/use-welcome-prompt-hint.mjs +17 -3
  252. package/src/tui/components/Picker.jsx +1 -1
  253. package/src/tui/components/TextEntryPanel.jsx +7 -2
  254. package/src/tui/dist/index.mjs +652 -561
  255. package/src/tui/lib/voice-setup.mjs +1 -2
  256. package/src/tui/session/agent-job-feed.mjs +73 -11
  257. package/src/tui/session/session-api-ext.mjs +5 -6
  258. package/src/tui/session/session-api.mjs +1 -0
  259. package/src/tui/session/tool-approval.mjs +15 -5
  260. package/src/tui/session/tui-steering-persist.mjs +33 -19
  261. package/src/tui/session/tui-steering-persist.test.mjs +104 -0
  262. package/src/tui/session-local.mjs +0 -22
  263. package/scripts/.tmp-coverage-report.mjs +0 -96
  264. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.mjs +0 -57
  265. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.test.mjs +0 -57
@@ -56,17 +56,22 @@ let _kiwi = null
56
56
  let _log = () => {}
57
57
  let _initMs = 0
58
58
  let _idleTimer = null
59
+ const _stemCache = new Map()
60
+ const STEM_CACHE_LIMIT = 256
59
61
  // Idle window before the analyzer is released. A cold rebuild costs ~1.5-1.8s
60
62
  // and BLOCKS the recall path (buildFtsQuery awaits it for Hangul queries), so a
61
- // 60s window expired between ordinary conversational turns and made nearly
62
- // every interactive recall pay that rebuild. 5 minutes keeps the analyzer alive
63
- // across a normal back-and-forth while still reclaiming its ~560MB once the
64
- // session genuinely goes quiet. MIXDOG_KO_MORPH_IDLE_TIMEOUT_MS overrides;
63
+ // 60s expired between ordinary conversational turns and made nearly every
64
+ // interactive recall pay that rebuild. Two minutes covers a normal follow-up
65
+ // while returning the analyzer's native memory promptly once recall goes quiet.
66
+ // MIXDOG_KO_MORPH_IDLE_TIMEOUT_MS overrides;
65
67
  // 0 disables the release entirely.
66
- const _envIdleMs = Number(process.env.MIXDOG_KO_MORPH_IDLE_TIMEOUT_MS)
67
- const IDLE_TIMEOUT_MS = Number.isFinite(_envIdleMs) && _envIdleMs >= 0
68
- ? _envIdleMs
69
- : 300_000
68
+ export function _resolveKoMorphIdleTimeout(value) {
69
+ const configured = Number(value)
70
+ return Number.isFinite(configured) && configured >= 0 ? configured : 120_000
71
+ }
72
+ const IDLE_TIMEOUT_MS = _resolveKoMorphIdleTimeout(
73
+ process.env.MIXDOG_KO_MORPH_IDLE_TIMEOUT_MS,
74
+ )
70
75
 
71
76
  export function isReady() { return _state === 'ready' && _kiwi != null }
72
77
  export function state() { return _state }
@@ -80,6 +85,7 @@ function releaseLoaded(reason = '') {
80
85
  _state = 'idle'
81
86
  _initPromise = null
82
87
  _initMs = 0
88
+ _stemCache.clear()
83
89
  try { current?.dispose?.() } catch {}
84
90
  if (current && reason) _log(`[memory-service] kiwi morph ${reason} — analyzer released\n`)
85
91
  }
@@ -200,13 +206,20 @@ function readModelFiles(dir) {
200
206
  for (const f of REQUIRED_MODEL_FILES) {
201
207
  modelFiles[f] = new Uint8Array(fs.readFileSync(path.join(dir, f)))
202
208
  }
203
- for (const f of OPTIONAL_MODEL_FILES) {
204
- const p = path.join(dir, f)
205
- if (fs.existsSync(p)) modelFiles[f] = new Uint8Array(fs.readFileSync(p))
206
- }
207
209
  return modelFiles
208
210
  }
209
211
 
212
+ export function _kiwiBuildArgs(modelFiles) {
213
+ return {
214
+ modelFiles,
215
+ modelType: 'knlm',
216
+ loadDefaultDict: true,
217
+ loadMultiDict: false,
218
+ loadTypoDict: false,
219
+ typos: 'none',
220
+ }
221
+ }
222
+
210
223
  // Lazy, idempotent, never-throwing init. Fire-and-forget at boot.
211
224
  export async function init(dataDir, log = () => {}) {
212
225
  _log = typeof log === 'function' ? log : (() => {})
@@ -232,9 +245,10 @@ export async function init(dataDir, log = () => {}) {
232
245
  const dir = await downloadAndExtractModel(dataDir)
233
246
  const modelFiles = readModelFiles(dir)
234
247
  const builder = await KiwiBuilder.create(wasmPath)
235
- // modelType 'knlm' = fast KnLM (sj.knlm); loadMultiDict/loadTypoDict only
236
- // engage if the optional files were present.
237
- _kiwi = await builder.build({ modelFiles, modelType: 'knlm' })
248
+ // Recall needs content stems, not typo correction or the 12 MB polysemous
249
+ // proper-noun dictionary. Keep the KNLM and default dictionary for Korean
250
+ // accuracy while leaving every optional model out of WASM memory.
251
+ _kiwi = await builder.build(_kiwiBuildArgs(modelFiles))
238
252
  _state = 'ready'
239
253
  _initMs = Date.now() - t0
240
254
  _log(`[memory-service] kiwi morph ready in ${_initMs}ms (model ${KIWI_MODEL_VERSION}, rss≈${Math.round(process.memoryUsage().rss / 1e6)}MB)\n`)
@@ -266,6 +280,15 @@ export function analyze(text) {
266
280
  // Content-morpheme stem forms for a Korean phrase. null when not ready.
267
281
  // Example: an inflected Korean noun/verb phrase is reduced to content stems.
268
282
  export function stems(text) {
283
+ const source = String(text ?? '')
284
+ if (!source) return null
285
+ const cached = _stemCache.get(source)
286
+ if (cached) {
287
+ _stemCache.delete(source)
288
+ _stemCache.set(source, cached)
289
+ touchIdleTimer()
290
+ return cached
291
+ }
269
292
  const tokens = analyze(text)
270
293
  if (!tokens) return null
271
294
  const out = []
@@ -274,6 +297,14 @@ export function stems(text) {
274
297
  const form = String(t.str || '').trim()
275
298
  if (form.length >= 1) out.push(form)
276
299
  }
300
+ if (source.length <= 4_096) {
301
+ _stemCache.set(source, out)
302
+ while (_stemCache.size > STEM_CACHE_LIMIT) {
303
+ const oldest = _stemCache.keys().next().value
304
+ if (oldest === undefined) break
305
+ _stemCache.delete(oldest)
306
+ }
307
+ }
277
308
  return out
278
309
  }
279
310
 
@@ -21,6 +21,7 @@ import {
21
21
  CYCLE2_ACTIVE_TARGET_CAP,
22
22
  } from './memory-cycle.mjs'
23
23
  import { getInFlightCycle1 } from './memory-cycle1.mjs'
24
+ import { isStoreFault } from './memory-cycle2-shared.mjs'
24
25
  import { pruneOldEntries } from './memory-maintenance-store.mjs'
25
26
  import { computeEntryScore } from './memory-score.mjs'
26
27
  import { runFullBackfill } from './memory-ops-policy.mjs'
@@ -869,7 +870,19 @@ export function createMemoryActionHandlers({
869
870
  }
870
871
  acted.add(targetId)
871
872
  filteredSources.forEach(s => acted.add(Number(s)))
872
- const moved = await applyMerge(db, targetId, filteredSources)
873
+ // Same optimistic contract as the archived/update verbs above: the
874
+ // gate's verdict is only applied while target and sources still
875
+ // hold the state it judged.
876
+ const expectedBySource = {}
877
+ for (const sourceId of filteredSources.map(Number)) {
878
+ const sourceRow = rowById.get(sourceId)
879
+ if (sourceRow) expectedBySource[sourceId] = { status: sourceRow.status, summary: sourceRow.summary }
880
+ }
881
+ const targetRow = rowById.get(targetId)
882
+ const moved = await applyMerge(db, targetId, filteredSources, {
883
+ expectedBySource,
884
+ expectedTarget: targetRow ? { status: targetRow.status, summary: targetRow.summary } : null,
885
+ })
873
886
  if (moved > 0) {
874
887
  merged += moved
875
888
  if (typeof act.element === 'string' || typeof act.summary === 'string') {
@@ -883,6 +896,10 @@ export function createMemoryActionHandlers({
883
896
  }
884
897
  }
885
898
  } catch (err) {
899
+ // Same split as the cycle2 apply path: a store fault stops the run
900
+ // (the DB state is unknown), while a guard-rejected verdict stays an
901
+ // ordinary counted error.
902
+ if (isStoreFault(err)) throw err
886
903
  log(`[retro_eval_active] action error (id=${act?.entry_id}): ${err.message}\n`)
887
904
  errors += 1
888
905
  }
@@ -1,4 +1,5 @@
1
1
  import { __mixdogMemoryLog } from './memory-log.mjs';
2
+ import { createSemaphore } from './memory-cycle2-shared.mjs';
2
3
 
3
4
  import { cleanMemoryText } from './memory.mjs'
4
5
  import { resolveMaintenancePreset } from '../../shared/llm/index.mjs'
@@ -278,24 +279,6 @@ function logCycle1Throttled(key, message, intervalMs = 60_000) {
278
279
  __mixdogMemoryLog(message)
279
280
  }
280
281
 
281
- // Tiny inline semaphore — bounds cycle1 window fan-out.
282
- function createSemaphore(limit) {
283
- const cap = Math.max(1, Number(limit) || 1)
284
- let active = 0
285
- const queue = []
286
- const release = () => {
287
- active -= 1
288
- const next = queue.shift()
289
- if (next) next()
290
- }
291
- return async (fn) => {
292
- if (active >= cap) await new Promise(res => queue.push(res))
293
- active += 1
294
- try { return await fn() }
295
- finally { release() }
296
- }
297
- }
298
-
299
282
  export function packCycle1Windows(rowsBySession, packetSize = CYCLE1_PACKET_MAX_ROWS, maxPackets = CYCLE1_MAX_PACKETS) {
300
283
  const size = Math.min(CYCLE1_PACKET_MAX_ROWS, Math.max(1, Number(packetSize) || CYCLE1_PACKET_MAX_ROWS))
301
284
  const cap = Math.min(CYCLE1_MAX_PACKETS, Math.max(1, Number(maxPackets) || CYCLE1_MAX_PACKETS))
@@ -7,7 +7,7 @@ import { join } from 'path'
7
7
  import { resolveMaintenancePreset } from '../../shared/llm/index.mjs'
8
8
  import { callAgentDispatch } from './agent-ipc.mjs'
9
9
  import { listCore } from './core-memory-store.mjs'
10
- import { __mixdogMemoryLog, throwIfAborted, resourceDir } from './memory-cycle2-shared.mjs'
10
+ import { __mixdogMemoryLog, throwIfAborted, resourceDir, createSemaphore } from './memory-cycle2-shared.mjs'
11
11
 
12
12
  export const CYCLE2_ACTIVE_TARGET_CAP = 100
13
13
  const CYCLE2_PACKET_MATERIAL_CAP = 50
@@ -213,23 +213,6 @@ export function packUnifiedGatePackets(rows, candidates, options = {}) {
213
213
  return { packets, deferredIds }
214
214
  }
215
215
 
216
- function createSemaphore(limit) {
217
- const cap = Math.max(1, Number(limit) || 1)
218
- let active = 0
219
- const queue = []
220
- const release = () => {
221
- active -= 1
222
- const next = queue.shift()
223
- if (next) next()
224
- }
225
- return async (fn) => {
226
- if (active >= cap) await new Promise(resolve => queue.push(resolve))
227
- active += 1
228
- try { return await fn() }
229
- finally { release() }
230
- }
231
- }
232
-
233
216
  function formatLineageCandidates(rows, candidates) {
234
217
  const lines = []
235
218
  rows.forEach((row, index) => {
@@ -4,7 +4,7 @@
4
4
  import { deleteRootEmbedding, syncRootEmbedding } from './memory-embed.mjs'
5
5
  import { resolveMaintenancePreset } from '../../shared/llm/index.mjs'
6
6
  import { callAgentDispatch } from './agent-ipc.mjs'
7
- import { __mixdogMemoryLog, throwIfAborted } from './memory-cycle2-shared.mjs'
7
+ import { __mixdogMemoryLog, throwIfAborted, markStoreFault } from './memory-cycle2-shared.mjs'
8
8
 
9
9
  const TIER1_THRESHOLD = 0.78
10
10
  const TIER2_LOW = 0.65
@@ -268,74 +268,122 @@ export async function applyMerge(db, targetId, sourceIds, options = {}) {
268
268
  const signal = options?.signal
269
269
  throwIfAborted(signal)
270
270
  if (!Number.isFinite(targetId)) return 0
271
- const targetRes = await db.query(
272
- `SELECT id, project_id FROM entries WHERE id = $1 AND is_root = 1`,
273
- [targetId],
274
- )
275
- throwIfAborted(signal)
276
- const target = targetRes.rows[0]
277
- if (!target) return 0
271
+ // Optimistic guards keyed by source id: { status, summary } as observed when
272
+ // the merge verdict was produced (same contract as applyUpdate's `expected`).
273
+ const expectedBySource = options?.expectedBySource ?? null
274
+ const readExpected = (sid) => (
275
+ typeof expectedBySource?.get === 'function' ? expectedBySource.get(sid) : expectedBySource?.[sid]
276
+ ) ?? null
277
+ // Snapshot of the merge TARGET as the verdict saw it. A merge is a statement
278
+ // about both rows, so the target has to be validated exactly like a source.
279
+ const expectedTarget = options?.expectedTarget ?? null
278
280
  let moved = 0
279
281
  for (const src of sourceIds) {
280
282
  throwIfAborted(signal)
281
283
  const sid = Number(src)
282
284
  if (!Number.isFinite(sid) || sid === targetId) continue
283
- const srcRes = await db.query(
284
- `SELECT id, project_id, status FROM entries WHERE id = $1 AND is_root = 1`,
285
- [sid],
286
- )
287
- throwIfAborted(signal)
288
- const srcRow = srcRes.rows[0]
289
- if (!srcRow) continue
290
- if (target.project_id !== srcRow.project_id) {
291
- __mixdogMemoryLog(
292
- `[cycle2] merge rejected: cross-pool (target=${targetId} project_id=${target.project_id ?? 'COMMON'} src=${sid} project_id=${srcRow.project_id ?? 'COMMON'})\n`,
293
- )
294
- continue
295
- }
296
- // Floor guard: archiving an active source row shrinks the active pool, so
297
- // it must reserve from the shared floor budget. Over-budget merges are
298
- // skipped (source stays active); pending sources never reserve.
299
- const srcActive = srcRow.status === 'active'
300
- if (srcActive && options.floorGuard && !options.floorGuard.reserve()) {
301
- __mixdogMemoryLog(`[cycle2] merge source archive skipped (floor guard): target=${targetId} src=${sid}\n`)
302
- continue
303
- }
304
- let archivedPrevStatus = null
285
+ let reserved = false
286
+ let archived = false
287
+ let skip = null
305
288
  try {
306
289
  // Archive is the guarded mutation unit: the reservation is only kept if
307
290
  // this transaction commits. Embedding cleanup is deliberately OUTSIDE
308
291
  // this try — a post-commit cleanup failure must NOT refund the budget,
309
292
  // since the active row is already archived.
310
293
  await db.transaction(async (tx) => {
294
+ // Target AND source are read inside the transaction and locked in id
295
+ // order (deterministic ordering, so two concurrent merges cannot
296
+ // deadlock). Reading them outside meant the verdict was applied to
297
+ // state that could already have changed content or status.
298
+ const { rows } = await tx.query(
299
+ `SELECT id, project_id, status, summary FROM entries
300
+ WHERE id = ANY($1::bigint[]) AND is_root = 1
301
+ ORDER BY id FOR UPDATE`,
302
+ [[targetId, sid]],
303
+ )
304
+ const target = rows.find((r) => Number(r.id) === Number(targetId))
305
+ const srcRow = rows.find((r) => Number(r.id) === sid)
306
+ if (!target) { skip = `target ${targetId} is not a root`; return }
307
+ if (!srcRow) { skip = `source ${sid} is not a root`; return }
308
+ if (target.project_id !== srcRow.project_id) {
309
+ skip = `cross-pool (target=${targetId} project_id=${target.project_id ?? 'COMMON'} src=${sid} project_id=${srcRow.project_id ?? 'COMMON'})`
310
+ return
311
+ }
312
+ // Target guards: folding a live source into a target that has since been
313
+ // archived (or rewritten) destroys the source without a surviving
314
+ // consolidated row.
315
+ if (target.status === 'archived') { skip = `target ${targetId} is archived`; return }
316
+ if (expectedTarget && typeof expectedTarget.status === 'string' && target.status !== expectedTarget.status) {
317
+ skip = `target ${targetId} status moved (${expectedTarget.status} → ${target.status})`
318
+ return
319
+ }
320
+ if (expectedTarget && typeof expectedTarget.summary === 'string' && target.summary !== expectedTarget.summary) {
321
+ skip = `target ${targetId} content changed since the verdict`
322
+ return
323
+ }
324
+ // Status guard: never re-archive an already archived source.
325
+ if (srcRow.status === 'archived') { skip = `source ${sid} already archived`; return }
326
+ // Content guard: the source must still say what the verdict judged.
327
+ const expected = readExpected(sid)
328
+ if (expected && typeof expected.status === 'string' && srcRow.status !== expected.status) {
329
+ skip = `source ${sid} status moved (${expected.status} → ${srcRow.status})`
330
+ return
331
+ }
332
+ if (expected && typeof expected.summary === 'string' && srcRow.summary !== expected.summary) {
333
+ skip = `source ${sid} content changed since the verdict`
334
+ return
335
+ }
336
+ // Floor guard: archiving an active source row shrinks the active pool,
337
+ // so it must reserve from the shared floor budget. Over-budget merges
338
+ // are skipped (source stays active); pending sources never reserve.
339
+ if (srcRow.status === 'active' && options.floorGuard) {
340
+ if (!options.floorGuard.reserve()) {
341
+ skip = `floor guard: target=${targetId} src=${sid}`
342
+ return
343
+ }
344
+ reserved = true
345
+ }
311
346
  await tx.query(
312
347
  `UPDATE entries SET chunk_root = $1, project_id = $2 WHERE chunk_root = $3 AND id != $4 AND is_root = 0`,
313
348
  [targetId, target.project_id, sid, sid],
314
349
  )
315
- // Capture prev status in the same statement so a stale snapshot (the
316
- // row was already archived by a concurrent write) can refund below.
317
350
  const ar = await tx.query(
318
- `WITH pre AS (SELECT id, status AS prev FROM entries WHERE id = $1 AND is_root = 1)
319
- UPDATE entries SET status = 'archived'
320
- FROM pre WHERE entries.id = pre.id AND entries.is_root = 1
321
- RETURNING pre.prev AS prev_status`,
322
- [sid],
351
+ `UPDATE entries SET status = 'archived'
352
+ WHERE id = $1 AND is_root = 1 AND status IS NOT DISTINCT FROM $2::entry_status`,
353
+ [sid, srcRow.status],
323
354
  )
324
- archivedPrevStatus = ar.rows?.[0]?.prev_status ?? null
355
+ archived = Number(ar.rowCount ?? ar.affectedRows ?? 0) > 0
325
356
  })
326
357
  } catch (err) {
327
- if (srcActive && options.floorGuard) options.floorGuard.refund()
358
+ // Store fault: the transaction rolled back, or its COMMIT outcome is
359
+ // unknown. The reservation is deliberately NOT refunded — the source may
360
+ // in fact be archived, so returning the budget would let a later archive
361
+ // spend it twice and breach the active floor. Marked so callers can tell
362
+ // it apart from the guard rejections above, which return normally.
363
+ if (reserved && options.floorGuard) {
364
+ __mixdogMemoryLog(`[cycle2] merge floor reservation withheld after store fault: target=${targetId} src=${sid}\n`)
365
+ }
328
366
  __mixdogMemoryLog(`[cycle2] merge failed (target=${targetId} src=${sid}): ${err.message}\n`)
367
+ throw markStoreFault(err)
368
+ }
369
+ if (skip) __mixdogMemoryLog(`[cycle2] merge source skipped: ${skip}\n`)
370
+ if (!archived) {
371
+ if (reserved && options.floorGuard) options.floorGuard.refund()
329
372
  continue
330
373
  }
331
- // Archive committed. If the source was no longer active at UPDATE time,
332
- // the reservation did not cover a real active→archived transition — refund.
333
- if (srcActive && options.floorGuard && archivedPrevStatus !== 'active') options.floorGuard.refund()
334
374
  // Archive committed — best-effort embedding cleanup only. The next abort
335
375
  // checkpoint is before the next source.
336
376
  moved += 1
337
- try { await deleteRootEmbedding(db, sid) }
338
- catch (err) { __mixdogMemoryLog(`[cycle2] merge embedding cleanup failed (target=${targetId} src=${sid}): ${err.message}\n`) }
377
+ try {
378
+ await deleteRootEmbedding(db, sid)
379
+ } catch (err) {
380
+ // The cleanup is a SECOND transaction, so its failure is a store fault of
381
+ // exactly the same kind — including the commit-ambiguous case. Absorbing
382
+ // it here kept archiving later sources against a store whose state is
383
+ // already unknown, which is the invariant the archive path enforces.
384
+ __mixdogMemoryLog(`[cycle2] merge embedding cleanup failed (target=${targetId} src=${sid}): ${err.message}\n`)
385
+ throw markStoreFault(err)
386
+ }
339
387
  }
340
388
  return moved
341
389
  }
@@ -432,7 +480,12 @@ export async function runPhaseMerge(db, options = {}) {
432
480
  const loser = keeper.id === a.id ? b : a
433
481
  // phase_merge only pairs active rows, so the loser archive is an active
434
482
  // demotion — thread the floor budget through applyMerge.
435
- const moved = await applyMerge(db, keeper.id, [loser.id], { signal, floorGuard })
483
+ const moved = await applyMerge(db, keeper.id, [loser.id], {
484
+ signal,
485
+ floorGuard,
486
+ expectedBySource: { [loser.id]: { status: loser.status, summary: loser.summary } },
487
+ expectedTarget: { status: keeper.status, summary: keeper.summary },
488
+ })
436
489
  if (moved > 0) {
437
490
  merged += moved
438
491
  mergedIds.add(loser.id)
@@ -11,6 +11,77 @@ export function throwIfAborted(signal) {
11
11
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
12
12
  }
13
13
 
14
+ // Tiny inline semaphore — bounds cycle fan-out (cycle1 windows, cycle2 gate
15
+ // packets). One implementation so the two concurrency caps cannot drift.
16
+ export function createSemaphore(limit) {
17
+ const cap = Math.max(1, Number(limit) || 1)
18
+ let active = 0
19
+ const queue = []
20
+ const release = () => {
21
+ active -= 1
22
+ const next = queue.shift()
23
+ if (next) next()
24
+ }
25
+ return async (fn) => {
26
+ if (active >= cap) await new Promise(resolve => queue.push(resolve))
27
+ active += 1
28
+ try { return await fn() }
29
+ finally { release() }
30
+ }
31
+ }
32
+
33
+ // Two error classes travel through the cycle2 apply paths and must never be
34
+ // confused:
35
+ // * STORE FAULT — the database itself failed the write (transaction rolled
36
+ // back, or its COMMIT outcome is unknown). Nothing about the verdict was
37
+ // wrong, and the store's state is no longer known, so the run stops.
38
+ // * verdict rejection — a guard refused the mutation (stale snapshot, status
39
+ // moved, content changed, floor budget exhausted). The store is healthy;
40
+ // these return normally and are counted as ordinary rejections/errors.
41
+ // Only writers raise store faults, via markStoreFault; callers branch on
42
+ // isStoreFault before absorbing an error into a per-action counter.
43
+ // Classification is TYPE-based, never text-based: the writer raises a dedicated
44
+ // error class, and `err instanceof MemoryStoreFault` is the decision. An
45
+ // explicit own field (`isMemoryStoreFault` + `code`) corroborates it for the
46
+ // cross-realm case where two copies of this module exist. Nothing about the
47
+ // error's message or name participates — a foreign provider/DB error is never
48
+ // reclassified because of how its text happens to read, and a store fault keeps
49
+ // the original message verbatim so logs stay honest.
50
+ //
51
+ // Boundaries this signal actually crosses: none that serialize. The writer
52
+ // (applyMerge) throws and the deciders (the cycle2 apply loop and
53
+ // retro_eval_active) catch it in the same process, same module registry, same
54
+ // tick. runCycle2 converts it at its own boundary into a plain
55
+ // { ok: false, error: <string>, storeFault: <boolean> } result, so the explicit
56
+ // boolean — not a re-parsed Error — is what any IPC/worker hop carries onward.
57
+ export const MEMORY_STORE_FAULT_CODE = 'MEMORY_STORE_FAULT'
58
+
59
+ export class MemoryStoreFault extends Error {
60
+ constructor(message, options) {
61
+ super(message, options)
62
+ this.name = 'MemoryStoreFault'
63
+ this.code = MEMORY_STORE_FAULT_CODE
64
+ this.isMemoryStoreFault = true
65
+ }
66
+ }
67
+
68
+ export function markStoreFault(err) {
69
+ if (isStoreFault(err)) return err
70
+ const cause = err instanceof Error ? err : new Error(String(err))
71
+ // The original is never mutated (frozen/sealed store errors are ordinary):
72
+ // it is carried as `cause`, with its message copied verbatim — unprefixed —
73
+ // and its stack preserved so the failing statement stays visible.
74
+ const fault = new MemoryStoreFault(cause.message || String(cause), { cause })
75
+ if (typeof cause.stack === 'string') fault.stack = cause.stack
76
+ return fault
77
+ }
78
+
79
+ export function isStoreFault(err) {
80
+ if (!err || typeof err !== 'object') return false
81
+ if (err instanceof MemoryStoreFault) return true
82
+ return err.isMemoryStoreFault === true && err.code === MEMORY_STORE_FAULT_CODE
83
+ }
84
+
14
85
  export function resourceDir() {
15
86
  return process.env.MIXDOG_ROOT
16
87
  ? join(process.env.MIXDOG_ROOT, 'src')
@@ -0,0 +1,78 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ MEMORY_STORE_FAULT_CODE,
6
+ MemoryStoreFault,
7
+ isStoreFault,
8
+ markStoreFault,
9
+ } from './memory-cycle2-shared.mjs'
10
+
11
+ test('a foreign error is never classified by its text', () => {
12
+ // The exact former marker text, now owned by nobody: a provider/DB error that
13
+ // happens to read this way must stay an ordinary, recoverable action error.
14
+ const lookalikeMessage = new Error('MemoryStoreFault: connection reset by peer')
15
+ assert.equal(isStoreFault(lookalikeMessage), false)
16
+
17
+ const lookalikeName = new Error('connection reset by peer')
18
+ lookalikeName.name = 'MemoryStoreFault'
19
+ assert.equal(isStoreFault(lookalikeName), false)
20
+
21
+ const lookalikeBoth = new Error('MemoryStoreFault: rollback')
22
+ lookalikeBoth.name = 'MemoryStoreFault:Error'
23
+ assert.equal(isStoreFault(lookalikeBoth), false)
24
+
25
+ // A half-claimed field is not a claim either.
26
+ const halfClaim = new Error('nope')
27
+ halfClaim.isMemoryStoreFault = true
28
+ assert.equal(isStoreFault(halfClaim), false)
29
+ })
30
+
31
+ test('markStoreFault produces a typed fault and preserves the original', () => {
32
+ const original = new Error('rollback')
33
+ const fault = markStoreFault(original)
34
+ assert.ok(fault instanceof MemoryStoreFault)
35
+ assert.equal(isStoreFault(fault), true)
36
+ assert.equal(fault.code, MEMORY_STORE_FAULT_CODE)
37
+ // Message stays verbatim — it is not a classification channel.
38
+ assert.equal(fault.message, 'rollback')
39
+ assert.equal(fault.cause, original)
40
+ assert.equal(original.message, 'rollback')
41
+ })
42
+
43
+ test('frozen, sealed and non-Error inputs still yield a typed fault', () => {
44
+ const frozen = Object.freeze(new Error('commit ambiguous'))
45
+ const fromFrozen = markStoreFault(frozen)
46
+ assert.equal(isStoreFault(fromFrozen), true)
47
+ assert.equal(fromFrozen.cause, frozen)
48
+ assert.equal(frozen.message, 'commit ambiguous')
49
+
50
+ const sealed = Object.seal(new Error('sealed write'))
51
+ assert.equal(isStoreFault(markStoreFault(sealed)), true)
52
+
53
+ const fromString = markStoreFault('boom')
54
+ assert.equal(isStoreFault(fromString), true)
55
+ assert.equal(fromString.message, 'boom')
56
+ })
57
+
58
+ test('marking is idempotent and non-faults stay unclassified', () => {
59
+ const fault = markStoreFault(new Error('rollback'))
60
+ assert.equal(markStoreFault(fault), fault)
61
+
62
+ assert.equal(isStoreFault(new Error('guard mismatch')), false)
63
+ assert.equal(isStoreFault(null), false)
64
+ assert.equal(isStoreFault(undefined), false)
65
+ assert.equal(isStoreFault('MemoryStoreFault: not an error'), false)
66
+ assert.equal(isStoreFault({}), false)
67
+ })
68
+
69
+ test('an explicitly tagged cross-realm fault is recognised without instanceof', () => {
70
+ // Second copy of the module (different realm/registry): instanceof fails, the
71
+ // explicit field pair still classifies.
72
+ const crossRealm = Object.assign(new Error('rollback'), {
73
+ isMemoryStoreFault: true,
74
+ code: MEMORY_STORE_FAULT_CODE,
75
+ })
76
+ assert.equal(crossRealm instanceof MemoryStoreFault, false)
77
+ assert.equal(isStoreFault(crossRealm), true)
78
+ })
@@ -9,7 +9,7 @@ import { flushEmbeddingDirty } from './memory-embed.mjs'
9
9
  import { refreshHotActive } from './memory.mjs'
10
10
  import { backfillCoreEmbeddings, nominateCoreCandidates } from './core-memory-store.mjs'
11
11
  import { markCycleRequest, consumeCycleRequests, resolveCoalesceMaxDrains, scheduleCoalescedCycleRetry, makeCycleRequestSignature, resolveCoalesceMaxRetries } from './memory-cycle-requests.mjs'
12
- import { __mixdogMemoryLog, throwIfAborted } from './memory-cycle2-shared.mjs'
12
+ import { __mixdogMemoryLog, throwIfAborted, isStoreFault } from './memory-cycle2-shared.mjs'
13
13
  import {
14
14
  applyBatchStatusVerdicts, clampPendingPromotions, blockTransientPromotions, applySimpleStatus, applyUpdate, applyLineage, applyMerge, runPhaseMerge,
15
15
  } from './memory-cycle2-mutations.mjs'
@@ -142,6 +142,16 @@ export async function runCycle2(db, config = {}, options = {}, dataDir = null) {
142
142
  try {
143
143
  result = await _runCycle2Impl(db, config, options, dataDir)
144
144
  } catch (err) {
145
+ // A marked store fault means the database just failed (or ambiguously
146
+ // failed) a write: do not push another write — the coalesced-request
147
+ // ledger is a meta-table INSERT/UPDATE — onto it. Go straight to the
148
+ // { ok: false } boundary. The in-process retry timer is left intact so
149
+ // recovery still happens on a later, independent run.
150
+ if (isStoreFault(err)) {
151
+ __mixdogMemoryLog('[cycle2] store fault — skipping coalesced request mark\n')
152
+ if (coalescedRetry && retryAttempt < maxRetries) scheduleRetry()
153
+ throw err
154
+ }
145
155
  if (coalescedRetry) {
146
156
  await markCycleRequest(db, 'cycle2', 'retry-error', requestSignature)
147
157
  if (retryAttempt < maxRetries) scheduleRetry()
@@ -162,6 +172,12 @@ export async function runCycle2(db, config = {}, options = {}, dataDir = null) {
162
172
  const next = await _runCycle2Impl(db, config, options, dataDir)
163
173
  result = mergeCycle2Results(result, next)
164
174
  } catch (err) {
175
+ // Same rule on the drain pass: no ledger write after a store fault.
176
+ if (isStoreFault(err)) {
177
+ __mixdogMemoryLog('[cycle2] store fault — skipping coalesced drain mark\n')
178
+ if (!coalescedRetry || retryAttempt < maxRetries) scheduleRetry()
179
+ throw err
180
+ }
165
181
  await markCycleRequest(db, 'cycle2', 'drain-error', requestSignature)
166
182
  if (!coalescedRetry || retryAttempt < maxRetries) scheduleRetry()
167
183
  throw err
@@ -178,7 +194,10 @@ export async function runCycle2(db, config = {}, options = {}, dataDir = null) {
178
194
  return okResult
179
195
  } catch (e) {
180
196
  if (signal?.aborted) throw signal.reason ?? e
181
- return { ok: false, error: e.message, ...partial }
197
+ // The store-fault outcome leaves this boundary as an explicit boolean, so
198
+ // no consumer downstream (IPC, worker, log pipeline) ever has to re-derive
199
+ // it from the error text.
200
+ return { ok: false, error: e.message, storeFault: isStoreFault(e), ...partial }
182
201
  } finally {
183
202
  let releaseErr = null
184
203
  try {
@@ -540,7 +559,16 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
540
559
  )
541
560
  continue
542
561
  }
543
- const moved = await applyMerge(db, targetId, sourceIds, { signal, floorGuard })
562
+ // Pass the snapshot each verdict was computed against so applyMerge
563
+ // can refuse to archive a source whose status/content moved on.
564
+ const expectedBySource = {}
565
+ for (const sourceId of sourceIds.map(Number)) {
566
+ const sourceRow = rowsById.get(sourceId)
567
+ if (sourceRow) expectedBySource[sourceId] = { status: sourceRow.status, summary: sourceRow.summary }
568
+ }
569
+ const targetRow = rowsById.get(Number(targetId))
570
+ const expectedTarget = targetRow ? { status: targetRow.status, summary: targetRow.summary } : null
571
+ const moved = await applyMerge(db, targetId, sourceIds, { signal, floorGuard, expectedBySource, expectedTarget })
544
572
  throwIfAborted(signal)
545
573
  if (moved > 0) {
546
574
  stats.merged += moved
@@ -561,6 +589,12 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
561
589
  if (accepted) reviewedIds.push(id)
562
590
  } catch (err) {
563
591
  if (signal?.aborted) throw signal.reason ?? err
592
+ // A store fault leaves the database state unknown and the floor
593
+ // reservation permanently spent, so the cycle stops here exactly like
594
+ // phase_merge does (runCycle2 reports it as { ok: false }). Verdict
595
+ // rejections never land here — a guard mismatch or skipped merge
596
+ // returns normally and is counted as merge_rejected / error_count.
597
+ if (isStoreFault(err)) throw err
564
598
  __mixdogMemoryLog(`[cycle2] action error (id=${id}): ${err.message}\n`)
565
599
  }
566
600
  }
@@ -527,7 +527,7 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
527
527
  const scope = core.project_id ? String(core.project_id) : 'common'
528
528
  let queryVector = null
529
529
  try {
530
- queryVector = await embedText(queryText)
530
+ queryVector = await embedText(queryText, { inputType: 'query' })
531
531
  } catch (err) {
532
532
  if (signal?.aborted) throw signal.reason ?? err
533
533
  __mixdogMemoryLog(`[cycle3] embedding failed for core id=${core.id}: ${err.message}\n`)