mixdog 0.9.1 → 0.9.3

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 (301) hide show
  1. package/package.json +9 -1
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  6. package/scripts/background-task-meta-smoke.mjs +1 -1
  7. package/scripts/bench-run.mjs +262 -0
  8. package/scripts/build-tui.mjs +13 -1
  9. package/scripts/compact-smoke.mjs +12 -0
  10. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  11. package/scripts/explore-bench.mjs +124 -0
  12. package/scripts/hook-bus-test.mjs +191 -0
  13. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  14. package/scripts/internal-comms-bench.mjs +727 -0
  15. package/scripts/internal-comms-smoke.mjs +75 -0
  16. package/scripts/lead-workflow-smoke.mjs +4 -4
  17. package/scripts/live-worker-smoke.mjs +9 -9
  18. package/scripts/output-style-bench.mjs +285 -0
  19. package/scripts/output-style-smoke.mjs +13 -10
  20. package/scripts/patch-replay.mjs +90 -0
  21. package/scripts/path-suffix-test.mjs +57 -0
  22. package/scripts/provider-stream-stall-test.mjs +276 -0
  23. package/scripts/provider-toolcall-test.mjs +599 -1
  24. package/scripts/recall-bench.mjs +207 -0
  25. package/scripts/routing-corpus.mjs +281 -0
  26. package/scripts/session-bench.mjs +1526 -0
  27. package/scripts/session-diag.mjs +595 -0
  28. package/scripts/task-bench.mjs +207 -0
  29. package/scripts/tool-failures.mjs +6 -6
  30. package/scripts/tool-smoke.mjs +310 -67
  31. package/scripts/toolcall-args-test.mjs +81 -0
  32. package/src/agents/debugger/AGENT.md +4 -4
  33. package/src/agents/heavy-worker/AGENT.md +20 -9
  34. package/src/agents/reviewer/AGENT.md +4 -4
  35. package/src/agents/worker/AGENT.md +17 -9
  36. package/src/app.mjs +10 -6
  37. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  38. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  39. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  40. package/src/headless-role.mjs +14 -14
  41. package/src/help.mjs +1 -0
  42. package/src/lib/rules-builder.cjs +32 -54
  43. package/src/mixdog-session-runtime.mjs +1040 -2036
  44. package/src/output-styles/default.md +12 -7
  45. package/src/output-styles/minimal.md +25 -0
  46. package/src/output-styles/oneline.md +21 -0
  47. package/src/output-styles/simple.md +10 -9
  48. package/src/repl.mjs +17 -7
  49. package/src/rules/agent/00-common.md +7 -5
  50. package/src/rules/agent/30-explorer.md +8 -12
  51. package/src/rules/lead/01-general.md +3 -1
  52. package/src/rules/lead/lead-tool.md +13 -2
  53. package/src/rules/shared/01-tool.md +23 -12
  54. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  55. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  59. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  60. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  61. package/src/runtime/agent/orchestrator/context/collect.mjs +182 -67
  62. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  63. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +100 -18
  65. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  66. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +313 -84
  70. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +104 -38
  71. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +28 -33
  72. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  73. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  74. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  75. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  76. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +227 -31
  78. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +83 -6
  79. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +229 -115
  80. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +213 -33
  81. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  82. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +49 -17
  83. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  84. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +22 -17
  85. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  86. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  87. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  88. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  89. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  90. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  91. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  92. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  93. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  94. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  96. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  97. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  98. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  99. package/src/runtime/agent/orchestrator/session/loop.mjs +513 -1000
  100. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  101. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  102. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  103. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  104. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  105. package/src/runtime/agent/orchestrator/session/manager.mjs +232 -1152
  106. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  107. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  108. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  109. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  110. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  113. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  115. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  116. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  118. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  119. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  120. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  121. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  122. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +14 -5
  123. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  124. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  125. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -2
  126. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  127. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  129. package/src/runtime/agent/orchestrator/tools/builtin.mjs +70 -1
  130. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  131. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  132. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  133. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  134. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  135. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  136. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  137. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  138. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  139. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  140. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  141. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  142. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  143. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +39 -4189
  146. package/src/runtime/agent/orchestrator/tools/patch.mjs +119 -5
  147. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  148. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  149. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  150. package/src/runtime/channels/backends/discord.mjs +99 -9
  151. package/src/runtime/channels/backends/telegram.mjs +501 -0
  152. package/src/runtime/channels/index.mjs +437 -1439
  153. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  154. package/src/runtime/channels/lib/config.mjs +54 -2
  155. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  156. package/src/runtime/channels/lib/format.mjs +4 -2
  157. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  158. package/src/runtime/channels/lib/output-forwarder.mjs +88 -67
  159. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  160. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  161. package/src/runtime/channels/lib/telegram-format.mjs +280 -0
  162. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  164. package/src/runtime/channels/lib/webhook.mjs +59 -31
  165. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  166. package/src/runtime/channels/tool-defs.mjs +1 -1
  167. package/src/runtime/memory/index.mjs +465 -345
  168. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  169. package/src/runtime/memory/lib/core-memory-store.mjs +352 -2
  170. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  171. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  172. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  173. package/src/runtime/memory/lib/memory-cycle2.mjs +65 -9
  174. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  175. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  176. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  177. package/src/runtime/memory/lib/memory.mjs +121 -4
  178. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  179. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  180. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  181. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  182. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  183. package/src/runtime/memory/tool-defs.mjs +10 -7
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/background-tasks.mjs +2 -3
  186. package/src/runtime/shared/buffered-appender.mjs +149 -0
  187. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  188. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  189. package/src/runtime/shared/config.mjs +9 -0
  190. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  191. package/src/runtime/shared/schedules-store.mjs +21 -19
  192. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  193. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  194. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  195. package/src/runtime/shared/tool-surface.mjs +98 -13
  196. package/src/runtime/shared/transcript-writer.mjs +156 -0
  197. package/src/runtime/shared/update-checker.mjs +214 -0
  198. package/src/session-runtime/config-helpers.mjs +209 -0
  199. package/src/session-runtime/effort.mjs +128 -0
  200. package/src/session-runtime/fs-utils.mjs +10 -0
  201. package/src/session-runtime/model-capabilities.mjs +130 -0
  202. package/src/session-runtime/output-styles.mjs +124 -0
  203. package/src/session-runtime/plugin-mcp.mjs +114 -0
  204. package/src/session-runtime/session-text.mjs +100 -0
  205. package/src/session-runtime/statusline-route.mjs +35 -0
  206. package/src/session-runtime/tool-catalog.mjs +720 -0
  207. package/src/session-runtime/workflow.mjs +358 -0
  208. package/src/standalone/agent-tool.mjs +302 -117
  209. package/src/standalone/channel-admin.mjs +133 -40
  210. package/src/standalone/channel-worker.mjs +10 -292
  211. package/src/standalone/explore-tool.mjs +12 -5
  212. package/src/standalone/hook-bus.mjs +165 -8
  213. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  214. package/src/standalone/opencode-go-login.mjs +121 -0
  215. package/src/standalone/provider-admin.mjs +25 -3
  216. package/src/standalone/usage-dashboard.mjs +1 -1
  217. package/src/tui/App.jsx +2883 -778
  218. package/src/tui/components/ConfirmBar.jsx +47 -0
  219. package/src/tui/components/ContextPanel.jsx +5 -3
  220. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  221. package/src/tui/components/Markdown.jsx +22 -98
  222. package/src/tui/components/Message.jsx +14 -35
  223. package/src/tui/components/Picker.jsx +87 -12
  224. package/src/tui/components/PromptInput.jsx +355 -22
  225. package/src/tui/components/QueuedCommands.jsx +1 -1
  226. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  227. package/src/tui/components/Spinner.jsx +7 -7
  228. package/src/tui/components/StatusLine.jsx +40 -21
  229. package/src/tui/components/TextEntryPanel.jsx +51 -7
  230. package/src/tui/components/ToolExecution.jsx +183 -101
  231. package/src/tui/components/TurnDone.jsx +4 -4
  232. package/src/tui/components/UsagePanel.jsx +1 -1
  233. package/src/tui/components/tool-output-format.mjs +161 -23
  234. package/src/tui/components/tool-output-format.test.mjs +87 -0
  235. package/src/tui/display-width.mjs +69 -0
  236. package/src/tui/display-width.test.mjs +35 -0
  237. package/src/tui/dist/index.mjs +6731 -2333
  238. package/src/tui/engine/agent-envelope.mjs +296 -0
  239. package/src/tui/engine/boot-profile.mjs +21 -0
  240. package/src/tui/engine/labels.mjs +67 -0
  241. package/src/tui/engine/notice-text.mjs +112 -0
  242. package/src/tui/engine/queue-helpers.mjs +161 -0
  243. package/src/tui/engine/session-stats.mjs +46 -0
  244. package/src/tui/engine/tool-call-fields.mjs +23 -0
  245. package/src/tui/engine/tool-result-text.mjs +126 -0
  246. package/src/tui/engine.mjs +569 -948
  247. package/src/tui/index.jsx +117 -7
  248. package/src/tui/input-editing.mjs +58 -8
  249. package/src/tui/input-editing.selection.test.mjs +75 -0
  250. package/src/tui/keyboard-protocol.mjs +42 -0
  251. package/src/tui/lib/voice-recorder.mjs +469 -0
  252. package/src/tui/markdown/format-token.mjs +128 -76
  253. package/src/tui/markdown/format-token.test.mjs +61 -19
  254. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  255. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  256. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  257. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  258. package/src/tui/markdown/table-layout.mjs +9 -9
  259. package/src/tui/paste-attachments.mjs +36 -9
  260. package/src/tui/paste-fix.test.mjs +119 -0
  261. package/src/tui/prompt-history-store.mjs +129 -0
  262. package/src/tui/prompt-history-store.test.mjs +52 -0
  263. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  264. package/src/tui/theme.mjs +41 -657
  265. package/src/tui/themes/base.mjs +86 -0
  266. package/src/tui/themes/basic.mjs +85 -0
  267. package/src/tui/themes/catppuccin.mjs +72 -0
  268. package/src/tui/themes/dracula.mjs +70 -0
  269. package/src/tui/themes/everforest.mjs +71 -0
  270. package/src/tui/themes/gruvbox.mjs +71 -0
  271. package/src/tui/themes/index.mjs +71 -0
  272. package/src/tui/themes/indigo.mjs +78 -0
  273. package/src/tui/themes/kanagawa.mjs +80 -0
  274. package/src/tui/themes/light.mjs +81 -0
  275. package/src/tui/themes/nord.mjs +72 -0
  276. package/src/tui/themes/onedark.mjs +16 -0
  277. package/src/tui/themes/rosepine.mjs +70 -0
  278. package/src/tui/themes/teal.mjs +80 -0
  279. package/src/tui/themes/tokyonight.mjs +79 -0
  280. package/src/tui/themes/utils.mjs +106 -0
  281. package/src/tui/themes/warm.mjs +79 -0
  282. package/src/tui/transcript-tool-failures.mjs +13 -2
  283. package/src/ui/markdown.mjs +1 -1
  284. package/src/ui/model-display.mjs +2 -2
  285. package/src/ui/statusline.mjs +75 -27
  286. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  287. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  288. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  289. package/src/workflows/default/WORKFLOW.md +46 -12
  290. package/src/workflows/sequential/WORKFLOW.md +51 -0
  291. package/src/workflows/solo/WORKFLOW.md +12 -1
  292. package/vendor/ink/build/display-width.js +62 -0
  293. package/vendor/ink/build/ink.js +100 -12
  294. package/vendor/ink/build/measure-text.js +4 -1
  295. package/vendor/ink/build/output.js +115 -9
  296. package/vendor/ink/build/render-node-to-output.js +4 -1
  297. package/vendor/ink/build/render.js +4 -0
  298. package/src/output-styles/extreme-simple.md +0 -20
  299. package/src/rules/lead/04-workflow.md +0 -51
  300. package/src/workflows/default/workflow.json +0 -13
  301. package/src/workflows/solo/workflow.json +0 -7
