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
@@ -18,11 +18,35 @@ export function buildCategoryFilterClause(offset, categories, { tableAlias = ''
18
18
  return { clause: `AND (${inner})`, params: [...cats] }
19
19
  }
20
20
 
21
+ // Shared, param-less predicate excluding promoted/promoting core-candidate
22
+ // roots AND member rows under such a root. Single source of truth so the hybrid
23
+ // recall path (buildRecallScopeFilter) and the query-less browse path
24
+ // (retrieveEntries) can't diverge. tableAlias='' → bare `entries` column refs.
25
+ export function buildPromotedExclusionClauses(tableAlias = '') {
26
+ const p = `${tableAlias || 'entries'}.`
27
+ return [
28
+ // Promoted core-candidate roots have been absorbed into user-curated
29
+ // core_entries and archived by promoteCoreCandidate. Their content now
30
+ // lives in the {{USER_CORE}} slot, so surfacing the stale generated root
31
+ // would double-serve the same fact. 'promoting' (mid-flight or crashed
32
+ // promote awaiting recovery) is excluded too — its root is already archived
33
+ // and finalizes to 'promoted'. Member rows whose chunk_root points at a
34
+ // promoted/promoting root are excluded via EXISTS-on-root (the flag lives
35
+ // only on the root; members keep NULL). Constant predicates — no bind param.
36
+ `(${p}core_candidate_status IS NULL OR ${p}core_candidate_status NOT IN ('promoted', 'promoting'))`,
37
+ `NOT (${p}is_root = 0 AND ${p}chunk_root IS NOT NULL AND ${p}chunk_root <> ${p}id AND EXISTS (
38
+ SELECT 1 FROM entries r WHERE r.id = ${p}chunk_root AND r.is_root = 1 AND r.core_candidate_status IN ('promoted', 'promoting')
39
+ ))`,
40
+ ]
41
+ }
42
+
21
43
  export function buildRecallScopeFilter(offset, options = {}, tableAlias = '') {
22
44
  const outerRef = tableAlias || 'entries'
23
45
  const p = `${outerRef}.`
24
46
  const clauses = [
25
47
  `NOT (${p}is_root = 0 AND ${p}chunk_root IS NOT DISTINCT FROM ${p}id AND ${p}status IS NOT DISTINCT FROM 'archived')`,
48
+ // Exclude promoted/promoting roots + their members (shared predicate).
49
+ ...buildPromotedExclusionClauses(tableAlias),
26
50
  ]
27
51
  const params = []
28
52
  let next = offset
@@ -1,5 +1,7 @@
1
1
  import { recallReadQuery } from './memory-recall-read-query.mjs'
2
2
 
3
+ import { buildPromotedExclusionClauses } from './memory-recall-scope-filter.mjs'
4
+
3
5
  const VALID_CATEGORIES_SET = new Set([
4
6
  'rule', 'constraint', 'decision', 'fact', 'goal', 'preference', 'task', 'issue',
5
7
  ])
@@ -72,6 +74,12 @@ export async function retrieveEntries(db, filters = {}) {
72
74
  where.push(`chunk_root IS NULL`)
73
75
  }
74
76
 
77
+ // Exclude promoted/promoting core-candidate roots AND members under such a
78
+ // root — the query-less browse path (includeArchived) would otherwise surface
79
+ // rows the hybrid recall path already filters. Shared predicate (param-less)
80
+ // keeps this in lock-step with buildRecallScopeFilter.
81
+ where.push(...buildPromotedExclusionClauses())
82
+
75
83
  const limit = Math.max(1, Math.min(500, Number(filters.limit ?? 50)))
76
84
  const offset = Math.max(0, Number(filters.offset ?? 0))
77
85
  const sort = String(filters.sort ?? 'importance').trim().toLowerCase()
@@ -136,9 +136,18 @@ export async function init(db, dims) {
136
136
  await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_phase_sweep ON entries(status, is_root, error_count, reviewed_at, id)`)
137
137
  await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_promoted_at ON entries(promoted_at) WHERE promoted_at IS NOT NULL`)
138
138
  await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_tsv ON entries USING GIN (search_tsv)`)
139
- await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_content_trgm ON entries USING GIN (content gin_trgm_ops) WHERE is_root = 1`)
140
- await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_element_trgm ON entries USING GIN (element gin_trgm_ops) WHERE is_root = 1 AND element IS NOT NULL`)
141
- await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_embedding_hnsw ON entries USING hnsw (embedding halfvec_cosine_ops) WHERE is_root = 1 AND embedding IS NOT NULL`)
139
+ // Recall CTEs (memory-recall-store.mjs dense/trgm legs) intentionally match
140
+ // BOTH root and leaf/chunk rows, so their SQL has NO `is_root = 1` predicate
141
+ // (only `embedding IS NOT NULL` / content|element|summary text filters).
142
+ // The old root-only PARTIAL indexes therefore could not be used by those
143
+ // queries — the planner fell back to a Seq Scan + top-N heapsort over every
144
+ // embedding (verified via EXPLAIN ANALYZE). Broaden the predicates to match
145
+ // the query shape so HNSW/GIN are actually used. A `summary` trgm index is
146
+ // added because the trgm leg also filters on `summary` but had no index.
147
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_content_trgm ON entries USING GIN (content gin_trgm_ops)`)
148
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_element_trgm ON entries USING GIN (element gin_trgm_ops) WHERE element IS NOT NULL`)
149
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_summary_trgm ON entries USING GIN (summary gin_trgm_ops) WHERE summary IS NOT NULL`)
150
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_embedding_hnsw ON entries USING hnsw (embedding halfvec_cosine_ops) WHERE embedding IS NOT NULL`)
142
151
 
143
152
  // BEFORE INSERT/UPDATE trigger keeps score in sync with category + last_seen_at
144
153
  // automatically; cycle code no longer needs to UPDATE entries SET score = ...
@@ -333,19 +342,127 @@ export async function validateEmbeddingDims(db, dimCount) {
333
342
  }
334
343
  }
335
344
 
345
+ // One-time migration: broaden the recall indexes that were created as root-only
346
+ // PARTIAL indexes (`WHERE is_root = 1 ...`) so they match the recall CTE query
347
+ // predicates (which do NOT restrict is_root — recall matches leaf/chunk rows
348
+ // too). A stale root-only index is unusable by those queries and forces a Seq
349
+ // Scan. This runs on every boot (via ensureCurrentSchemaExtensions), so it is
350
+ // carefully guarded: it only DROP+CREATEs an index when the CURRENT definition
351
+ // still contains the `is_root = 1` predicate. Once broadened, every check is a
352
+ // cheap catalog read and no rebuild happens. Best-effort: catalog-read or DDL
353
+ // failures are logged and swallowed so a boot is never blocked by this.
354
+ async function _migrateRecallIndexesIfStale(db) {
355
+ // Each entry: [indexName, newDefTailPredicate] where the presence of
356
+ // "is_root" in the live indexdef signals the stale root-only shape.
357
+ const targets = [
358
+ { name: 'idx_entries_content_trgm', create: `CREATE INDEX idx_entries_content_trgm ON entries USING GIN (content gin_trgm_ops)` },
359
+ { name: 'idx_entries_element_trgm', create: `CREATE INDEX idx_entries_element_trgm ON entries USING GIN (element gin_trgm_ops) WHERE element IS NOT NULL` },
360
+ { name: 'idx_entries_embedding_hnsw', create: `CREATE INDEX idx_entries_embedding_hnsw ON entries USING hnsw (embedding halfvec_cosine_ops) WHERE embedding IS NOT NULL` },
361
+ ]
362
+ try {
363
+ for (const t of targets) {
364
+ let def = null
365
+ try {
366
+ const r = await db.query(`SELECT indexdef FROM pg_indexes WHERE indexname = $1`, [t.name])
367
+ def = r.rows?.[0]?.indexdef ?? null
368
+ } catch { def = null }
369
+ // Missing index → post-loop IF-NOT-EXISTS ensures below self-heal trgm
370
+ // indexes; embedding_hnsw is also re-ensured by ensureCurrentSchemaExtensions.
371
+ if (def == null) continue
372
+ // Already broadened (no is_root predicate) → no-op, no rebuild.
373
+ if (!/is_root/i.test(def)) continue
374
+ try {
375
+ await db.exec(`DROP INDEX IF EXISTS ${t.name}`)
376
+ await db.exec(t.create)
377
+ __mixdogMemoryLog(`[memory] migrated stale root-only index ${t.name} → broadened to match recall query predicates\n`)
378
+ } catch (err) {
379
+ __mixdogMemoryLog(`[memory] recall index migration for ${t.name} failed: ${err?.message || err}\n`)
380
+ }
381
+ }
382
+ // Self-heal trgm recall indexes on already-bootstrapped DBs: init() will
383
+ // not re-run, and a missing index is not handled by the stale-shape loop
384
+ // above (def == null → continue). These IF-NOT-EXISTS ensures recreate any
385
+ // trgm index that never existed or whose migrate-create failed. No-op (cheap
386
+ // catalog check) when the broadened index is already present.
387
+ try {
388
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_content_trgm ON entries USING GIN (content gin_trgm_ops)`)
389
+ } catch (err) {
390
+ __mixdogMemoryLog(`[memory] idx_entries_content_trgm ensure failed: ${err?.message || err}\n`)
391
+ }
392
+ try {
393
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_element_trgm ON entries USING GIN (element gin_trgm_ops) WHERE element IS NOT NULL`)
394
+ } catch (err) {
395
+ __mixdogMemoryLog(`[memory] idx_entries_element_trgm ensure failed: ${err?.message || err}\n`)
396
+ }
397
+ try {
398
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_summary_trgm ON entries USING GIN (summary gin_trgm_ops) WHERE summary IS NOT NULL`)
399
+ } catch (err) {
400
+ __mixdogMemoryLog(`[memory] idx_entries_summary_trgm ensure failed: ${err?.message || err}\n`)
401
+ }
402
+ } catch (err) {
403
+ __mixdogMemoryLog(`[memory] _migrateRecallIndexesIfStale failed: ${err?.message || err}\n`)
404
+ }
405
+ }
406
+
336
407
  export async function ensureCurrentSchemaExtensions(db, dims) {
408
+ // One-time cleanup: attachment-only placeholder rows ('(attachment)' user
409
+ // content, e.g. Discord backend discord.mjs:724) predate the
410
+ // shouldExcludeIngestMessage() ingest-time filter (session-ingest.mjs).
411
+ // Delete any already-persisted rows so they stop polluting recall/cycle1.
412
+ // Idempotent (no-op once cleaned); best-effort so a failure never blocks boot.
413
+ try {
414
+ const cleaned = await db.query(
415
+ `DELETE FROM entries WHERE content = '(attachment)' AND role = 'user'`,
416
+ )
417
+ const n = Number(cleaned?.rowCount ?? 0)
418
+ if (n > 0) {
419
+ __mixdogMemoryLog(`[memory] ensureCurrentSchemaExtensions: removed ${n} attachment-only placeholder rows\n`)
420
+ }
421
+ } catch (err) {
422
+ __mixdogMemoryLog(`[memory] attachment-placeholder cleanup failed: ${err?.message || err}\n`)
423
+ }
337
424
  // core_entries gained an embedding column for cross-table semantic dedup
338
425
  // between user-curated rows and cycle2-promoted entries. ALTER + index are
339
426
  // idempotent and define the current runtime schema.
340
427
  if (Number.isInteger(dims) && dims > 0) {
341
428
  await db.exec(`ALTER TABLE core_entries ADD COLUMN IF NOT EXISTS embedding halfvec(${dims})`)
342
- await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_embedding_hnsw ON entries USING hnsw (embedding halfvec_cosine_ops) WHERE is_root = 1 AND embedding IS NOT NULL`)
429
+ // One-time migration for EXISTING deployments (bootstrap-complete DBs never
430
+ // re-run init(), so the broadened index definitions there would otherwise
431
+ // never reach them). This path runs on EVERY boot, so we must NOT
432
+ // unconditionally DROP+CREATE an HNSW index (that would rebuild it on every
433
+ // startup). Only rebuild when the current index still carries the stale
434
+ // root-only `is_root = 1` predicate; once broadened, the check is a no-op.
435
+ await _migrateRecallIndexesIfStale(db)
436
+ // Residual (low risk, no action needed): cycle2's root-active embedding
437
+ // scans (memory-cycle2.mjs) now share this broader all-embedding HNSW
438
+ // instead of a root-only partial index; acceptable at current scale.
439
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_entries_embedding_hnsw ON entries USING hnsw (embedding halfvec_cosine_ops) WHERE embedding IS NOT NULL`)
343
440
  await db.exec(`CREATE INDEX IF NOT EXISTS core_entries_embedding_hnsw ON core_entries USING hnsw (embedding halfvec_cosine_ops) WHERE embedding IS NOT NULL`)
344
441
  }
345
442
  await db.exec(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS core_summary text`)