@@ -9,7 +9,6 @@ process.removeAllListeners('warning')
9
9
  process.on('warning', () => {})
10
10
 
11
11
  import http from 'node:http'
12
- import crypto from 'node:crypto'
13
12
  import os from 'node:os'
14
13
  import fs from 'node:fs'
15
14
  import path from 'node:path'
@@ -17,47 +16,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
17
16
 
18
17
  const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
19
18
 
20
- function readPluginVersion() {
21
- try {
22
- return JSON.parse(fs.readFileSync(path.join(PLUGIN_ROOT, 'package.json'), 'utf8')).version || '0.0.1'
23
- } catch { return '0.0.1' }
24
- }
25
- const PLUGIN_VERSION = readPluginVersion()
26
- const PROMOTION_FINGERPRINT_ROOTS = ['src/memory']
27
- function collectPromotionFingerprintFiles() {
28
- const out = []
29
- const walk = (relDir) => {
30
- let entries = []
31
- try { entries = fs.readdirSync(path.join(PLUGIN_ROOT, relDir), { withFileTypes: true }) }
32
- catch { return }
33
- for (const ent of entries) {
34
- const rel = `${relDir}/${ent.name}`.replace(/\\/g, '/')
35
- if (ent.isDirectory()) {
36
- walk(rel)
37
- } else if (ent.isFile() && rel.endsWith('.mjs')) {
38
- out.push(rel)
39
- }
40
- }
41
- }
42
- for (const root of PROMOTION_FINGERPRINT_ROOTS) walk(root)
43
- return out.sort()
44
- }
45
- function readPromotionCodeFingerprint() {
46
- const hash = crypto.createHash('sha256')
47
- const files = collectPromotionFingerprintFiles()
48
- for (const rel of files) {
49
- hash.update(rel)
50
- hash.update('\0')
51
- try {
52
- hash.update(fs.readFileSync(path.join(PLUGIN_ROOT, rel)))
53
- } catch {
54
- hash.update('missing')
55
- }
56
- hash.update('\0')
57
- }
58
- return `src/memory:${files.length}:${hash.digest('hex').slice(0, 16)}`
59
- }
60
- const BOOT_PROMOTION_CODE_FINGERPRINT = readPromotionCodeFingerprint()
19
+ import { readPluginVersion, readPromotionCodeFingerprint } from './lib/promotion-fingerprint.mjs'
20
+ const PLUGIN_VERSION = readPluginVersion(PLUGIN_ROOT)
21
+ const BOOT_PROMOTION_CODE_FINGERPRINT = readPromotionCodeFingerprint(PLUGIN_ROOT)
61
22
 
62
23
  try { os.setPriority(os.constants.priority.PRIORITY_BELOW_NORMAL) } catch {}