346
443
 
347
444
 
348
445
 
446
+ // Core-candidate promotion pipeline (proposal mode): active entries the
447
+ // cycle2 nomination pass flags as strong core-memory candidates. Never
448
+ // auto-inserted into core_entries — a user approves each via the
449
+ // action:'core' op:'promote' handler. Columns are nullable and the ALTERs
450
+ // are idempotent (ADD COLUMN IF NOT EXISTS), safe to re-run every boot.
451
+ // core_candidate_status: NULL (not a candidate) | 'candidate' | 'promoting'
452
+ // (mid-flight promote, recoverable) | 'promoted' | 'dismissed'
453
+ // core_candidate_at: ms timestamp of last nomination/state change
454
+ // 'dismissed'/'promoted' are terminal for a given root so the pass never
455
+ // re-nominates the same entry.
456
+ await db.exec(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS core_candidate_status text`)
457
+ await db.exec(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS core_candidate_at bigint`)
458
+ // No index on core_candidate_status by design (round-2 finding #4): the only
459
+ // readers are listCoreCandidates (user picker, on-demand) and
460
+ // nominateCoreCandidates (once per cycle2, hourly). Both are rare and the
461
+ // entries table is small enough that a seq scan is fine — an index isn't
462
+ // worth the boot-time AccessExclusive build lock on the hot entries table.
463
+ // Drop it if a previous deploy created it (idempotent no-op otherwise).
464
+ await db.exec(`DROP INDEX IF EXISTS idx_entries_core_candidate`)
465
+
349
466
  // Dedupe core_entries before creating the unique index — keeps the row with