63
24
  try {
@@ -90,6 +51,8 @@ import {
90
51
  stableSessionSourceRef,
91
52
  sessionMessageContent,
92
53
  createIngestTurnAllocator,
54
+ sessionMessageContentForIngest,
55
+ shouldExcludeIngestMessage,
93
56
  } from './lib/session-ingest.mjs'
94
57
  import { configureEmbedding, embedText, embedTexts, getEmbeddingDims, getEmbeddingDtype, getEmbeddingModelId, getKnownDimsForCurrentModel, isEmbeddingModelReady, primeEmbeddingDims, warmupEmbeddingProvider } from './lib/embedding-provider.mjs'
95
58
  import { startLlmWorker, stopLlmWorker } from './lib/llm-worker-host.mjs'
@@ -98,18 +61,22 @@ import { loadConfig as loadAgentConfig } from '../agent/orchestrator/config.mjs'
98
61
  import { initProviders } from '../agent/orchestrator/providers/registry.mjs'
99
62
  import { makeAgentDispatch } from '../agent/orchestrator/agent-runtime/agent-dispatch.mjs'
100
63
  import { getInFlightCycle1 } from './lib/memory-cycle1.mjs'
101
- import { claimAndMarkScheduledCycle, makeCycleRequestSignature, resolveCoalesceMaxRetries, scheduleCoalescedCycleRetry } from './lib/memory-cycle-requests.mjs'
64
+ import { drainSessionCycle1 } from '../agent/orchestrator/session/compact.mjs'
65
+ import { claimAndMarkScheduledCycle, resolveCoalesceMaxRetries, scheduleCoalescedCycleRetry } from './lib/memory-cycle-requests.mjs'
102
66
  import { searchRelevantHybrid } from './lib/memory-recall-store.mjs'
103
67
  import { fetchEntriesByIdsScoped } from './lib/memory-recall-id-patch.mjs'
104
68
  import { retrieveEntries } from './lib/memory-retrievers.mjs'
105
69
  import { pruneOldEntries } from './lib/memory-maintenance-store.mjs'
106
70
  import { computeEntryScore } from './lib/memory-score.mjs'
107
71
  import { runFullBackfill } from './lib/memory-ops-policy.mjs'
108
- import { listCore, addCore, editCore, deleteCore, compactCoreIds, CORE_SUMMARY_MAX } from './lib/core-memory-store.mjs'
72
+ import { listCore, addCore, editCore, deleteCore, compactCoreIds, listCoreCandidates, promoteCoreCandidate, dismissCoreCandidate, CORE_SUMMARY_MAX } from './lib/core-memory-store.mjs'
109
73
  import { resolveProjectId, resolveProjectScope } from './lib/project-id-resolver.mjs'
110
74
  import { openTraceDatabase, closeTraceDatabase, insertTraceEvents, enqueueTraceEvents, insertAgentCalls, registerTraceExitDrain } from './lib/trace-store.mjs'
111
75
  import { updateJsonAtomicSync, writeJsonAtomicSync } from '../shared/atomic-file.mjs'
112
76
  import { resolvePluginData, mixdogHome } from '../shared/plugin-paths.mjs'
77
+ import { parsePeriod, formatTs, coreRecallTerms, normalizeRecallProjectScope, sessionRecallTerms, interleaveRawRows, renderEntryLines } from './lib/recall-format.mjs'
78
+ import { readBody, sendJson, sendError, isLocalOrigin, normalizeCoreProjectId } from './lib/http-wire.mjs'
79
+ import { scheduledCycle1Signature, scheduledCycle2Signature, scheduledCycle3Signature } from './lib/cycle-signatures.mjs'
113
80
  const IS_MEMORY_ENTRY = !!process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
114
81
  const USE_ARG_DATA_DIR = IS_MEMORY_ENTRY || process.env.MIXDOG_WORKER_MODE === '1'
115
82
  const DATA_DIR = process.env.MIXDOG_DATA_DIR || (USE_ARG_DATA_DIR ? process.argv[2] : '') || resolvePluginData()
@@ -398,6 +365,13 @@ let _startupTimeout = null
398
365
  // getting the real stats and others getting `skippedInFlight: true` from
399
366
  // the inner guard.
400
367
  let _cycle1InFlight = null // shared cycle1 promise (outer coalesce layer)
368
+ // Per-session recall-drain in-flight map. Multiple terminals share one
369
+ // memory daemon (active-instance.json memory_port); concurrent recall calls
370
+ // for the SAME session must share one drain promise (dedup), while DIFFERENT
371
+ // sessions drain in parallel (cycle1's own advisory-lock/coalesce guard in
372
+ // memory-cycle1.mjs still serializes the actual DB work). A drain never
373
+ // blocks recall for sessions other than its own.
374
+ const _recallDrainInFlight = new Map() // sessionId -> Promise
401
375
  let _initialized = false
402
376
  let _initPromise = null
403
377
  let _stopPromise = null
@@ -645,57 +619,78 @@ async function setCycleLastRun(kind, ts) {
645
619
  // Raw-row priority lookup for narrow-window queries. Raw rows (is_root=0,
646
620
  // chunk_root IS NULL) are inserted immediately by ingestTranscriptFile before
647
621
  // cycle1 runs, so they always carry the freshest turns in the DB.
648
- async function readRawRowsInWindow(db, tsFromMs, tsToMs, hardLimit = 10, { projectScope } = {}) {
622
+ async function readRawRowsInWindow(db, tsFromMs, tsToMs, hardLimit = 10, { projectScope, sessionId } = {}) {
649
623
  try {
650
- let sql, params
624
+ // Composable WHERE assembly (mirrors retrieveEntries' filter semantics so
625
+ // raw and chunked legs stay in filter parity: projectScope AND sessionId
626
+ // apply identically to both pools).
627
+ const where = ['chunk_root IS NULL', 'is_root = 0', 'ts >= $1', 'ts <= $2']
628
+ const params = [tsFromMs ?? 0, tsToMs ?? Date.now()]
651
629
  if (projectScope === 'common') {
652
- sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
653
- element, category, summary, status, score, last_seen_at, project_id
654
- FROM entries
655
- WHERE chunk_root IS NULL AND is_root = 0
656
- AND ts >= $1 AND ts <= $2
657
- AND project_id IS NULL
658
- ORDER BY ts DESC
659
- LIMIT $3`
660
- params = [tsFromMs ?? 0, tsToMs ?? Date.now(), hardLimit]
630
+ where.push('project_id IS NULL')
661
631
  } else if (projectScope && projectScope !== 'all') {
662
- sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
663
- element, category, summary, status, score, last_seen_at, project_id
664
- FROM entries
665
- WHERE chunk_root IS NULL AND is_root = 0
666
- AND ts >= $1 AND ts <= $2
667
- AND (project_id IS NULL OR project_id = $3)
668
- ORDER BY ts DESC
669
- LIMIT $4`
670
- params = [tsFromMs ?? 0, tsToMs ?? Date.now(), projectScope, hardLimit]
671
- } else {
672
- sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
632
+ params.push(projectScope)
633
+ where.push(`(project_id IS NULL OR project_id = $${params.length})`)
634
+ }
635
+ const sid = String(sessionId || '').trim()
636
+ if (sid) {
637
+ params.push(sid)
638
+ where.push(`session_id = $${params.length}`)
639
+ }
640
+ params.push(hardLimit)
641
+ const sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
673
642
  element, category, summary, status, score, last_seen_at, project_id
674
643
  FROM entries
675
- WHERE chunk_root IS NULL AND is_root = 0
676
- AND ts >= $1 AND ts <= $2
644
+ WHERE ${where.join(' AND ')}
677
645
  ORDER BY ts DESC
678
- LIMIT $3`
679
- params = [tsFromMs ?? 0, tsToMs ?? Date.now(), hardLimit]
680
- }
646
+ LIMIT $${params.length}`
681
647
  const rows = (await db.query(sql, params)).rows
682
648
  return rows.map(r => ({ ...r, retrievalScore: 0, rrf: 0 }))
683
649
  } catch { return [] }
684
650
  }
685
651
 
686
- function sessionRecallTerms(query) {
687
- return [...new Set(String(query || '').toLowerCase().match(/[\p{L}\p{N}_./:-]{2,}/gu) || [])]
688
- .filter((term) => !CORE_RECALL_STOPWORDS.has(term))
689
- .slice(0, 12)
690
- }
691
-
692
652
  async function recallSessionRows(args = {}) {
693
653
  const sessionId = String(args.sessionId || args.session_id || '').trim()
694
654
  if (!sessionId) return { text: '(no current session)' }
695
655
  const limit = Math.max(1, Math.min(100, Number(args.limit) || 20))
696
656
  const terms = sessionRecallTerms(args.query)
697
657
  const params = [sessionId]
698
- const where = ['session_id = $1']
658
+ // Roots + not-yet-chunked leaves only. Once cycle1 turns raw leaves into
659
+ // (root, members) pairs, selecting every row unfiltered emitted the root's
660
+ // summary AND its own member rows in the same browse — duplicate content.
661
+ // A committed member (is_root=0 with a chunk_root) is always reachable via
662
+ // its root's `members` expansion below, so it never needs to be selected
663
+ // directly here.
664
+ const where = ['session_id = $1', '(is_root = 1 OR chunk_root IS NULL OR chunk_root = id)']
665
+ // Current-turn cutoff: the newest unchunked row is very often the calling
666
+ // turn's OWN recall request/tool-args, still being written when this query
667
+ // runs. Exclude it from a bare (no-query) browse so the in-flight turn
668
+ // doesn't self-echo; a query browse (explicit search intent) keeps it.
669
+ // Only treat the newest unchunked turn as "in-flight" when its latest row
670
+ // is fresh (within FRESHNESS_MS of now) — an older newest-unchunked-turn
671
+ // is completed history (cycle1 just hasn't gotten to it, or drain timed
672
+ // out) and must stay visible, not be silently hidden every browse.
673
+ const IN_FLIGHT_TURN_FRESHNESS_MS = 5 * 60 * 1000
674
+ let excludeSourceTurnId = null
675
+ if (terms.length === 0) {
676
+ try {
677
+ const r = await db.query(
678
+ `SELECT source_turn t, MAX(ts) last_ts FROM entries
679
+ WHERE session_id = $1 AND chunk_root IS NULL
680
+ GROUP BY source_turn ORDER BY source_turn DESC LIMIT 1`,
681
+ [sessionId],
682
+ )
683
+ const t = r.rows?.[0]?.t
684
+ const lastTs = Number(r.rows?.[0]?.last_ts)
685
+ if (t != null && Number.isFinite(lastTs) && (Date.now() - lastTs) <= IN_FLIGHT_TURN_FRESHNESS_MS) {
686
+ excludeSourceTurnId = Number(t)
687
+ }
688
+ } catch {}
689
+ }
690
+ if (Number.isFinite(excludeSourceTurnId)) {
691
+ params.push(excludeSourceTurnId)
692
+ where.push(`NOT (chunk_root IS NULL AND source_turn = $${params.length})`)
693
+ }
699
694
  if (terms.length > 0) {
700
695
  const textExpr = `lower(coalesce(content, '') || ' ' || coalesce(element, '') || ' ' || coalesce(summary, ''))`
701
696
  const clauses = terms.map((term) => {
@@ -716,16 +711,22 @@ async function recallSessionRows(args = {}) {
716
711
  if (rows.length < limit) {
717
712
  const seen = new Set(rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id)))
718
713
  const fillLimit = Math.max(0, limit - rows.length)
714
+ const fillWhere = ['session_id = $1', 'id <> ALL($2::bigint[])', '(is_root = 1 OR chunk_root IS NULL OR chunk_root = id)']
715
+ const fillParams = [sessionId, [...seen]]
716
+ if (Number.isFinite(excludeSourceTurnId)) {
717
+ fillParams.push(excludeSourceTurnId)
718
+ fillWhere.push(`NOT (chunk_root IS NULL AND source_turn = $${fillParams.length})`)
719
+ }
720
+ fillParams.push(fillLimit)
719
721
  const fillRows = fillLimit > 0
720
722
  ? (await db.query(`
721
723
  SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
722
724
  element, category, summary, status, score, last_seen_at, project_id
723
725
  FROM entries
724
- WHERE session_id = $1
725
- AND id <> ALL($2::bigint[])
726
+ WHERE ${fillWhere.join(' AND ')}
726
727
  ORDER BY ts DESC, id DESC
727
- LIMIT $3
728
- `, [sessionId, [...seen], fillLimit])).rows
728
+ LIMIT $${fillParams.length}
729
+ `, fillParams)).rows
729
730
  : []
730
731
  if (fillRows.length > 0) rows = [...rows, ...fillRows]
731
732
  }
@@ -792,7 +793,17 @@ async function ingestSessionMessages(args = {}) {
792
793
  // are dropped so recall-fasttrack summaries stay conversation-focused and
793
794
  // do not re-inject content already in the protected system prefix.
794
795
  if (!role) continue
795
- const content = cleanMemoryText(sessionMessageContent(m))
796
+ // Exclude synthetic / non-conversation rows entirely (reference-files
797
+ // injections, compaction summaries, protected-context `.` acks, internal
798
+ // runtime nudges). These are mechanical noise, not conversation.
799
+ if (shouldExcludeIngestMessage(m)) continue
800
+ // Pure-conversation shaping: only the human/model prose text. The ingest
801
+ // shaper NEVER inlines tool_call / tool_result traces and strips the
802
+ // deterministic manager.mjs user-turn prefix envelopes (# Session /
803
+ // # Additional context / # Prefetch / # Task) so only the real human
804
+ // prompt remains. cleanMemoryText then removes the <system-reminder> block
805
+ // and residual transcript noise.
806
+ const content = cleanMemoryText(sessionMessageContentForIngest(m))
796
807
  if (!content || !content.trim()) continue
797
808
  considered += 1
798
809
  const tsMs = parseTsToMs(m.ts ?? m.timestamp ?? (Date.now() - (messages.length - i)))
@@ -1178,7 +1189,7 @@ let _cycle1AgentDispatch = null
1178
1189
  function getCycle1CallLlm() {
1179
1190
  if (!_cycle1AgentDispatch) {
1180
1191
  _cycle1AgentDispatch = makeAgentDispatch({
1181
- role: 'cycle1-agent',
1192
+ agent: 'cycle1-agent',
1182
1193
  taskType: 'maintenance',
1183
1194
  sourceType: 'memory-cycle',
1184
1195
  // cycle1 parses the full raw line-format response; the agent brief cap
@@ -1206,7 +1217,7 @@ let _cycle2AgentDispatch = null
1206
1217
  function getCycle2CallLlm() {
1207
1218
  if (!_cycle2AgentDispatch) {
1208
1219
  _cycle2AgentDispatch = makeAgentDispatch({
1209
- role: 'cycle2-agent',
1220
+ agent: 'cycle2-agent',
1210
1221
  taskType: 'maintenance',
1211
1222
  sourceType: 'memory-cycle',
1212
1223
  brief: false,
@@ -1226,7 +1237,7 @@ let _cycle3AgentDispatch = null
1226
1237
  function getCycle3CallLlm() {
1227
1238
  if (!_cycle3AgentDispatch) {
1228
1239
  _cycle3AgentDispatch = makeAgentDispatch({
1229
- role: 'cycle3-agent',
1240
+ agent: 'cycle3-agent',
1230
1241
  taskType: 'maintenance',
1231
1242
  sourceType: 'memory-cycle',
1232
1243
  brief: false,
@@ -1254,6 +1265,62 @@ async function recordCycle1Result(result) {
1254
1265
  if (!skipped && !coalescedNoop && !allFailed) {
1255
1266
  await setCycleLastRun('cycle1', now)
1256
1267
  }
1268
+ if (!skipped && !coalescedNoop) markCycleDone('cycle1', !allFailed, allFailed ? 'all rows skipped' : null)
1269
+ }
1270
+
1271
+ // ── Cycle health state (no new commands; consumed by /health + statusline) ──
1272
+ // In-memory per-cycle run ledger + a small state file the TUI statusline can
1273
+ // read without HTTP. "Silently stalled cycles" (2026-07: cycle2 poison
1274
+ // rotation sat unnoticed for months) become visible three ways: /health
1275
+ // fields, a WARN log with cooldown, and the L2 "Memory" segment.
1276
+ const CYCLE_STATE_FILE = path.join(DATA_DIR, 'memory-cycle-state.json')
1277
+ const _cycleHealth = {
1278
+ cycle1: { last_success_at: 0, last_error_at: 0, last_error: null, consecutive_failures: 0 },
1279
+ cycle2: { last_success_at: 0, last_error_at: 0, last_error: null, consecutive_failures: 0 },
1280
+ cycle3: { last_success_at: 0, last_error_at: 0, last_error: null, consecutive_failures: 0 },
1281
+ }
1282
+ let _cycleRunning = null // { cycle, started_at }
1283
+ let _cycleBacklogSnapshot = { unchunked: 0, cycle2_pending: 0, at: 0 }
1284
+ let _lastBacklogWarnAt = 0
1285
+ const BACKLOG_WARN_COOLDOWN_MS = 10 * 60_000
1286
+ const BACKLOG_WARN_PENDING = 500
1287
+ const BACKLOG_WARN_FAILURES = 5
1288
+
1289
+ function _writeCycleStateFile() {
1290
+ try {
1291
+ fs.writeFileSync(CYCLE_STATE_FILE, JSON.stringify({
1292
+ running: _cycleRunning,
1293
+ backlog: _cycleBacklogSnapshot,
1294
+ cycles: _cycleHealth,
1295
+ updatedAt: Date.now(),
1296
+ }))
1297
+ } catch { /* best-effort; statusline just shows nothing */ }
1298
+ }
1299
+
1300
+ function markCycleRunning(cycle) {
1301
+ _cycleRunning = { cycle, started_at: Date.now() }
1302
+ _writeCycleStateFile()
1303
+ }
1304
+
1305
+ function markCycleDone(cycle, ok, err = null) {
1306
+ const h = _cycleHealth[cycle]
1307
+ if (h) {
1308
+ const now = Date.now()
1309
+ if (ok) { h.last_success_at = now; h.consecutive_failures = 0; h.last_error = null }
1310
+ else { h.last_error_at = now; h.consecutive_failures += 1; h.last_error = String(err || 'unknown').slice(0, 200) }
1311
+ if (!ok && h.consecutive_failures >= BACKLOG_WARN_FAILURES) {
1312
+ _warnCycleHealth(`${cycle} failing repeatedly (consecutive=${h.consecutive_failures}, last="${h.last_error}")`)
1313
+ }
1314
+ }
1315
+ if (_cycleRunning?.cycle === cycle) _cycleRunning = null
1316
+ _writeCycleStateFile()
1317
+ }
1318
+
1319
+ function _warnCycleHealth(msg) {
1320
+ const now = Date.now()
1321
+ if (now - _lastBacklogWarnAt < BACKLOG_WARN_COOLDOWN_MS) return
1322
+ _lastBacklogWarnAt = now
1323
+ __mixdogMemoryLog(`[cycle-health] WARN ${msg}\n`)
1257
1324
  }
1258
1325
 
1259
1326
  function _startCycle1Run(config = {}, options = {}) {
@@ -1264,6 +1331,7 @@ function _startCycle1Run(config = {}, options = {}) {
1264
1331
  if (typeof options?.callLlm !== 'function') {
1265
1332
  options = { ...options, callLlm: getCycle1CallLlm() }
1266
1333
  }
1334
+ markCycleRunning('cycle1')
1267
1335
  _cycle1InFlight = (async () => {
1268
1336
  try {
1269
1337
  const result = await runCycle1(db, config, options, DATA_DIR)
@@ -1276,7 +1344,11 @@ function _startCycle1Run(config = {}, options = {}) {
1276
1344
  await recordCycle1Result(result)
1277
1345
  }
1278
1346
  return result
1347
+ } catch (err) {
1348
+ markCycleDone('cycle1', false, err?.message || err)
1349
+ throw err
1279
1350
  } finally {
1351
+ if (_cycleRunning?.cycle === 'cycle1') { _cycleRunning = null; _writeCycleStateFile() }
1280
1352
  if (_cycle1InFlight === promise) _cycle1InFlight = null
1281
1353
  }
1282
1354
  })()
@@ -1303,7 +1375,14 @@ async function _awaitCycle1Run(config = {}, options = {}) {
1303
1375
  let timer
1304
1376
  const deadlinePromise = new Promise((resolve) => {
1305
1377
  timer = setTimeout(() => {
1306
- if (_cycle1InFlight === target) _cycle1InFlight = null
1378
+ // Do NOT clear _cycle1InFlight here — the underlying runCycle1 is still
1379
+ // running in the background and owns that slot; its own `finally`
1380
+ // (_startCycle1Run above) clears it when the real work settles. Clearing
1381
+ // it early made a later unbounded drain (deadlineMs:0, e.g. the query
1382
+ // recall path) re-enter _startCycle1Run and spawn a SECOND concurrent
1383
+ // cycle1 run instead of awaiting the one already in flight, defeating
1384
+ // drain-to-zero silently. The caller here just stops WAITING at the
1385
+ // deadline; state ownership stays with the promise itself.
1307
1386
  resolve({
1308
1387
  processed: 0,
1309
1388
  chunks: 0,
@@ -1339,34 +1418,6 @@ function periodicCycle1Config() {
1339
1418
  }
1340
1419
  }
1341
1420
 
1342
- function scheduledCycle1Signature(config) {
1343
- return makeCycleRequestSignature('cycle1', config, {
1344
- preset: undefined,
1345
- concurrency: undefined,
1346
- maxConcurrent: undefined,
1347
- })
1348
- }
1349
-
1350
- function scheduledCycle2Signature(config) {
1351
- return makeCycleRequestSignature('cycle2', config, {
1352
- cascadePreset: undefined,
1353
- concurrency: undefined,
1354
- })
1355
- }
1356
-
1357
- function scheduledCycle3ApplyMode(config) {
1358
- const raw = String(config?.cycle3?.applyMode || 'conservative').trim().toLowerCase()
1359
- return (raw === 'proposal' || raw === 'dry-run' || raw === 'dryrun') ? 'proposal' : 'conservative'
1360
- }
1361
-
1362
- function scheduledCycle3Signature(config) {
1363
- const retryConfig = config?.cycle3 || config
1364
- return makeCycleRequestSignature('cycle3', retryConfig, {
1365
- applyMode: scheduledCycle3ApplyMode(config),
1366
- apply: undefined,
1367
- })
1368
- }
1369
-
1370
1421
  async function enqueueScheduledCycle(kind, intervalMs, signature) {
1371
1422
  const claim = await claimAndMarkScheduledCycle(db, kind, intervalMs, signature, { reason: 'scheduled' })
1372
1423
  return claim.claimed === true
@@ -1427,6 +1478,7 @@ function scheduleScheduledCycle2(config, signature, attempt = 0) {
1427
1478
  return
1428
1479
  }
1429
1480
  _cycle2InFlight = true
1481
+ markCycleRunning('cycle2')
1430
1482
  try {
1431
1483
  let c2Options = {
1432
1484
  coalescedRetry: true,
@@ -1445,8 +1497,10 @@ function scheduleScheduledCycle2(config, signature, attempt = 0) {
1445
1497
  }
1446
1498
  } catch (err) {
1447
1499
  __mixdogMemoryLog(`[cycle2] scheduled queue failed: ${err?.message || err}\n`)
1500
+ markCycleDone('cycle2', false, err?.message || err)
1448
1501
  } finally {
1449
1502
  _cycle2InFlight = false
1503
+ if (_cycleRunning?.cycle === 'cycle2') { _cycleRunning = null; _writeCycleStateFile() }
1450
1504
  }
1451
1505
  }, config, signature)
1452
1506
  }
@@ -1464,10 +1518,11 @@ function scheduleScheduledCycle3(config, signature, attempt = 0) {
1464
1518
  return
1465
1519
  }
1466
1520
  _cycle3InFlight = true
1521
+ markCycleRunning('cycle3')
1467
1522
  try {
1468
1523
  let c3Options = {
1469
1524
  coalescedRetry: true,
1470
- onCoalescedSuccess: () => setCycleLastRun('cycle3', Date.now()),
1525
+ onCoalescedSuccess: async () => { await setCycleLastRun('cycle3', Date.now()); markCycleDone('cycle3', true) },
1471
1526
  }
1472
1527
  if (typeof c3Options?.callLlm !== 'function') {
1473
1528
  c3Options = { ...c3Options, callLlm: getCycle3CallLlm() }
@@ -1480,8 +1535,10 @@ function scheduleScheduledCycle3(config, signature, attempt = 0) {
1480
1535
  }
1481
1536
  } catch (err) {
1482
1537
  __mixdogMemoryLog(`[cycle3] scheduled queue failed: ${err?.message || err}\n`)
1538
+ markCycleDone('cycle3', false, err?.message || err)
1483
1539
  } finally {
1484
1540
  _cycle3InFlight = false
1541
+ if (_cycleRunning?.cycle === 'cycle3') { _cycleRunning = null; _writeCycleStateFile() }
1485
1542
  }
1486
1543
  }, retryConfig, signature)
1487
1544
  }
@@ -1495,9 +1552,11 @@ async function _finalizeCycle2Run(result) {
1495
1552
  await setCycleLastRun('cycle2', Date.now())
1496
1553
  await setCycleLastRun('cycle2_last_error', '')
1497
1554
  __mixdogMemoryLog('[cycle2] completed\n')
1555
+ markCycleDone('cycle2', true)
1498
1556
  } else {
1499
1557
  await setCycleLastRun('cycle2_last_error', result.error || 'unknown error')
1500
1558
  __mixdogMemoryLog(`[cycle2] failed: ${result.error}\n`)
1559
+ markCycleDone('cycle2', false, result.error || 'unknown error')
1501
1560
  }
1502
1561
  }
1503
1562
 
@@ -1574,6 +1633,36 @@ async function checkCycles() {
1574
1633
  if (now - last.cycle3 >= cycle3Ms) {
1575
1634
  await enqueueScheduledCycle3(cycle3Ms, 'scheduled')
1576
1635
  }
1636
+
1637
+ // ── Backlog watch + self-kick (cycle-health) ─────────────────────────────
1638
+ // Cheap counts each tick (60s): surface a WARN when the pipeline is
1639
+ // building a backlog, and if a cycle has not SUCCEEDED for 2h+ while its
1640
+ // backlog is over threshold, kick one unscheduled run now instead of
1641
+ // waiting for the interval — a stalled cycle otherwise hides behind
1642
+ // "the schedule ran it" (attempt != success; see 2026-07 cycle2 stall).
1643
+ try {
1644
+ const unchunked = Number((await db.query(
1645
+ `SELECT COUNT(*) c FROM entries WHERE chunk_root IS NULL AND NULLIF(btrim(session_id), '') IS NOT NULL`,
1646
+ )).rows[0]?.c ?? 0)
1647
+ const cycle2Pending = Number((await db.query(
1648
+ `SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'pending'`,
1649
+ )).rows[0]?.c ?? 0)
1650
+ _cycleBacklogSnapshot = { unchunked, cycle2_pending: cycle2Pending, at: now }
1651
+ _writeCycleStateFile()
1652
+ if (unchunked > BACKLOG_WARN_PENDING || cycle2Pending > BACKLOG_WARN_PENDING) {
1653
+ _warnCycleHealth(`backlog unchunked=${unchunked} cycle2_pending=${cycle2Pending}`)
1654
+ }
1655
+ const SELF_KICK_STALE_MS = 2 * 3600_000
1656
+ const c1Stale = (_cycleHealth.cycle1.last_success_at || last.cycle1 || 0) < now - SELF_KICK_STALE_MS
1657
+ const c2Stale = (_cycleHealth.cycle2.last_success_at || last.cycle2 || 0) < now - SELF_KICK_STALE_MS
1658
+ if (c1Stale && unchunked > BACKLOG_WARN_PENDING) {
1659
+ _warnCycleHealth(`cycle1 self-kick: no success 2h+, unchunked=${unchunked}`)
1660
+ await enqueueScheduledCycle1(0, 'self-kick')
1661
+ } else if (c2Stale && cycle2Pending > BACKLOG_WARN_PENDING) {
1662
+ _warnCycleHealth(`cycle2 self-kick: no success 2h+, pending=${cycle2Pending}`)
1663
+ await enqueueScheduledCycle2(0, 'self-kick')
1664
+ }
1665
+ } catch { /* counts are best-effort; never fail the tick */ }
1577
1666
  }
1578
1667
  let _cycle2InFlight = false
1579
1668
  let _cycle3InFlight = false
@@ -1654,114 +1743,6 @@ function _beginRuntimeInit() {
1654
1743
  return _initPromise
1655
1744
  }
1656
1745
 
1657
-
1658
- function parsePeriod(period, hasQuery) {
1659
- if (!period && hasQuery) period = '30d'
1660
- if (!period) return null
1661
- if (period === 'all') return null
1662
- if (period === 'last') return { mode: 'last' }
1663
- // Calendar-day windows: 'today' anchors at local midnight rather than
1664
- // rolling 24h. Without this, a query asking 'today' at 01:30 would silently
1665
- // include yesterday's last 22.5h of activity, mislabelling them as
1666
- // 'today's work'. 'yesterday' is the previous calendar day.
1667
- if (period === 'today') {
1668
- const start = new Date()
1669
- start.setHours(0, 0, 0, 0)
1670
- return { startMs: start.getTime(), endMs: Date.now() }
1671
- }
1672
- if (period === 'yesterday') {
1673
- const start = new Date()
1674
- start.setDate(start.getDate() - 1)
1675
- start.setHours(0, 0, 0, 0)
1676
- const end = new Date(start)
1677
- end.setHours(23, 59, 59, 999)
1678
- return { startMs: start.getTime(), endMs: end.getTime() }
1679
- }
1680
- if (period === 'this_week' || period === 'last_week') {
1681
- // R6 P9: calendar Mon-Sun previous/current week. Mon-start ISO
1682
- // convention. Replaces R5 rolling 7-14d range which was empty for
1683
- // sessions where "last week" decisions actually fell on Mon (4/27) of
1684
- // this week. Precise calendar bounds match natural-language intuition.
1685
- const d = new Date()
1686
- d.setHours(0, 0, 0, 0)
1687
- const dayOfWeek = d.getDay()
1688
- const daysSinceMon = (dayOfWeek + 6) % 7
1689
- const thisWeekMon = new Date(d)
1690
- thisWeekMon.setDate(d.getDate() - daysSinceMon)
1691
- if (period === 'this_week') {
1692
- return { startMs: thisWeekMon.getTime(), endMs: Date.now() }
1693
- }
1694
- const lastWeekMon = new Date(thisWeekMon)
1695
- lastWeekMon.setDate(thisWeekMon.getDate() - 7)
1696
- const lastWeekSunEnd = new Date(thisWeekMon.getTime() - 1)
1697
- return { startMs: lastWeekMon.getTime(), endMs: lastWeekSunEnd.getTime() }
1698
- }
1699
- const relMatch = period.match(/^(\d+)(m|h|d)$/)
1700
- if (relMatch) {
1701
- const n = parseInt(relMatch[1])
1702
- const unit = relMatch[2]
1703
- const now = new Date()
1704
- if (unit === 'm') {
1705
- // Minute granularity is for "resume from the previous turn / pick
1706
- // up where we left off" style recall — sub-hour windows where 1h
1707
- // is too coarse. n=0 is invalid (the regex requires \d+ which
1708
- // matches "0" but a zero-width window returns no rows; leave that
1709
- // as caller-supplied no-op).
1710
- const start = new Date(now.getTime() - n * 60_000)
1711
- return { startMs: start.getTime(), endMs: now.getTime() }
1712
- }
1713
- if (unit === 'h') {
1714
- const start = new Date(now.getTime() - n * 3600_000)
1715
- return { startMs: start.getTime(), endMs: now.getTime() }
1716
- }
1717
- const start = new Date(now)
1718
- start.setDate(start.getDate() - n)
1719
- return { startMs: start.getTime(), endMs: now.getTime() }
1720
- }
1721
- const rangeMatch = period.match(/^(\d{4}-\d{2}-\d{2})~(\d{4}-\d{2}-\d{2})$/)
1722
- if (rangeMatch) {
1723
- return {
1724
- startMs: Date.parse(rangeMatch[1] + 'T00:00:00'),
1725
- endMs: Date.parse(rangeMatch[2] + 'T23:59:59.999'),
1726
- }
1727
- }
1728
- const dateMatch = period.match(/^(\d{4}-\d{2}-\d{2})$/)
1729
- if (dateMatch) {
1730
- return {
1731
- startMs: Date.parse(dateMatch[1] + 'T00:00:00'),
1732
- endMs: Date.parse(dateMatch[1] + 'T23:59:59.999'),
1733
- exact: true,
1734
- }
1735
- }
1736
- return null
1737
- }
1738
-
1739
- function formatTs(tsMs) {
1740
- const n = Number(tsMs)
1741
- if (Number.isFinite(n) && n > 1e12) {
1742
- return new Date(n).toLocaleString('sv-SE').slice(0, 16)
1743
- }
1744
- return String(tsMs ?? '').slice(0, 16)
1745
- }
1746
-
1747
- const CORE_RECALL_STOPWORDS = new Set([
1748
- 'about', 'after', 'again', 'before', 'check', 'color', 'decision', 'decided',
1749
- 'earlier', 'memory', 'previous', 'routing', 'stored', 'tell', 'what',
1750
- ])
1751
-
1752
- function coreRecallTerms(query) {
1753
- return [...new Set(String(query || '').toLowerCase().match(/[\p{L}\p{N}_-]{4,}/gu) || [])]
1754
- .filter((term) => !CORE_RECALL_STOPWORDS.has(term))
1755
- .slice(0, 8)
1756
- }
1757
-
1758
- function normalizeRecallProjectScope(projectScope) {
1759
- const raw = String(projectScope || 'common').trim()
1760
- if (!raw || raw.toLowerCase() === 'common') return null
1761
- if (raw.toLowerCase() === 'all') return '*'
1762
- return raw
1763
- }
1764
-
1765
1746
  async function recallCoreRows(query, { projectScope, category, limit } = {}) {
1766
1747
  const terms = coreRecallTerms(query)
1767
1748
  if (terms.length === 0) return []
@@ -1815,10 +1796,120 @@ async function recallCoreRows(query, { projectScope, category, limit } = {}) {
1815
1796
  }))
1816
1797
  }
1817
1798
 
1799
+ // Session-scoped full drain before a recall(query) search. Chunking is
1800
+ // partitioned by session_id (memory-cycle1.mjs:409-431 selected_sessions
1801
+ // GROUP BY session_id + ROW_NUMBER PARTITION BY session_id), so this drains
1802
+ // ONLY the calling session's backlog via the same session_id override
1803
+ // (memory-cycle1.mjs:394) — other sessions' pending rows are left for the
1804
+ // periodic cycle1 sweep. Reuses recall-fasttrack's drainSessionCycle1
1805
+ // (session/compact.mjs:504) which loops cycle1 until raw rows hit 0 or a
1806
+ // no-progress pass. Safety limits: a 500-row hard cap (both in the pending
1807
+ // pre-check and as the per-call cycle1 batch ceiling) and cooperative
1808
+ // abort-signal propagation through handleMemoryAction — no time budget.
1809
+ // Window 20 rows per classifier prompt × concurrency 5 (haiku — cost is
1810
+ // negligible; 500 rows ≈ 25 prompts ≈ 5 waves).
1811
+ //
1812
+ // Multi-terminal safety: the memory daemon is one shared instance across
1813
+ // terminals (active-instance.json memory_port). Concurrent recall(query)
1814
+ // calls for the SAME session share one in-flight drain promise (dedup);
1815
+ // different sessions drain independently in parallel (cycle1's own
1816
+ // advisory-lock/coalesce guard in memory-cycle1.mjs still serializes actual
1817
+ // DB writes). A drain only gates recall for its OWN session — every other
1818
+ // terminal's recall (different session, or no session) proceeds unblocked.
1819
+ const RECALL_DRAIN_HARD_CAP_ROWS = 500
1820
+ async function _drainSessionForRecallQuery(sessionId, signal, deadlineMs = 0) {
1821
+ if (!sessionId || signal?.aborted) return
1822
+ const existing = _recallDrainInFlight.get(sessionId)
1823
+ if (existing) { try { await existing } catch {} return }
1824
+ let pendingCount = 0
1825
+ try {
1826
+ const r = await db.query(
1827
+ `SELECT COUNT(*) c FROM entries WHERE chunk_root IS NULL AND session_id = $1`,
1828
+ [sessionId],
1829
+ )
1830
+ pendingCount = Number(r.rows?.[0]?.c ?? 0)
1831
+ } catch { return }
1832
+ if (pendingCount <= 0) return
1833
+ const runTool = async (_name, toolArgs) => {
1834
+ const result = await handleMemoryAction(toolArgs, signal)
1835
+ return result?.text ?? ''
1836
+ }
1837
+ const dumpArgs = {
1838
+ action: 'dump_session_roots',
1839
+ sessionId,
1840
+ includeRaw: true,
1841
+ limit: Math.min(RECALL_DRAIN_HARD_CAP_ROWS, Math.max(1, pendingCount)),
1842
+ }
1843
+ const drainPromise = (async () => {
1844
+ try {
1845
+ return await drainSessionCycle1(runTool, {
1846
+ sessionId,
1847
+ dumpArgs,
1848
+ deadlineMs, // 0 = no time budget (query path); bare browse passes a
1849
+ // short bound so a browse call never blocks minutes on LLM chunking.
1850
+ maxPasses: 20, // safety stopper; drainSessionCycle1 also self-stops on no-progress
1851
+ cycleArgs: {
1852
+ min_batch: 1,
1853
+ session_cap: 1,
1854
+ batch_size: RECALL_DRAIN_HARD_CAP_ROWS,
1855
+ rows_per_session: RECALL_DRAIN_HARD_CAP_ROWS,
1856
+ window_size: 20,
1857
+ concurrency: 5,
1858
+ },
1859
+ })
1860
+ } catch (err) {
1861
+ __mixdogMemoryLog(`[recall] session drain aborted/failed (sess=${sessionId}): ${err?.message || err}\n`)
1862
+ return null
1863
+ }
1864
+ })()
1865
+ _recallDrainInFlight.set(sessionId, drainPromise)
1866
+ try {
1867
+ await drainPromise
1868
+ } finally {
1869
+ if (_recallDrainInFlight.get(sessionId) === drainPromise) _recallDrainInFlight.delete(sessionId)
1870
+ }
1871
+ }
1872
+
1873
+ // Bare-browse drain deadline: best-effort only. A browse call ("show me
1874
+ // recent context") must never block on LLM chunking for minutes the way a
1875
+ // query recall is allowed to — the raw-row merge already covers unchunked
1876
+ // content, so the drain here is a nice-to-have upgrade to chunked/scored
1877
+ // rows, not a correctness requirement. Failures/timeouts are silently
1878
+ // tolerated; the raw fallback in the caller applies regardless.
1879
+ const RECALL_BROWSE_DRAIN_DEADLINE_MS = 1800
1880
+
1818
1881
  async function handleSearch(args, signal) {
1819
1882
  // Cooperative abort check: throw early if the caller already aborted
1820
1883
  // (IPC cancel handler signals the AbortController before re-entry).
1821
1884
  if (signal?.aborted) throw signal.reason ?? new Error('aborted')
1885
+ // Drain this session's unchunked backlog to zero BEFORE searching, so a
1886
+ // recall never returns raw/unclassified transcript tail instead of the
1887
+ // chunked summary. Runs whenever a session is scoped, query or not — a
1888
+ // bare "show recent" browse hits raw unchunked rows just as often as a
1889
+ // query recall does. Query path keeps the unbounded (deadlineMs:0) drain
1890
+ // since chunked accuracy matters more than latency there; bare browse
1891
+ // gets a short best-effort deadline — it must never block on LLM
1892
+ // chunking for minutes, the raw-row merge covers the fallback either way.
1893
+ {
1894
+ const _drainSessionId = String(args?.sessionId || args?.session_id || '').trim()
1895
+ const _drainHasQuery = Array.isArray(args?.query)
1896
+ ? args.query.some((v) => String(v || '').trim())
1897
+ : String(args?.query ?? '').trim() !== ''
1898
+ if (_drainSessionId) {
1899
+ await _drainSessionForRecallQuery(
1900
+ _drainSessionId,
1901
+ signal,
1902
+ _drainHasQuery ? 0 : RECALL_BROWSE_DRAIN_DEADLINE_MS,
1903
+ )
1904
+ }
1905
+ }
1906
+ // #id lookup normalization: search_memories and memory action:'search'
1907
+ // callers pass a single `id` (or an id array under that same key), not
1908
+ // the `ids` array below. Normalize once here so every dispatch path gets
1909
+ // exact-id lookup, not just callers who already knew to use `ids`.
1910
+ if (!Array.isArray(args.ids) && args.id != null) {
1911
+ args = { ...args, ids: Array.isArray(args.id) ? args.id : [args.id] }
1912
+ }
1822
1913
  if (args?.currentSession === true || args?.sessionId || args?.session_id) {
1823
1914
  return await recallSessionRows(args)
1824
1915
  }
@@ -1830,7 +1921,7 @@ async function handleSearch(args, signal) {
1830
1921
  if (Array.isArray(args.ids) && args.ids.length > 0) {
1831
1922
  const ids = args.ids
1832
1923
  .map(v => Number(v))
1833
- .filter(v => Number.isFinite(v) && v > 0)
1924
+ .filter(v => Number.isInteger(v) && v > 0)
1834
1925
  if (ids.length === 0) return { text: '(no valid ids)' }
1835
1926
  const includeArchived = args.includeArchived !== false
1836
1927
  const category = args.category
@@ -1935,8 +2026,6 @@ async function handleSearch(args, signal) {
1935
2026
  })),
1936
2027
  deadlineRace,
1937
2028
  ])
1938
- } catch (err) {
1939
- throw err
1940
2029
  } finally {
1941
2030
  clearTimeout(deadlineTimer)
1942
2031
  }
@@ -1972,7 +2061,15 @@ async function handleSearch(args, signal) {
1972
2061
  offset = Math.min(RECALL_OFFSET_CAP, offset)
1973
2062
  }
1974
2063
  const recallCapPrefix = recallCapNotes.length ? `${recallCapNotes.join('; ')}\n` : ''
1975
- const sort = args.sort != null ? String(args.sort) : 'importance'
2064
+ // Recent-browsing default: a query-less recall is a "show me the latest
2065
+ // messages" browse, not a relevance search — chronological order is the
2066
+ // only ordering that makes sense there, so sort defaults to 'date' when
2067
+ // no query is present (explicit args.sort still wins). Query recalls keep
2068
+ // the importance default.
2069
+ const hasQueryForSort = Array.isArray(args.query)
2070
+ ? args.query.some((v) => String(v || '').trim())
2071
+ : String(args.query ?? '').trim() !== ''
2072
+ const sort = args.sort != null ? String(args.sort) : (hasQueryForSort ? 'importance' : 'date')
1976
2073
  // Chunk content is the primary recall output. Members default to true so
1977
2074
  // callers receive the raw chunk leaves (the cycle1-produced semantic
1978
2075
  // chunks) rather than just the root's cycle2-compressed summary line.
@@ -2071,13 +2168,16 @@ async function handleSearch(args, signal) {
2071
2168
  })
2072
2169
  }
2073
2170
  if (includeRaw) {
2074
- // Reserve slots for raw rows under sort=importance: hybrid rows are
2075
- // already score-sorted descending, so a full hybrid page (limit rows)
2076
- // would shut out raw rows entirely after slice(offset, offset+limit).
2077
- // Reserve up to RAW_RESERVE slots near the top of the post-slice
2078
- // window by trimming the hybrid prefix before merging, then re-sort
2079
- // for sort=date or otherwise append (already ranked) for importance.
2080
- const RAW_FETCH = 20
2171
+ // Raw rows (chunk_root IS NULL) carry no retrievalScore, so a naive
2172
+ // append-after-hybrid under sort=importance always lands them past
2173
+ // slice(offset, offset+limit) once the hybrid pool exceeds one page —
2174
+ // every page beyond the first silently drops them. Fetch a wider raw
2175
+ // window (bounded like the hybrid candidate pool) and spread the
2176
+ // fetched raw rows evenly across the WHOLE hybrid list before slicing,
2177
+ // so every offset page gets its proportional share instead of only
2178
+ // page 0. Same projectScope/ts window as the hybrid leg — filter
2179
+ // parity (item 3) is deliberate, not accidental.
2180
+ const RAW_FETCH = Math.min(500, Math.max(20, limit + offset))
2081
2181
  const rawRows = await readRawRowsInWindow(
2082
2182
  db,
2083
2183
  temporal?.startMs ?? null,
@@ -2091,10 +2191,10 @@ async function handleSearch(args, signal) {
2091
2191
  for (const r of newRaw) filtered.push(r)
2092
2192
  filtered.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0))
2093
2193
  } else {
2094
- // sort=importance: append raw rows after the hybrid page (mostly
2095
- // ineffective slice(offset, offset+limit) typically shuts them
2096
- // out). Proper includeRaw paging fix deferred (needs fetching extra rows / paging redesign).
2097
- for (const r of newRaw) filtered.push(r)
2194
+ // sort=importance: interleave raw rows at a fixed stride through the
2195
+ // full (pre-slice) hybrid list instead of appending at the tail, so
2196
+ // offset > 0 pages also draw from the raw pool proportionally.
2197
+ filtered = interleaveRawRows(filtered, newRaw)
2098
2198
  }
2099
2199
  }
2100
2200
  const coreRows = await recallCoreRows(query, { projectScope, category, limit })
@@ -2133,7 +2233,16 @@ async function handleSearch(args, signal) {
2133
2233
 
2134
2234
  const filters = { limit: limit + offset }
2135
2235
  if (temporal?.startMs != null) { filters.ts_from = temporal.startMs; filters.ts_to = temporal.endMs }
2136
- if (temporal?.mode === 'last' && _bootTimestamp) {
2236
+ // period='last' used to hard-cap ts_to at _bootTimestamp-1 unconditionally,
2237
+ // which silently hid the CURRENT session's own content from a query-less
2238
+ // recent browse (the whole point of "what did we just talk about"). Keep
2239
+ // the boot cap only for session-less global browsing, where 'last' means
2240
+ // "the previously completed session" and there's no session-scoped drain
2241
+ // above to have already surfaced the in-flight rows. When a sessionId is
2242
+ // present the drain above already pulled that session's raw backlog to
2243
+ // zero, so the current session's rows are safe (and wanted) to include.
2244
+ const _hasScopedSession = Boolean(String(args?.sessionId || args?.session_id || '').trim())
2245
+ if (temporal?.mode === 'last' && _bootTimestamp && !_hasScopedSession) {
2137
2246
  filters.ts_to = _bootTimestamp - 1
2138
2247
  }
2139
2248
  filters.projectScope = projectScope
@@ -2142,55 +2251,33 @@ async function handleSearch(args, signal) {
2142
2251
  if (!includeArchived) filters.excludeStatuses = ['archived']
2143
2252
  if (includeMembers) filters.includeMembers = true
2144
2253
  const rows = await retrieveEntries(db, filters)
2145
- const sliced = rows.slice(offset, offset + limit)
2146
- return { text: recallCapPrefix + renderEntryLines(sliced) }
2147
- }
2148
-
2149
- function renderEntryLines(rows) {
2150
- if (!rows || rows.length === 0) return '(no results)'
2151
- const lines = []
2152
- // Bound total emitted lines (roots x members) so a many-member recall can't
2153
- // inject unbounded output. Per-line content is already capped at 1000 chars;
2154
- // this caps the line COUNT. Narrow the query (limit/period/projectScope) for more.
2155
- const RECALL_LINE_CAP = 200
2156
- let _capped = false
2157
- outer:
2158
- for (const r of rows) {
2159
- const hasMembers = Array.isArray(r.members) && r.members.length > 0
2160
- if (hasMembers) {
2161
- // Chunks present: emit each member as its own line. Root row is a
2162
- // grouping artifact for retrieval the caller wants the chunk
2163
- // content (cycle1 raw), not the cycle2-compressed summary.
2164
- for (const m of r.members) {
2165
- if (lines.length >= RECALL_LINE_CAP) { _capped = true; break outer }
2166
- const mTs = formatTs(m.ts)
2167
- const role = m.role === 'user' ? 'u' : m.role === 'assistant' ? 'a' : (m.role || '?')
2168
- const content = cleanMemoryText(String(m.content ?? '')).slice(0, 1000)
2169
- lines.push(`[${mTs}] ${role}: ${content} #${m.id}`)
2170
- }
2171
- } else {
2172
- if (lines.length >= RECALL_LINE_CAP) { _capped = true; break }
2173
- // No chunks (root not yet chunked by cycle1, or orphan leaf): emit
2174
- // the row itself in the same shape. element/summary fall back to
2175
- // raw content when both are absent.
2176
- const ts = formatTs(r.ts)
2177
- const element = r.element ?? ''
2178
- const summary = r.summary ?? ''
2179
- // Standalone leaf rows (is_root=0, no parent chunks_root resolved
2180
- // into a `members` list) carry their u/a role just like inline
2181
- // chunk members — surface it so the format stays consistent across
2182
- // the two emission paths.
2183
- const rolePrefix = r.is_root === 0 && r.role
2184
- ? (r.role === 'user' ? 'u: ' : r.role === 'assistant' ? 'a: ' : `${r.role}: `)
2185
- : ''
2186
- const body = element || summary
2187
- ? `${element}${summary ? ' — ' + summary : ''}`
2188
- : cleanMemoryText(String(r.content ?? '')).slice(0, 1000)
2189
- lines.push(`[${ts}] ${rolePrefix}${body.slice(0, 1000)} #${r.id}`)
2254
+ // Recent-browsing raw merge: a query-less recall must show the freshest
2255
+ // turns even when cycle1 hasn't chunked them yet. Roots lag ingest by up
2256
+ // to a cycle interval, so on sort=date pull the raw (unchunked) window
2257
+ // too and merge chronologically — original text first, no summaries.
2258
+ // Query-less + includeRaw:false callers keep the roots-only view.
2259
+ let merged = rows
2260
+ if (sort === 'date' && args.includeRaw !== false) {
2261
+ const rawRows = await readRawRowsInWindow(
2262
+ db,
2263
+ temporal?.startMs ?? null,
2264
+ temporal?.endMs ?? (filters.ts_to ?? Date.now()),
2265
+ Math.min(500, Math.max(20, limit + offset)),
2266
+ { projectScope },
2267
+ )
2268
+ const seenIds = new Set(rows.map(r => Number(r.id)))
2269
+ // Drop raw leaves already inlined as some returned root's member.
2270
+ for (const r of rows) {
2271
+ if (Array.isArray(r.members)) for (const m of r.members) seenIds.add(Number(m.id))
2272
+ }
2273
+ const newRaw = rawRows.filter(r => !seenIds.has(Number(r.id)))
2274
+ if (newRaw.length > 0) {
2275
+ merged = [...rows, ...newRaw]
2276
+ merged.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0) || (Number(b.id) || 0) - (Number(a.id) || 0))
2190
2277
  }
2191
2278
  }
2192
- if (_capped) lines.push(`[recall truncated showing first ${RECALL_LINE_CAP} lines; narrow the query (limit/period/projectScope) for the rest]`)
2193
- return lines.join('\n')
2279
+ const sliced = merged.slice(offset, offset + limit)
2280
+ return { text: recallCapPrefix + renderEntryLines(sliced) }
2194
2281
  }
2195
2282
 
2196
2283
  async function dumpSessionRootChunks(args = {}) {
@@ -2824,11 +2911,57 @@ async function handleMemoryAction(args, signal) {
2824
2911
 
2825
2912
  if (action === 'core') {
2826
2913
  const op = String(args.op ?? '').trim().toLowerCase()
2827
- if (!['add', 'edit', 'delete', 'list'].includes(op)) {
2828
- return { text: 'core requires op: "add" | "edit" | "delete" | "list"', isError: true }
2914
+ if (!['add', 'edit', 'delete', 'list', 'candidates', 'promote', 'dismiss'].includes(op)) {
2915
+ return { text: 'core requires op: "add" | "edit" | "delete" | "list" | "candidates" | "promote" | "dismiss"', isError: true }
2829
2916
  }
2830
2917
  const dataDir = (typeof DATA_DIR === 'string' ? DATA_DIR : resolvePluginData())
2831
2918
  if (!dataDir) return { text: 'core: memory data dir is not initialized', isError: true }
2919
+ // Core-candidate promotion pipeline (proposal mode). The candidate flag
2920
+ // lives on generated `entries`, which carry a project_id, so these ops MUST
2921
+ // be project-scoped just like add/edit/delete — an unscoped listing/promote
2922
+ // would leak candidates across projects. project_id resolution mirrors the
2923
+ // block below: 'common'/null → COMMON (project_id NULL), '*' → all pools
2924
+ // (candidates op only, same escape hatch as op:'list'). UI calls exactly
2925
+ // these op names.
2926
+ if (op === 'candidates' || op === 'promote' || op === 'dismiss') {
2927
+ const hasPid = Object.prototype.hasOwnProperty.call(args, 'project_id')
2928
+ const scope = (() => {
2929
+ if (!hasPid || args.project_id == null) return null
2930
+ const s = String(args.project_id).trim()
2931
+ if (s === '' || s.toLowerCase() === 'common') return null
2932
+ if (s === '*') return '*'
2933
+ return s
2934
+ })()
2935
+ try {
2936
+ if (op === 'candidates') {
2937
+ const list = await listCoreCandidates(dataDir, scope)
2938
+ if (list.length === 0) return { text: 'core candidates: none' }
2939
+ return {
2940
+ text: list.map(c =>
2941
+ // project=<pool> lets the UI thread project_id into the follow-up
2942
+ // promote/dismiss call — matters under project_id:'*' listing where
2943
+ // rows span pools. Uses the same COMMON/slug convention as op:'list'.
2944
+ `id=${c.id} project=${c.project_id == null ? 'COMMON' : c.project_id} [${c.category}] score=${c.score == null ? '-' : c.score.toFixed(2)} ${c.element} — ${String(c.summary || '').slice(0, 200)} (${c.reason})`,
2945
+ ).join('\n'),
2946
+ }
2947
+ }
2948
+ // promote/dismiss operate on a single id but are scope-guarded: the
2949
+ // candidate must belong to the resolved scope (or COMMON), never '*'.
2950
+ if (scope === '*') {
2951
+ return { text: `core ${op}: project_id "*" only valid for op="candidates"`, isError: true }
2952
+ }
2953
+ if (op === 'promote') {
2954
+ const entry = await promoteCoreCandidate(dataDir, args.id, { ...args, scope })
2955
+ const mergeNote = entry.merged_with ? ` (merged into core id=${entry.merged_with}, sim=${entry.sim})` : ''
2956
+ return { text: `core promoted candidate id=${args.id} → core id=${entry.id}${mergeNote}: [${entry.category}] ${entry.element}` }
2957
+ }
2958
+ // dismiss
2959
+ const removed = await dismissCoreCandidate(dataDir, args.id, { scope })
2960
+ return { text: `core candidate dismissed (id=${removed.id}): [${removed.category}] ${removed.element}` }
2961
+ } catch (e) {
2962
+ return { text: `core ${op} failed: ${e.message}`, isError: true }
2963
+ }
2964
+ }
2832
2965
  // Local trim helper — the manage-block trimOrNull at :1807 is scoped to
2833
2966
  // that branch and unreachable from here.
2834
2967
  // Normalize project_id: 'common' (case-insensitive) or null → null (COMMON pool); non-empty string → slug.
@@ -3095,37 +3228,6 @@ function createHttpMcpServer() {
3095
3228
  return s
3096
3229
  }
3097
3230
 
3098
- function readBody(req) {
3099
- return new Promise((resolve, reject) => {
3100
- const chunks = []
3101
- req.on('data', c => chunks.push(c))
3102
- req.on('end', () => {
3103
- const raw = Buffer.concat(chunks).toString('utf8').trim()
3104
- if (!raw) { resolve({}); return }
3105
- try { resolve(JSON.parse(raw)) }
3106
- catch (error) {
3107
- const e = new Error(`invalid JSON body: ${error.message}`)
3108
- e.statusCode = 400
3109
- reject(e)
3110
- }
3111
- })
3112
- req.on('error', reject)
3113
- })
3114
- }
3115
-
3116
- function sendJson(res, data, status = 200) {
3117
- const body = JSON.stringify(data)
3118
- res.writeHead(status, {
3119
- 'Content-Type': 'application/json; charset=utf-8',
3120
- 'Content-Length': Buffer.byteLength(body),
3121
- })
3122
- res.end(body)
3123
- }
3124
-
3125
- function sendError(res, msg, status = 500) {
3126
- sendJson(res, { error: msg }, status)
3127
- }
3128
-
3129
3231
  async function awaitRuntimeReadyForHttp(res) {
3130
3232
  if (_initialized) return true
3131
3233
  if (!_initPromise) {
@@ -3141,29 +3243,6 @@ async function awaitRuntimeReadyForHttp(res) {
3141
3243
  }
3142
3244
  }
3143
3245
 
3144
- // Origin/Referer guard for /admin/* mutation routes. Memory-service binds
3145
- // 127.0.0.1, but browser DNS-rebinding or a stray cross-origin fetch could
3146
- // still reach destructive endpoints (purge, backfill, entry mutations).
3147
- // Server-to-server callers (setup-server, hooks) issue raw http.request
3148
- // without a browser Origin/Referer, so absent headers pass; any non-loopback
3149
- // Origin/Referer is rejected. Mirrors setup-server.mjs isAllowedOrigin.
3150
- function isLocalOrigin(req) {
3151
- const LOOP = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i
3152
- const origin = req.headers.origin || ''
3153
- const referer = req.headers.referer || ''
3154
- if (origin && !LOOP.test(origin)) return false
3155
- if (referer && !LOOP.test(referer)) return false
3156
- return true
3157
- }
3158
-
3159
- function normalizeCoreProjectId(value, { allowStar = false } = {}) {
3160
- if (value == null) return null
3161
- const s = String(value).trim()
3162
- if (!s || s.toLowerCase() === 'common') return null
3163
- if (allowStar && s === '*') return '*'
3164
- return s
3165
- }
3166
-
3167
3246
  async function buildSessionCoreMemoryPayload(cwd) {
3168
3247
  const projectId = resolveProjectScope(typeof cwd === 'string' && cwd ? cwd : null)
3169
3248
  const generatedScopeClause = projectId !== null
@@ -3246,6 +3325,9 @@ const httpServer = http.createServer(async (req, res) => {
3246
3325
  active_core_summaries: stats.active_core_summaries,
3247
3326
  active_core_summary_missing: stats.active_core_summary_missing,
3248
3327
  mv_hot_active_populated: stats.mv_hot_active_populated,
3328
+ cycle_running: _cycleRunning,
3329
+ cycle_health: _cycleHealth,
3330
+ cycle_backlog: _cycleBacklogSnapshot,
3249
3331
  })
3250
3332
  } catch (e) { sendError(res, e.message) }
3251
3333
  return
@@ -3920,8 +4002,46 @@ export async function stop() {
3920
4002
  // postmaster keeps running after the memory service exits.
3921
4003
  if (!memorySecondaryMode()) {
3922
4004
  try {
3923
- const { stopPgForShutdown } = await import('./lib/pg/supervisor.mjs')
3924
- await stopPgForShutdown()
4005
+ // Conservative check: only skip stopPgForShutdown when the owner
4006
+ // record is unambiguously (a) a memory-runtime-daemon owner record
4007
+ // (kind check — guards against a stale/foreign pid reusing this pid
4008
+ // number for an unrelated process) and (b) that pid is alive AND not
4009
+ // this process. Any read/parse failure or ambiguous state falls back
4010
+ // to stopping PG (previous unconditional behavior) rather than
4011
+ // risking an orphaned PG postmaster.
4012
+ const anotherOwnerAlive = await (async () => {
4013
+ try {
4014
+ const { readSingletonOwner } = await import('../shared/singleton-owner.mjs')
4015
+ const ownerPath = path.join(DATA_DIR, 'memory-runtime-owner.json')
4016
+ const { owner, alive } = readSingletonOwner(ownerPath)
4017
+ if (!alive) return false
4018
+ if (owner?.kind !== 'memory-runtime-daemon') return false
4019
+ const ownerPid = Number(owner?.pid)
4020
+ if (!Number.isInteger(ownerPid) || ownerPid === process.pid) return false
4021
+ // Best-effort process-name check (mirrors supervisor.mjs's
4022
+ // isPostgresPid pattern) — confirms the pid is actually a node
4023
+ // process before trusting it as a live sibling memory owner.
4024
+ // Falls back to true (trust the owner file) when the platform
4025
+ // check is unavailable or inconclusive.
4026
+ try {
4027
+ if (process.platform === 'win32') {
4028
+ const { execFileSync } = await import('node:child_process')
4029
+ const out = execFileSync('tasklist', ['/FI', `PID eq ${ownerPid}`, '/FO', 'CSV', '/NH'], { encoding: 'utf8', windowsHide: true })
4030
+ if (!String(out || '').toLowerCase().includes('node')) return false
4031
+ } else if (process.platform === 'linux') {
4032
+ const comm = fs.readFileSync(`/proc/${ownerPid}/comm`, 'utf8').trim()
4033
+ if (!comm.includes('node')) return false
4034
+ }
4035
+ } catch { /* inconclusive — trust the owner file (already alive+kind-matched) */ }
4036
+ return true
4037
+ } catch { return false }
4038
+ })()
4039
+ if (anotherOwnerAlive) {
4040
+ __mixdogMemoryLog('[memory-service] shutdown: another live memory owner holds memory-runtime-owner.json — leaving PG running\n')
4041
+ } else {
4042
+ const { stopPgForShutdown } = await import('./lib/pg/supervisor.mjs')
4043
+ await stopPgForShutdown()
4044
+ }
3925
4045
  } catch {}
3926
4046
  } else {
3927
4047
  __mixdogMemoryLog('[memory-service] secondary mode; leaving shared PG running\n')