350
467
  // the most recent updated_at (id breaks ties), drops the rest.
351
468
  const dedupe = await db.query(`
@@ -105,31 +105,50 @@ const _checkedConnect = checkedConnect
105
105
  // native PG db shim
106
106
  // ---------------------------------------------------------------------------
107
107
 
108
- function makeCompatDb(pgPool, schema) {
108
+ function makeCompatDb(pgPool, schema, dataDir) {
109
109
  const db = {
110
110
  // query: use pool directly for single-statement queries
111
111
  query: async (sql, params) => {
112
- const client = await _checkedConnect(pgPool, schema)
113
- try {
114
- return await client.query(sql, params)
115
- } finally {
116
- client.release()
117
- }
112
+ return await withPgRetry(dataDir, schema, async (pool) => {
113
+ const client = await _checkedConnect(pool, schema)
114
+ try {
115
+ return await client.query(sql, params)
116
+ } finally {
117
+ client.release()
118
+ }
119
+ })
118
120
  },
119
121
 
120
122
  // exec: multi-statement SQL (semicolon-separated); single client for session state
121
123
  exec: async (sql) => {
122
- const client = await _checkedConnect(pgPool, schema)
124
+ // Not retried: exec runs arbitrary multi-statement SQL where a partial
125
+ // failure mid-sequence is unobservable from here — replaying the whole
126
+ // string after recovery risks double-applying already-committed
127
+ // statements. On ECONNREFUSED, trigger recovery in the background (so
128
+ // the NEXT call gets a fresh pool) and propagate the original error.
129
+ const pool = instances.get(`${resolve(dataDir)}|${schema}`)?.pool ?? pgPool
123
130
  try {
124
- await client.query(sql)
125
- } finally {
126
- client.release()
131
+ const client = await _checkedConnect(pool, schema)
132
+ try {
133
+ await client.query(sql)
134
+ } finally {
135
+ client.release()
136
+ }
137
+ } catch (err) {
138
+ if (err?.code === 'ECONNREFUSED') _recoverPgConnection(dataDir, schema).catch(() => {})
139
+ throw err
127
140
  }
128
141
  },
129
142
 
130
143
  // transaction: check out one client, BEGIN, run callback(tx), COMMIT or ROLLBACK
131
144
  transaction: async (fn) => {
132
- const client = await _checkedConnect(pgPool, schema)
145
+ // Not retried same rationale as exec(): a COMMIT that fails with
146
+ // ECONNREFUSED leaves the transaction's applied/rolled-back state
147
+ // unknown to this process; blindly replaying fn() could double-apply
148
+ // side effects. Recover in the background for the next caller and
149
+ // propagate the original error immediately.
150
+ const pool = instances.get(`${resolve(dataDir)}|${schema}`)?.pool ?? pgPool
151
+ const client = await _checkedConnect(pool, schema)
133
152
  try {
134
153
  await client.query('BEGIN')
135
154
  const tx = {
@@ -141,6 +160,7 @@ function makeCompatDb(pgPool, schema) {
141
160
  return result
142
161
  } catch (err) {
143
162
  try { await client.query('ROLLBACK') } catch {}
163
+ if (err?.code === 'ECONNREFUSED') _recoverPgConnection(dataDir, schema).catch(() => {})
144
164
  throw err
145
165
  } finally {
146
166
  client.release()
@@ -150,12 +170,116 @@ function makeCompatDb(pgPool, schema) {
150
170
  // close: drain pool
151
171
  close: () => pgPool.end(),
152
172
 
153
- // Internal access for callers that need raw pool
154
- _pool: pgPool,
173
+ // Internal access for callers that need raw pool. Getter (not a fixed
174
+ // field) so callers that hold onto `db` across a recovery cycle (e.g.
175
+ // trace-store's checkedConnect/pool.connect() call sites) transparently
176
+ // see the refreshed pool after withPgRetry swaps the cached instance —
177
+ // otherwise db._pool would keep pointing at the dead pre-recovery pool.
178
+ get _pool() {
179
+ return instances.get(`${resolve(dataDir)}|${schema}`)?.pool ?? pgPool
180
+ },
155
181
  }
156
182
  return db
157
183
  }
158
184
 
185
+ // ---------------------------------------------------------------------------
186
+ // Self-healing: pool query fails with ECONNREFUSED (target pg_port down while
187
+ // the server process itself stays alive) → discard the stale pool via
188
+ // closePgInstance and re-run ensurePgInstance so supervisor-pg restarts/
189
+ // re-attaches PG, then retry the failed operation exactly once against the
190
+ // fresh pool.
191
+ //
192
+ // Guards against runaway restart loops:
193
+ // - _recoverInFlight: at most one recovery coroutine per dataDir at a time;
194
+ // concurrent callers await the same promise instead of racing restarts.
195
+ // - _lastRecoverAt / RECOVER_COOLDOWN_MS: after a recovery attempt (success
196
+ // or failure) further ECONNREFUSED hits within the cooldown window
197
+ // surface the original error instead of re-triggering PG restart.
198
+ // ---------------------------------------------------------------------------
199
+
200
+ const RECOVER_COOLDOWN_MS = 15_000
201
+ const _lastRecoverAt = new Map() // dataDirKey → epoch ms of last recovery attempt
202
+ const _recoverInFlight = new Map() // dataDirKey → Promise<boolean>
203
+
204
+ function _instanceKeysForDataDir(dataDirKey) {
205
+ const prefix = `${dataDirKey}|`
206
+ return Array.from(instances.keys()).filter(k => k.startsWith(prefix))
207
+ }
208
+
209
+ async function _recoverPgConnection(dataDir, schema) {
210
+ const dataDirKey = resolve(dataDir)
211
+ // In-flight check FIRST: a concurrent recovery already running for this
212
+ // dataDir must be awaited by every caller (even ones arriving inside the
213
+ // cooldown window that starts once that recovery begins) — otherwise a
214
+ // caller that lands between the in-flight recovery's start and its cooldown
215
+ // stamp would see neither in-flight nor cooldown and could trigger a second
216
+ // redundant restart cycle.
217
+ if (_recoverInFlight.has(dataDirKey)) return _recoverInFlight.get(dataDirKey)
218
+ const now = Date.now()
219
+ const last = _lastRecoverAt.get(dataDirKey) || 0
220
+ if (now - last < RECOVER_COOLDOWN_MS) {
221
+ // Cooldown active — do not hammer PG restart on every failing query.
222
+ return false
223
+ }
224
+
225
+ const p = (async () => {
226
+ _lastRecoverAt.set(dataDirKey, Date.now())
227
+ __mixdogMemoryLog(`[pg-adapter] ECONNREFUSED on pool query — recovering PG for dataDir=${dataDirKey}\n`)
228
+ // memory + trace schemas share one PG cluster/port; discard every cached
229
+ // pool for this dataDir so a dead-port pool is never reused after restart.
230
+ // Track every schema whose pool was actually closed here so ALL of them
231
+ // get re-ensured below (not just the schema of the caller that happened
232
+ // to trigger this recovery) — otherwise a sibling schema's cached `db`
233
+ // handle is left pointing at an ended pool (its `_pool` getter falls back
234
+ // to the stale `pgPool` closure var) and its next query throws a
235
+ // "Cannot use a pool after calling end" TypeError instead of recovering.
236
+ const closedSchemas = new Set()
237
+ for (const key of _instanceKeysForDataDir(dataDirKey)) {
238
+ const sch = key.slice(dataDirKey.length + 1)
239
+ try { await closePgInstance(dataDir, { schema: sch }) } catch {}
240
+ closedSchemas.add(sch)
241
+ }
242
+ closedSchemas.add(schema) // the triggering schema, even if it had no cached instance yet
243
+ try {
244
+ for (const sch of closedSchemas) {
245
+ await ensurePgInstance(dataDir, { schema: sch })
246
+ }
247
+ __mixdogMemoryLog(`[pg-adapter] PG reconnect recovery complete for dataDir=${dataDirKey}\n`)
248
+ return true
249
+ } catch (e) {
250
+ __mixdogMemoryLog(`[pg-adapter] PG reconnect recovery failed for dataDir=${dataDirKey}: ${e?.message || e}\n`)
251
+ return false
252
+ }
253
+ })()
254
+ _recoverInFlight.set(dataDirKey, p)
255
+ try {
256
+ return await p
257
+ } finally {
258
+ _recoverInFlight.delete(dataDirKey)
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Run `fn(pool)` against the current pool for (dataDir, schema); on
264
+ * ECONNREFUSED, recover PG (see _recoverPgConnection) and retry once against
265
+ * the refreshed pool. Non-ECONNREFUSED errors and cooldown/in-flight misses
266
+ * propagate the original error unchanged.
267
+ */
268
+ async function withPgRetry(dataDir, schema, fn) {
269
+ const key = `${resolve(dataDir)}|${schema}`
270
+ const pool0 = instances.get(key)?.pool
271
+ try {
272
+ return await fn(pool0)
273
+ } catch (err) {
274
+ if (err?.code !== 'ECONNREFUSED') throw err
275
+ const recovered = await _recoverPgConnection(dataDir, schema)
276
+ if (!recovered) throw err
277
+ const pool1 = instances.get(key)?.pool
278
+ if (!pool1) throw err
279
+ return await fn(pool1)
280
+ }
281
+ }
282
+
159
283
  // ---------------------------------------------------------------------------
160
284
  // Instance bootstrap — extensions + schemas (idempotent)
161
285
  // ---------------------------------------------------------------------------
@@ -299,7 +423,7 @@ export async function ensurePgInstance(dataDir, opts = {}) {
299
423
  await bootstrapInstance(pgPool, resolve(dataDir))
300
424
 
301
425
  // 5. Build the compat db shim.
302
- const db = makeCompatDb(pgPool, schema)
426
+ const db = makeCompatDb(pgPool, schema, dataDir)
303
427
 
304
428
  const result = { db, pool: pgPool, host, port, runtimeDir, pgdataDir }
305
429
  instances.set(key, result)
@@ -0,0 +1,50 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+
5
+ // Plugin version + promotion code fingerprint. Extracted from index.mjs
6
+ // (behavior-preserving). Callers pass PLUGIN_ROOT so this stays free of any
7
+ // entry-point path resolution.
8
+
9
+ export function readPluginVersion(pluginRoot) {
10
+ try {
11
+ return JSON.parse(fs.readFileSync(path.join(pluginRoot, 'package.json'), 'utf8')).version || '0.0.1'
12
+ } catch { return '0.0.1' }
13
+ }
14
+
15
+ const PROMOTION_FINGERPRINT_ROOTS = ['src/memory']
16
+
17
+ function collectPromotionFingerprintFiles(pluginRoot) {
18
+ const out = []
19
+ const walk = (relDir) => {
20
+ let entries = []
21
+ try { entries = fs.readdirSync(path.join(pluginRoot, relDir), { withFileTypes: true }) }
22
+ catch { return }
23
+ for (const ent of entries) {
24
+ const rel = `${relDir}/${ent.name}`.replace(/\\/g, '/')
25
+ if (ent.isDirectory()) {
26
+ walk(rel)
27
+ } else if (ent.isFile() && rel.endsWith('.mjs')) {
28
+ out.push(rel)
29
+ }
30
+ }
31
+ }
32
+ for (const root of PROMOTION_FINGERPRINT_ROOTS) walk(root)
33
+ return out.sort()
34
+ }
35
+
36
+ export function readPromotionCodeFingerprint(pluginRoot) {
37
+ const hash = crypto.createHash('sha256')
38
+ const files = collectPromotionFingerprintFiles(pluginRoot)
39
+ for (const rel of files) {
40
+ hash.update(rel)
41
+ hash.update('\0')
42
+ try {
43
+ hash.update(fs.readFileSync(path.join(pluginRoot, rel)))
44
+ } catch {
45
+ hash.update('missing')
46
+ }
47
+ hash.update('\0')
48
+ }
49
+ return `src/memory:${files.length}:${hash.digest('hex').slice(0, 16)}`
50
+ }
@@ -0,0 +1,183 @@
1
+ import { cleanMemoryText } from './memory.mjs'
2
+
3
+ // Recall query/format helpers extracted verbatim from index.mjs
4
+ // (behavior-preserving). Pure string/date logic plus row rendering.
5
+
6
+ export function parsePeriod(period, hasQuery) {
7
+ if (!period && hasQuery) period = '30d'
8
+ if (!period) return null
9
+ if (period === 'all') return null
10
+ if (period === 'last') return { mode: 'last' }
11
+ // Calendar-day windows: 'today' anchors at local midnight rather than
12
+ // rolling 24h. Without this, a query asking 'today' at 01:30 would silently
13
+ // include yesterday's last 22.5h of activity, mislabelling them as
14
+ // 'today's work'. 'yesterday' is the previous calendar day.
15
+ if (period === 'today') {
16
+ const start = new Date()
17
+ start.setHours(0, 0, 0, 0)
18
+ return { startMs: start.getTime(), endMs: Date.now() }
19
+ }
20
+ if (period === 'yesterday') {
21
+ const start = new Date()
22
+ start.setDate(start.getDate() - 1)
23
+ start.setHours(0, 0, 0, 0)
24
+ const end = new Date(start)
25
+ end.setHours(23, 59, 59, 999)
26
+ return { startMs: start.getTime(), endMs: end.getTime() }
27
+ }
28
+ if (period === 'this_week' || period === 'last_week') {
29
+ // R6 P9: calendar Mon-Sun previous/current week. Mon-start ISO
30
+ // convention. Replaces R5 rolling 7-14d range which was empty for
31
+ // sessions where "last week" decisions actually fell on Mon (4/27) of
32
+ // this week. Precise calendar bounds match natural-language intuition.
33
+ const d = new Date()
34
+ d.setHours(0, 0, 0, 0)
35
+ const dayOfWeek = d.getDay()
36
+ const daysSinceMon = (dayOfWeek + 6) % 7
37
+ const thisWeekMon = new Date(d)
38
+ thisWeekMon.setDate(d.getDate() - daysSinceMon)
39
+ if (period === 'this_week') {
40
+ return { startMs: thisWeekMon.getTime(), endMs: Date.now() }
41
+ }
42
+ const lastWeekMon = new Date(thisWeekMon)
43
+ lastWeekMon.setDate(thisWeekMon.getDate() - 7)
44
+ const lastWeekSunEnd = new Date(thisWeekMon.getTime() - 1)
45
+ return { startMs: lastWeekMon.getTime(), endMs: lastWeekSunEnd.getTime() }
46
+ }
47
+ const relMatch = period.match(/^(\d+)(m|h|d)$/)
48
+ if (relMatch) {
49
+ const n = parseInt(relMatch[1])
50
+ const unit = relMatch[2]
51
+ const now = new Date()
52
+ if (unit === 'm') {
53
+ // Minute granularity is for "resume from the previous turn / pick
54
+ // up where we left off" style recall — sub-hour windows where 1h
55
+ // is too coarse. n=0 is invalid (the regex requires \d+ which
56
+ // matches "0" but a zero-width window returns no rows; leave that
57
+ // as caller-supplied no-op).
58
+ const start = new Date(now.getTime() - n * 60_000)
59
+ return { startMs: start.getTime(), endMs: now.getTime() }
60
+ }
61
+ if (unit === 'h') {
62
+ const start = new Date(now.getTime() - n * 3600_000)
63
+ return { startMs: start.getTime(), endMs: now.getTime() }
64
+ }
65
+ const start = new Date(now)
66
+ start.setDate(start.getDate() - n)
67
+ return { startMs: start.getTime(), endMs: now.getTime() }
68
+ }
69
+ const rangeMatch = period.match(/^(\d{4}-\d{2}-\d{2})~(\d{4}-\d{2}-\d{2})$/)
70
+ if (rangeMatch) {
71
+ return {
72
+ startMs: Date.parse(rangeMatch[1] + 'T00:00:00'),
73
+ endMs: Date.parse(rangeMatch[2] + 'T23:59:59.999'),
74
+ }
75
+ }
76
+ const dateMatch = period.match(/^(\d{4}-\d{2}-\d{2})$/)
77
+ if (dateMatch) {
78
+ return {
79
+ startMs: Date.parse(dateMatch[1] + 'T00:00:00'),
80
+ endMs: Date.parse(dateMatch[1] + 'T23:59:59.999'),
81
+ exact: true,
82
+ }
83
+ }
84
+ return null
85
+ }
86
+
87
+ export function formatTs(tsMs) {
88
+ const n = Number(tsMs)
89
+ if (Number.isFinite(n) && n > 1e12) {
90
+ return new Date(n).toLocaleString('sv-SE').slice(0, 16)
91
+ }
92
+ return String(tsMs ?? '').slice(0, 16)
93
+ }
94
+
95
+ export const CORE_RECALL_STOPWORDS = new Set([
96
+ 'about', 'after', 'again', 'before', 'check', 'color', 'decision', 'decided',
97
+ 'earlier', 'memory', 'previous', 'routing', 'stored', 'tell', 'what',
98
+ ])
99
+
100
+ export function coreRecallTerms(query) {
101
+ return [...new Set(String(query || '').toLowerCase().match(/[\p{L}\p{N}_-]{4,}/gu) || [])]
102
+ .filter((term) => !CORE_RECALL_STOPWORDS.has(term))
103
+ .slice(0, 8)
104
+ }
105
+
106
+ export function normalizeRecallProjectScope(projectScope) {
107
+ const raw = String(projectScope || 'common').trim()
108
+ if (!raw || raw.toLowerCase() === 'common') return null
109
+ if (raw.toLowerCase() === 'all') return '*'
110
+ return raw
111
+ }
112
+
113
+ export function sessionRecallTerms(query) {
114
+ return [...new Set(String(query || '').toLowerCase().match(/[\p{L}\p{N}_./:-]{2,}/gu) || [])]
115
+ .filter((term) => !CORE_RECALL_STOPWORDS.has(term))
116
+ .slice(0, 12)
117
+ }
118
+
119
+ export function interleaveRawRows(hybridRows, rawRows) {
120
+ if (!Array.isArray(rawRows) || rawRows.length === 0) return hybridRows
121
+ const out = []
122
+ const stride = Math.max(1, Math.round(hybridRows.length / (rawRows.length + 1)))
123
+ let rawIdx = 0
124
+ for (let i = 0; i < hybridRows.length; i += 1) {
125
+ out.push(hybridRows[i])
126
+ if ((i + 1) % stride === 0 && rawIdx < rawRows.length) {
127
+ out.push(rawRows[rawIdx])
128
+ rawIdx += 1
129
+ }
130
+ }
131
+ while (rawIdx < rawRows.length) out.push(rawRows[rawIdx++])
132
+ return out
133
+ }
134
+
135
+ export function renderEntryLines(rows) {
136
+ if (!rows || rows.length === 0) return '(no results)'
137
+ const lines = []
138
+ // Bound total emitted lines (roots x members) so a many-member recall can't
139
+ // inject unbounded output. Per-line content is already capped at 1000 chars;
140
+ // this caps the line COUNT. Narrow the query (limit/period/projectScope) for more.
141
+ const RECALL_LINE_CAP = 200
142
+ let _capped = false
143
+ outer:
144
+ for (const r of rows) {
145
+ const hasMembers = Array.isArray(r.members) && r.members.length > 0
146
+ if (hasMembers) {
147
+ // Chunks present: emit each member as its own line. Root row is a
148
+ // grouping artifact for retrieval — the caller wants the chunk
149
+ // content (cycle1 raw), not the cycle2-compressed summary.
150
+ for (const m of r.members) {
151
+ if (lines.length >= RECALL_LINE_CAP) { _capped = true; break outer }
152
+ const mTs = formatTs(m.ts)
153
+ const role = m.role === 'user' ? 'u' : m.role === 'assistant' ? 'a' : (m.role || '?')
154
+ const content = cleanMemoryText(String(m.content ?? '')).slice(0, 1000)
155
+ lines.push(`[${mTs}] ${role}: ${content} #${m.id}`)
156
+ }
157
+ } else {
158
+ if (lines.length >= RECALL_LINE_CAP) { _capped = true; break }
159
+ // No chunks (root not yet chunked by cycle1, or orphan leaf): emit
160
+ // the row itself in the same shape. element/summary fall back to
161
+ // raw content when both are absent.
162
+ const ts = formatTs(r.ts)
163
+ const element = r.element ?? ''
164
+ const summary = r.summary ?? ''
165
+ // Standalone leaf rows (is_root=0, no parent chunks_root resolved
166
+ // into a `members` list) carry their u/a role just like inline
167
+ // chunk members — surface it so the format stays consistent across
168
+ // the two emission paths.
169
+ const rolePrefix = r.is_root === 0 && r.role
170
+ ? (r.role === 'user' ? 'u: ' : r.role === 'assistant' ? 'a: ' : `${r.role}: `)
171
+ : ''
172
+ const body = element || summary
173
+ ? `${element}${summary ? ' — ' + summary : ''}`
174
+ : cleanMemoryText(String(r.content ?? '')).slice(0, 1000)
175
+ // Unchunked raw leaf (cycle1 hasn't classified it yet): mark it so
176
+ // callers can tell fresh-but-unprocessed rows from chunked memory.
177
+ const pendingMark = (r.is_root === 0 && r.chunk_root == null) ? ' [pending]' : ''
178
+ lines.push(`[${ts}] ${rolePrefix}${body.slice(0, 1000)}${pendingMark} #${r.id}`)
179
+ }
180
+ }
181
+ if (_capped) lines.push(`[recall truncated — showing first ${RECALL_LINE_CAP} lines; narrow the query (limit/period/projectScope) for the rest]`)
182
+ return lines.join('\n')
183
+ }