mixdog 0.9.3 → 0.9.5

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 (382) hide show
  1. package/README.md +112 -38
  2. package/package.json +10 -3
  3. package/scripts/bench/lead-review-tasks-r3.json +20 -0
  4. package/scripts/bench/lead-review-tasks.json +20 -0
  5. package/scripts/bench/r4-mixed-tasks.json +20 -0
  6. package/scripts/bench/r5-orchestrated-task.json +7 -0
  7. package/scripts/bench/review-tasks.json +20 -0
  8. package/scripts/bench/round-codex.json +114 -0
  9. package/scripts/bench/round-mixdog-lead-r3.json +269 -0
  10. package/scripts/bench/round-mixdog-lead.json +269 -0
  11. package/scripts/bench/round-mixdog.json +126 -0
  12. package/scripts/bench/round-r10-bigsample.json +679 -0
  13. package/scripts/bench/round-r11-codexalign.json +257 -0
  14. package/scripts/bench/round-r13-clientmeta.json +464 -0
  15. package/scripts/bench/round-r14-betafeatures.json +466 -0
  16. package/scripts/bench/round-r15-fulldefault.json +462 -0
  17. package/scripts/bench/round-r16-sessionid.json +466 -0
  18. package/scripts/bench/round-r17-wirebytes.json +456 -0
  19. package/scripts/bench/round-r18-prewarm.json +468 -0
  20. package/scripts/bench/round-r19-clean.json +472 -0
  21. package/scripts/bench/round-r20-prewarm-clean.json +475 -0
  22. package/scripts/bench/round-r21-delta-retry.json +473 -0
  23. package/scripts/bench/round-r22-full-probe.json +693 -0
  24. package/scripts/bench/round-r23-itemprobe.json +701 -0
  25. package/scripts/bench/round-r24-shapefix.json +677 -0
  26. package/scripts/bench/round-r25-serial.json +464 -0
  27. package/scripts/bench/round-r26-parallel3.json +671 -0
  28. package/scripts/bench/round-r27-parallel10.json +894 -0
  29. package/scripts/bench/round-r28-parallel10-stagger.json +882 -0
  30. package/scripts/bench/round-r29-parallel10-stagger166.json +886 -0
  31. package/scripts/bench/round-r30-instid.json +253 -0
  32. package/scripts/bench/round-r31-upgradeprobe.json +256 -0
  33. package/scripts/bench/round-r32-vs-codex-lead.json +254 -0
  34. package/scripts/bench/round-r33-vs-codex-codex.json +115 -0
  35. package/scripts/bench/round-r34-orchestrated.json +120 -0
  36. package/scripts/bench/round-r35-orchestrated-codex.json +61 -0
  37. package/scripts/bench/round-r36-orchestrated-capped.json +128 -0
  38. package/scripts/bench/round-r4-codex.json +114 -0
  39. package/scripts/bench/round-r4-mixed.json +225 -0
  40. package/scripts/bench/round-r5-gpt-lead.json +259 -0
  41. package/scripts/bench/round-r6-codex.json +114 -0
  42. package/scripts/bench/round-r6-solo.json +257 -0
  43. package/scripts/bench/round-r7-full.json +254 -0
  44. package/scripts/bench/round-r8-fulldefault.json +255 -0
  45. package/scripts/bench-run.mjs +251 -32
  46. package/scripts/freevar-smoke.mjs +95 -0
  47. package/scripts/internal-comms-bench.mjs +3 -4
  48. package/scripts/internal-comms-smoke.mjs +10 -9
  49. package/scripts/model-catalog-audit.mjs +209 -0
  50. package/scripts/model-list-sanitize-test.mjs +37 -0
  51. package/scripts/mouse-probe.mjs +45 -0
  52. package/scripts/output-style-bench.mjs +13 -6
  53. package/scripts/output-style-smoke.mjs +4 -4
  54. package/scripts/provider-toolcall-test.mjs +7 -3
  55. package/scripts/recall-bench.mjs +76 -13
  56. package/scripts/recall-quality-cases.json +12 -0
  57. package/scripts/recall-usecase-cases.json +18 -0
  58. package/scripts/session-bench.mjs +152 -6
  59. package/scripts/tool-smoke.mjs +25 -65
  60. package/scripts/tui-render-smoke.mjs +90 -0
  61. package/scripts/webhook-smoke.mjs +208 -0
  62. package/src/agents/debugger/AGENT.md +4 -1
  63. package/src/agents/heavy-worker/AGENT.md +9 -8
  64. package/src/agents/maintainer/AGENT.md +4 -0
  65. package/src/agents/reviewer/AGENT.md +2 -1
  66. package/src/agents/scheduler-task/AGENT.md +2 -3
  67. package/src/agents/webhook-handler/AGENT.md +2 -3
  68. package/src/agents/worker/AGENT.md +10 -7
  69. package/src/app.mjs +12 -1
  70. package/src/headless-role.mjs +7 -1
  71. package/src/lib/rules-builder.cjs +4 -0
  72. package/src/mixdog-session-runtime.mjs +647 -2056
  73. package/src/output-styles/default.md +30 -9
  74. package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
  75. package/src/output-styles/minimal.md +8 -6
  76. package/src/output-styles/simple.md +21 -7
  77. package/src/rules/agent/00-common.md +6 -3
  78. package/src/rules/agent/30-explorer.md +16 -5
  79. package/src/rules/lead/01-general.md +5 -5
  80. package/src/rules/lead/lead-brief.md +15 -0
  81. package/src/rules/lead/lead-tool.md +6 -15
  82. package/src/rules/shared/01-tool.md +17 -21
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +8 -3
  84. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
  85. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
  86. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
  87. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
  88. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
  89. package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
  90. package/src/runtime/agent/orchestrator/config.mjs +69 -2
  91. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +62 -20
  92. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
  93. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
  94. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +81 -1281
  95. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +32 -3
  97. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
  100. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
  101. package/src/runtime/agent/orchestrator/providers/gemini.mjs +43 -1013
  102. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +17 -3
  103. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +105 -11
  104. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +356 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
  107. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
  108. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
  109. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +40 -1143
  110. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +740 -0
  111. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
  112. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +349 -2131
  113. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +143 -1002
  114. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +229 -0
  115. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
  116. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +465 -0
  117. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
  118. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
  119. package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +80 -0
  120. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
  121. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
  122. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
  123. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
  124. package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
  125. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
  126. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
  127. package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
  128. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +14 -2
  129. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
  130. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +275 -0
  132. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
  133. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
  134. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
  135. package/src/runtime/agent/orchestrator/session/loop.mjs +278 -402
  136. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
  137. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +7 -4
  138. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +12 -0
  139. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
  140. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
  141. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
  142. package/src/runtime/agent/orchestrator/session/manager.mjs +166 -1087
  143. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
  144. package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
  145. package/src/runtime/agent/orchestrator/stall-policy.mjs +20 -1
  146. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
  147. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
  148. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +35 -44
  149. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
  150. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
  151. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
  152. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
  153. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -92
  154. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
  155. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
  156. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +28 -265
  157. package/src/runtime/agent/orchestrator/tools/builtin.mjs +0 -6
  158. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +57 -3
  159. package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
  160. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +10 -122
  161. package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
  162. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
  163. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
  164. package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
  165. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
  166. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
  167. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
  168. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
  169. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
  170. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
  171. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
  172. package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
  173. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +0 -21
  174. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -72
  175. package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
  176. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
  177. package/src/runtime/channels/backends/discord-access.mjs +32 -0
  178. package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
  179. package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
  180. package/src/runtime/channels/backends/discord.mjs +27 -318
  181. package/src/runtime/channels/backends/telegram.mjs +8 -12
  182. package/src/runtime/channels/index.mjs +247 -701
  183. package/src/runtime/channels/lib/backend-dispatch.mjs +46 -0
  184. package/src/runtime/channels/lib/config.mjs +37 -149
  185. package/src/runtime/channels/lib/event-pipeline.mjs +22 -5
  186. package/src/runtime/channels/lib/event-queue.mjs +78 -13
  187. package/src/runtime/channels/lib/inbound-routing.mjs +74 -0
  188. package/src/runtime/channels/lib/interaction-workflows.mjs +5 -113
  189. package/src/runtime/channels/lib/output-forwarder.mjs +1 -1
  190. package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
  191. package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
  192. package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
  193. package/src/runtime/channels/lib/scheduler.mjs +27 -113
  194. package/src/runtime/channels/lib/session-discovery.mjs +56 -4
  195. package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
  196. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  197. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
  198. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
  199. package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
  200. package/src/runtime/channels/lib/webhook/deliveries.mjs +313 -0
  201. package/src/runtime/channels/lib/webhook/log.mjs +42 -0
  202. package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
  203. package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
  204. package/src/runtime/channels/lib/webhook.mjs +43 -616
  205. package/src/runtime/channels/tool-defs.mjs +11 -130
  206. package/src/runtime/memory/index.mjs +210 -1948
  207. package/src/runtime/memory/lib/core-memory-store.mjs +5 -1
  208. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
  209. package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
  210. package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
  211. package/src/runtime/memory/lib/ko-morph.mjs +195 -0
  212. package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
  213. package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
  214. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
  215. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
  216. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
  217. package/src/runtime/memory/lib/memory-cycle2.mjs +24 -842
  218. package/src/runtime/memory/lib/memory-embed.mjs +149 -0
  219. package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
  220. package/src/runtime/memory/lib/memory-recall-store.mjs +69 -12
  221. package/src/runtime/memory/lib/memory-text-utils.mjs +46 -0
  222. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
  223. package/src/runtime/memory/lib/query-handlers.mjs +802 -0
  224. package/src/runtime/memory/lib/recall-format.mjs +55 -0
  225. package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
  226. package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
  227. package/src/runtime/memory/tool-defs.mjs +5 -13
  228. package/src/runtime/search/lib/http-fetch.mjs +274 -0
  229. package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
  230. package/src/runtime/search/lib/web-tools.mjs +24 -602
  231. package/src/runtime/shared/atomic-file.mjs +26 -1
  232. package/src/runtime/shared/config.mjs +14 -4
  233. package/src/runtime/shared/launcher-control.mjs +2 -2
  234. package/src/runtime/shared/markdown-frontmatter.mjs +19 -0
  235. package/src/runtime/shared/schedules-store.mjs +13 -3
  236. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  237. package/src/runtime/shared/tool-primitives.mjs +308 -0
  238. package/src/runtime/shared/tool-result-summary.mjs +515 -0
  239. package/src/runtime/shared/tool-surface.mjs +80 -898
  240. package/src/runtime/shared/transcript-writer.mjs +23 -0
  241. package/src/runtime/shared/update-checker.mjs +7 -4
  242. package/src/session-runtime/config-helpers.mjs +119 -2
  243. package/src/session-runtime/config-lifecycle.mjs +232 -0
  244. package/src/session-runtime/cwd-plugins.mjs +226 -0
  245. package/src/session-runtime/mcp-glue.mjs +177 -0
  246. package/src/session-runtime/model-recency.mjs +111 -0
  247. package/src/session-runtime/native-search.mjs +247 -0
  248. package/src/session-runtime/output-styles.mjs +11 -9
  249. package/src/session-runtime/prewarm.mjs +142 -0
  250. package/src/session-runtime/provider-models.mjs +278 -0
  251. package/src/session-runtime/provider-usage.mjs +120 -0
  252. package/src/session-runtime/quick-model-rows.mjs +205 -0
  253. package/src/session-runtime/quick-search-models.mjs +47 -0
  254. package/src/session-runtime/session-hooks.mjs +93 -0
  255. package/src/session-runtime/settings-api.mjs +352 -0
  256. package/src/session-runtime/tool-catalog.mjs +29 -29
  257. package/src/session-runtime/tool-defs.mjs +84 -0
  258. package/src/session-runtime/warmup-schedulers.mjs +201 -0
  259. package/src/session-runtime/workflow.mjs +1 -1
  260. package/src/standalone/agent-tool/helpers.mjs +237 -0
  261. package/src/standalone/agent-tool/notify.mjs +107 -0
  262. package/src/standalone/agent-tool/provider-init.mjs +143 -0
  263. package/src/standalone/agent-tool/render.mjs +152 -0
  264. package/src/standalone/agent-tool/tool-def.mjs +55 -0
  265. package/src/standalone/agent-tool.mjs +138 -669
  266. package/src/standalone/channel-admin.mjs +102 -90
  267. package/src/standalone/channel-worker.mjs +4 -7
  268. package/src/standalone/explore-tool.mjs +64 -14
  269. package/src/standalone/hook-bus/config.mjs +207 -0
  270. package/src/standalone/hook-bus/constants.mjs +90 -0
  271. package/src/standalone/hook-bus/handlers.mjs +481 -0
  272. package/src/standalone/hook-bus/payload.mjs +31 -0
  273. package/src/standalone/hook-bus/rules.mjs +77 -0
  274. package/src/standalone/hook-bus.mjs +77 -870
  275. package/src/standalone/memory-runtime-proxy.mjs +7 -0
  276. package/src/standalone/opencode-go-login.mjs +5 -1
  277. package/src/standalone/provider-admin.mjs +1 -16
  278. package/src/standalone/usage-dashboard.mjs +3 -1
  279. package/src/tui/App.jsx +1059 -8110
  280. package/src/tui/app/app-format.mjs +213 -0
  281. package/src/tui/app/channel-pickers.mjs +508 -0
  282. package/src/tui/app/clipboard.mjs +67 -0
  283. package/src/tui/app/core-memory-picker.mjs +210 -0
  284. package/src/tui/app/extension-pickers.mjs +506 -0
  285. package/src/tui/app/input-parsers.mjs +193 -0
  286. package/src/tui/app/maintenance-pickers.mjs +356 -0
  287. package/src/tui/app/model-options.mjs +334 -0
  288. package/src/tui/app/model-picker.mjs +365 -0
  289. package/src/tui/app/onboarding-steps.mjs +400 -0
  290. package/src/tui/app/project-picker.mjs +247 -0
  291. package/src/tui/app/provider-setup-picker.mjs +580 -0
  292. package/src/tui/app/resume-picker.mjs +55 -0
  293. package/src/tui/app/route-pickers.mjs +419 -0
  294. package/src/tui/app/settings-picker.mjs +489 -0
  295. package/src/tui/app/slash-commands.mjs +101 -0
  296. package/src/tui/app/slash-dispatch.mjs +427 -0
  297. package/src/tui/app/text-layout.mjs +46 -0
  298. package/src/tui/app/theme-effort-pickers.mjs +154 -0
  299. package/src/tui/app/transcript-window.mjs +677 -0
  300. package/src/tui/app/use-mouse-input.mjs +460 -0
  301. package/src/tui/app/use-prompt-handlers.mjs +310 -0
  302. package/src/tui/app/use-transcript-scroll.mjs +512 -0
  303. package/src/tui/app/use-transcript-window.mjs +607 -0
  304. package/src/tui/components/ConfirmBar.jsx +10 -7
  305. package/src/tui/components/Picker.jsx +64 -15
  306. package/src/tui/components/PromptInput.jsx +33 -102
  307. package/src/tui/components/SlashCommandPalette.jsx +8 -1
  308. package/src/tui/components/StatusLine.jsx +69 -15
  309. package/src/tui/components/TextEntryPanel.jsx +11 -0
  310. package/src/tui/components/ToolExecution.jsx +52 -594
  311. package/src/tui/components/TranscriptItem.jsx +105 -0
  312. package/src/tui/components/UsagePanel.jsx +18 -4
  313. package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
  314. package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
  315. package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
  316. package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
  317. package/src/tui/components/tool-execution/text-format.mjs +161 -0
  318. package/src/tui/display-width.mjs +20 -3
  319. package/src/tui/dist/index.mjs +13553 -12384
  320. package/src/tui/engine/agent-job-feed.mjs +133 -0
  321. package/src/tui/engine/notification-plan.mjs +76 -0
  322. package/src/tui/engine/render-timing.mjs +17 -0
  323. package/src/tui/engine/tool-approval.mjs +94 -0
  324. package/src/tui/engine/tool-card-results.mjs +234 -0
  325. package/src/tui/engine/tool-result-status.mjs +135 -0
  326. package/src/tui/engine.mjs +170 -574
  327. package/src/tui/figures.mjs +5 -0
  328. package/src/tui/index.jsx +65 -1
  329. package/src/tui/input-editing.mjs +2 -2
  330. package/src/tui/markdown/format-token.mjs +4 -1
  331. package/src/tui/statusline-ansi-bridge.mjs +11 -3
  332. package/src/tui/theme.mjs +6 -0
  333. package/src/ui/statusline-agents.mjs +213 -0
  334. package/src/ui/statusline-format.mjs +146 -0
  335. package/src/ui/statusline-segments.mjs +148 -0
  336. package/src/ui/statusline.mjs +77 -501
  337. package/src/ui/tool-card.mjs +0 -1
  338. package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
  339. package/src/workflows/default/WORKFLOW.md +16 -18
  340. package/src/workflows/sequential/WORKFLOW.md +16 -18
  341. package/vendor/ink/build/display-width.js +19 -3
  342. package/vendor/ink/build/ink.js +112 -7
  343. package/vendor/ink/build/log-update.js +17 -3
  344. package/vendor/ink/build/wrap-text.js +125 -0
  345. package/scripts/_test-folder-dialog.mjs +0 -30
  346. package/scripts/fix-brief-fn.mjs +0 -35
  347. package/scripts/fix-format-tool-surface.mjs +0 -24
  348. package/scripts/fix-tool-exec-visible.mjs +0 -42
  349. package/scripts/patch-agent-brief.mjs +0 -48
  350. package/scripts/patch-app.mjs +0 -21
  351. package/scripts/patch-app2.mjs +0 -18
  352. package/scripts/patch-dist-brief.mjs +0 -96
  353. package/scripts/patch-tool-exec.mjs +0 -70
  354. package/src/examples/schedules/SCHEDULE.example.md +0 -32
  355. package/src/examples/webhooks/WEBHOOK.example.md +0 -40
  356. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
  357. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
  358. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
  359. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +0 -162
  360. package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
  361. package/src/runtime/channels/lib/holidays.mjs +0 -138
  362. package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
  363. package/src/runtime/shared/task-notification-envelope.test.mjs +0 -107
  364. package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
  365. package/src/standalone/agent-task-status.test.mjs +0 -76
  366. package/src/tui/components/tool-output-format.test.mjs +0 -399
  367. package/src/tui/display-width.test.mjs +0 -35
  368. package/src/tui/engine-runtime-notification.test.mjs +0 -115
  369. package/src/tui/engine-tool-result-text.test.mjs +0 -75
  370. package/src/tui/input-editing.selection.test.mjs +0 -75
  371. package/src/tui/markdown/format-token.test.mjs +0 -354
  372. package/src/tui/markdown/render-ansi.test.mjs +0 -108
  373. package/src/tui/markdown/stream-fence.test.mjs +0 -26
  374. package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
  375. package/src/tui/paste-fix.test.mjs +0 -119
  376. package/src/tui/prompt-history-store.test.mjs +0 -52
  377. package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
  378. package/src/tui/transcript-tool-failures.test.mjs +0 -111
  379. package/src/ui/markdown.test.mjs +0 -70
  380. package/src/ui/statusline-context-label.test.mjs +0 -15
  381. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
  382. package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
@@ -0,0 +1,802 @@
1
+ // Recall / search / dump / stats query handlers extracted from index.mjs.
2
+ //
3
+ // These six functions form the read-side query cluster: they share the same
4
+ // live DB handle plus a small set of recall-format / retriever helpers, and a
5
+ // few facade-local pieces of state (the cold-recall log throttle and the
6
+ // optional trace DB used for recall
7
+ // telemetry). All of that is injected through createQueryHandlers({...}) so the
8
+ // module holds no state of its own and the facade keeps ownership of `db`,
9
+ // `_traceDb`, etc. (getBootTimestamp stays injected for facade compatibility
10
+ // but period='last' no longer reads it — 'last' is an unbounded newest-first
11
+ // browse.)
12
+
13
+ import {
14
+ parsePeriod,
15
+ coreRecallTerms,
16
+ normalizeRecallProjectScope,
17
+ sessionRecallTerms,
18
+ interleaveRawRows,
19
+ renderEntryLines,
20
+ renderSessionGroupedLines,
21
+ } from './recall-format.mjs'
22
+ import { searchRelevantHybrid } from './memory-recall-store.mjs'
23
+ import { fetchEntriesByIdsScoped } from './memory-recall-id-patch.mjs'
24
+ import { retrieveEntries } from './memory-retrievers.mjs'
25
+ import { cleanMemoryText } from './memory.mjs'
26
+ import { insertTraceEvents } from './trace-store.mjs'
27
+ import {
28
+ embedText,
29
+ embedTexts,
30
+ isEmbeddingModelReady,
31
+ warmupEmbeddingProvider,
32
+ } from './embedding-provider.mjs'
33
+
34
+ export function createQueryHandlers({
35
+ getDb,
36
+ log,
37
+ resolveProjectScope,
38
+ embeddingWarmupCanStart,
39
+ getBootTimestamp,
40
+ getTraceDb,
41
+ }) {
42
+ // Facade-owned cold-recall log throttle. Kept module-local to this factory
43
+ // instance (one memory runtime = one factory) so the 10s de-dup window
44
+ // behaves exactly as it did when it was a top-level `let` in index.mjs.
45
+ let _embeddingColdRecallLogAt = 0
46
+
47
+ // Raw-row priority lookup for narrow-window queries. Raw rows (is_root=0,
48
+ // chunk_root IS NULL) are inserted immediately by ingestTranscriptFile before
49
+ // cycle1 runs, so they always carry the freshest turns in the DB.
50
+ async function readRawRowsInWindow(db, tsFromMs, tsToMs, hardLimit = 10, { projectScope, sessionId, terms } = {}) {
51
+ try {
52
+ // Composable WHERE assembly (mirrors retrieveEntries' filter semantics so
53
+ // raw and chunked legs stay in filter parity: projectScope AND sessionId
54
+ // apply identically to both pools).
55
+ const where = ['chunk_root IS NULL', 'is_root = 0', 'ts >= $1', 'ts <= $2']
56
+ const params = [tsFromMs ?? 0, tsToMs ?? Date.now()]
57
+ if (projectScope === 'common') {
58
+ where.push('project_id IS NULL')
59
+ } else if (projectScope && projectScope !== 'all') {
60
+ params.push(projectScope)
61
+ where.push(`(project_id IS NULL OR project_id = $${params.length})`)
62
+ }
63
+ const sid = String(sessionId || '').trim()
64
+ if (sid) {
65
+ params.push(sid)
66
+ where.push(`session_id = $${params.length}`)
67
+ }
68
+ if (Array.isArray(terms) && terms.length > 0) {
69
+ const textExpr = `lower(coalesce(content, '') || ' ' || coalesce(element, '') || ' ' || coalesce(summary, ''))`
70
+ const clauses = terms.map((term) => {
71
+ params.push(`%${term}%`)
72
+ return `(CASE WHEN ${textExpr} LIKE $${params.length} THEN 1 ELSE 0 END)`
73
+ })
74
+ // Multi-token queries: from 3+ terms require at least 2 matching terms
75
+ // so one common token ("대화", "recall") can't drag unrelated raw rows
76
+ // into the page. 1-2 term queries keep single-hit contains semantics —
77
+ // short Korean queries are often exactly two meaningful tokens and a
78
+ // 2-of-2 requirement silently emptied the raw leg for them.
79
+ const minHits = terms.length >= 3 ? 2 : 1
80
+ where.push(`(${clauses.join(' + ')}) >= ${minHits}`)
81
+ }
82
+ params.push(hardLimit)
83
+ const sql = `SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
84
+ element, category, summary, status, score, last_seen_at, project_id
85
+ FROM entries
86
+ WHERE ${where.join(' AND ')}
87
+ ORDER BY ts DESC
88
+ LIMIT $${params.length}`
89
+ const rows = (await db.query(sql, params)).rows
90
+ return rows.map(r => ({ ...r, retrievalScore: 0, rrf: 0 }))
91
+ } catch { return [] }
92
+ }
93
+
94
+ async function recallSessionRows(args = {}) {
95
+ const db = getDb()
96
+ const sessionId = String(args.sessionId || args.session_id || '').trim()
97
+ if (!sessionId) return { text: '(no current session)' }
98
+ const limit = Math.max(1, Math.min(100, Number(args.limit) || 20))
99
+ const terms = sessionRecallTerms(args.query)
100
+ const params = [sessionId]
101
+ // Roots + not-yet-chunked leaves only. Once cycle1 turns raw leaves into
102
+ // (root, members) pairs, selecting every row unfiltered emitted the root's
103
+ // summary AND its own member rows in the same browse — duplicate content.
104
+ // A committed member (is_root=0 with a chunk_root) is always reachable via
105
+ // its root's `members` expansion below, so it never needs to be selected
106
+ // directly here.
107
+ const where = ['session_id = $1', '(is_root = 1 OR chunk_root IS NULL OR chunk_root = id)']
108
+ // Current-turn cutoff: the newest unchunked row is very often the calling
109
+ // turn's OWN recall request/tool-args, still being written when this query
110
+ // runs. Exclude it from a bare (no-query) browse so the in-flight turn
111
+ // doesn't self-echo; a query browse (explicit search intent) keeps it.
112
+ // Only treat the newest unchunked turn as "in-flight" when its latest row
113
+ // is fresh (within FRESHNESS_MS of now) — an older newest-unchunked-turn
114
+ // is completed history (cycle1 just hasn't gotten to it, or drain timed
115
+ // out) and must stay visible, not be silently hidden every browse.
116
+ const IN_FLIGHT_TURN_FRESHNESS_MS = 5 * 60 * 1000
117
+ let excludeSourceTurnId = null
118
+ if (terms.length === 0) {
119
+ try {
120
+ const r = await db.query(
121
+ `SELECT source_turn t, MAX(ts) last_ts FROM entries
122
+ WHERE session_id = $1 AND chunk_root IS NULL
123
+ GROUP BY source_turn ORDER BY source_turn DESC LIMIT 1`,
124
+ [sessionId],
125
+ )
126
+ const t = r.rows?.[0]?.t
127
+ const lastTs = Number(r.rows?.[0]?.last_ts)
128
+ if (t != null && Number.isFinite(lastTs) && (Date.now() - lastTs) <= IN_FLIGHT_TURN_FRESHNESS_MS) {
129
+ excludeSourceTurnId = Number(t)
130
+ }
131
+ } catch {}
132
+ }
133
+ if (Number.isFinite(excludeSourceTurnId)) {
134
+ params.push(excludeSourceTurnId)
135
+ where.push(`NOT (chunk_root IS NULL AND source_turn = $${params.length})`)
136
+ }
137
+ if (terms.length > 0) {
138
+ const textExpr = `lower(coalesce(content, '') || ' ' || coalesce(element, '') || ' ' || coalesce(summary, ''))`
139
+ const clauses = terms.map((term) => {
140
+ params.push(`%${term}%`)
141
+ return `${textExpr} LIKE $${params.length}`
142
+ })
143
+ where.push(`(${clauses.join(' OR ')})`)
144
+ }
145
+ params.push(limit)
146
+ let rows = (await db.query(`
147
+ SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
148
+ element, category, summary, status, score, last_seen_at, project_id
149
+ FROM entries
150
+ WHERE ${where.join(' AND ')}
151
+ ORDER BY ts DESC, id DESC
152
+ LIMIT $${params.length}
153
+ `, params)).rows
154
+ if (rows.length < limit) {
155
+ const seen = new Set(rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id)))
156
+ const fillLimit = Math.max(0, limit - rows.length)
157
+ const fillWhere = ['session_id = $1', 'id <> ALL($2::bigint[])', '(is_root = 1 OR chunk_root IS NULL OR chunk_root = id)']
158
+ const fillParams = [sessionId, [...seen]]
159
+ if (Number.isFinite(excludeSourceTurnId)) {
160
+ fillParams.push(excludeSourceTurnId)
161
+ fillWhere.push(`NOT (chunk_root IS NULL AND source_turn = $${fillParams.length})`)
162
+ }
163
+ fillParams.push(fillLimit)
164
+ const fillRows = fillLimit > 0
165
+ ? (await db.query(`
166
+ SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
167
+ element, category, summary, status, score, last_seen_at, project_id
168
+ FROM entries
169
+ WHERE ${fillWhere.join(' AND ')}
170
+ ORDER BY ts DESC, id DESC
171
+ LIMIT $${fillParams.length}
172
+ `, fillParams)).rows
173
+ : []
174
+ if (fillRows.length > 0) rows = [...rows, ...fillRows]
175
+ }
176
+ if (args.includeMembers === true) {
177
+ const rootIds = rows
178
+ .filter((row) => Number(row.is_root) === 1)
179
+ .map((row) => Number(row.id))
180
+ .filter((id) => Number.isFinite(id))
181
+ if (rootIds.length > 0) {
182
+ const members = (await db.query(`
183
+ SELECT id, ts, role, content, session_id, source_turn, project_id, chunk_root
184
+ FROM entries
185
+ WHERE chunk_root = ANY($1::bigint[]) AND is_root = 0
186
+ ORDER BY chunk_root ASC, COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
187
+ `, [rootIds])).rows
188
+ const byRoot = new Map(rootIds.map((id) => [id, []]))
189
+ for (const member of members) {
190
+ const root = Number(member.chunk_root)
191
+ if (byRoot.has(root)) byRoot.get(root).push(member)
192
+ }
193
+ for (const row of rows) {
194
+ const id = Number(row.id)
195
+ if (byRoot.has(id)) row.members = byRoot.get(id)
196
+ }
197
+ }
198
+ }
199
+ return { text: renderEntryLines(rows) }
200
+ }
201
+
202
+ async function recallCoreRows(query, { projectScope, category, limit } = {}) {
203
+ const db = getDb()
204
+ const terms = coreRecallTerms(query)
205
+ if (terms.length === 0) return []
206
+
207
+ const params = []
208
+ const where = []
209
+ const scope = normalizeRecallProjectScope(projectScope)
210
+ if (scope === null) {
211
+ where.push('project_id IS NULL')
212
+ } else if (scope !== '*') {
213
+ params.push(scope)
214
+ where.push(`(project_id IS NULL OR project_id = $${params.length})`)
215
+ }
216
+ if (category != null) {
217
+ const cats = (Array.isArray(category) ? category : [category])
218
+ .map((value) => String(value || '').trim().toLowerCase())
219
+ .filter(Boolean)
220
+ if (cats.length > 0) {
221
+ const placeholders = cats.map((cat) => {
222
+ params.push(cat)
223
+ return `$${params.length}`
224
+ })
225
+ where.push(`category IN (${placeholders.join(', ')})`)
226
+ }
227
+ }
228
+
229
+ const textExpr = `lower(coalesce(element, '') || ' ' || coalesce(summary, ''))`
230
+ const termClauses = terms.map((term) => {
231
+ params.push(`%${term}%`)
232
+ return `${textExpr} LIKE $${params.length}`
233
+ })
234
+ where.push(`(${termClauses.join(' OR ')})`)
235
+ const hitExpr = termClauses.map((clause) => `CASE WHEN ${clause} THEN 1 ELSE 0 END`).join(' + ')
236
+ const rowLimit = Math.max(1, Math.min(10, Number(limit) || 5))
237
+ params.push(rowLimit)
238
+
239
+ const rows = (await db.query(`
240
+ SELECT id, element, summary, category, project_id, created_at, updated_at,
241
+ (${hitExpr}) AS hit_count
242
+ FROM core_entries
243
+ WHERE ${where.join(' AND ')}
244
+ ORDER BY hit_count DESC, updated_at DESC, id ASC
245
+ LIMIT $${params.length}
246
+ `, params)).rows
247
+
248
+ return rows.map((row) => ({
249
+ ...row,
250
+ id: `core:${row.id}`,
251
+ ts: row.updated_at || row.created_at || Date.now(),
252
+ is_root: 1,
253
+ }))
254
+ }
255
+
256
+ async function handleSearch(args, signal) {
257
+ const db = getDb()
258
+ const _traceDb = getTraceDb()
259
+ // Cooperative abort check: throw early if the caller already aborted
260
+ // (IPC cancel handler signals the AbortController before re-entry).
261
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
262
+ // No pre-search drain: recall NEVER runs LLM chunking inline. Unchunked
263
+ // rows are served directly by the raw leg (readRawRowsInWindow on the
264
+ // query path, the chunk_root IS NULL selection in recallSessionRows) and
265
+ // are dense-searchable via the always-on raw-embedding flush (post-ingest
266
+ // + checkCycles tick). Chunked/scored upgrades arrive from the background
267
+ // cycle1 sweep on its own schedule.
268
+ // #id lookup normalization: search_memories and memory action:'search'
269
+ // callers pass a single `id` (or an id array under that same key), not
270
+ // the `ids` array below. Normalize once here so every dispatch path gets
271
+ // exact-id lookup, not just callers who already knew to use `ids`.
272
+ if (!Array.isArray(args.ids) && args.id != null) {
273
+ args = { ...args, ids: Array.isArray(args.id) ? args.id : [args.id] }
274
+ }
275
+ if (args?.currentSession === true || args?.sessionId || args?.session_id) {
276
+ return await recallSessionRows(args)
277
+ }
278
+ // id mode (follow-up lookup): caller passed `#N` markers from a prior
279
+ // recall result. Fetch those rows directly + their chunk members,
280
+ // bypassing hybrid search entirely. Output reuses renderEntryLines so
281
+ // the shape stays identical to the search path (chunk members first,
282
+ // root summary fallback).
283
+ if (Array.isArray(args.ids) && args.ids.length > 0) {
284
+ const ids = args.ids
285
+ .map(v => Number(v))
286
+ .filter(v => Number.isInteger(v) && v > 0)
287
+ if (ids.length === 0) return { text: '(no valid ids)' }
288
+ const includeArchived = args.includeArchived !== false
289
+ const category = args.category
290
+ const period = String(args.period ?? '').trim() || undefined
291
+ const temporal = parsePeriod(period, false)
292
+ let projectScope
293
+ if (typeof args.projectScope === 'string' && args.projectScope) {
294
+ projectScope = args.projectScope
295
+ } else {
296
+ const projectId = resolveProjectScope(typeof args.cwd === 'string' && args.cwd ? args.cwd : null)
297
+ projectScope = projectId !== null ? projectId : 'common'
298
+ }
299
+ const excludeStatuses = includeArchived ? [] : ['archived']
300
+ const rows = await fetchEntriesByIdsScoped(db, ids, {
301
+ ts_from: temporal?.startMs,
302
+ ts_to: temporal?.endMs,
303
+ excludeStatuses,
304
+ category,
305
+ projectScope,
306
+ })
307
+ if (rows.length === 0) return { text: '(no results)' }
308
+ // Members for any root rows in the result set.
309
+ const rootIds = rows.filter(r => r.is_root === 1).map(r => Number(r.id))
310
+ const memberLeafIds = new Set()
311
+ if (rootIds.length > 0) {
312
+ const { rows: memberRows } = await db.query(
313
+ `SELECT id, ts, role, content, chunk_root
314
+ FROM entries WHERE chunk_root = ANY($1::bigint[]) AND is_root = 0
315
+ ORDER BY ts ASC, id ASC`,
316
+ [rootIds],
317
+ )
318
+ const membersByRoot = new Map()
319
+ for (const m of memberRows) {
320
+ const k = Number(m.chunk_root)
321
+ if (!membersByRoot.has(k)) membersByRoot.set(k, [])
322
+ membersByRoot.get(k).push(m)
323
+ memberLeafIds.add(Number(m.id))
324
+ }
325
+ for (const r of rows) {
326
+ if (r.is_root === 1) r.members = membersByRoot.get(Number(r.id)) ?? []
327
+ }
328
+ }
329
+ // Preserve caller-supplied id order; drop leaves already inlined as a
330
+ // root's chunk member to prevent double emission when the caller names
331
+ // a root and one of its leaves in the same batch.
332
+ const byId = new Map(rows.map(r => [Number(r.id), r]))
333
+ const ordered = ids
334
+ .map(id => byId.get(id))
335
+ .filter(Boolean)
336
+ .filter(r => !(r.is_root === 0 && memberLeafIds.has(Number(r.id))))
337
+ return { text: renderEntryLines(ordered) }
338
+ }
339
+ // Array query — fan out in parallel, each query runs its own hybrid search
340
+ // path, and results are grouped in the response so the caller sees one
341
+ // ranked list per angle. Collapses what would otherwise be N sequential
342
+ // tool calls into a single invocation.
343
+ if (Array.isArray(args.query)) {
344
+ // Dedup + fan-out cap. The cap protects the result envelope from
345
+ // over-eager callers (20+ near-duplicate queries N× the IO) without
346
+ // silently swallowing the caller's intent: when the input exceeds
347
+ // QUERIES_CAP, prepend a one-line note so the caller can see the
348
+ // truncation and re-shape their query list.
349
+ const QUERIES_CAP = 5
350
+ const dedup = [...new Set(args.query.map(q => String(q || '').trim()).filter(Boolean))]
351
+ if (dedup.length === 0) return { text: '' }
352
+ const queries = dedup.slice(0, QUERIES_CAP)
353
+ const dropped = dedup.length - queries.length
354
+ const rest = { ...args }
355
+ delete rest.query
356
+ const deadlineSec = Math.max(1, Number(process.env.MEMORY_FANOUT_DEADLINE_S) || 180)
357
+ const deadlineMs = deadlineSec * 1000
358
+ const fanOutAbort = new AbortController()
359
+ let deadlineTimer
360
+ const deadlineRace = new Promise((_res, rej) => {
361
+ deadlineTimer = setTimeout(() => {
362
+ fanOutAbort.abort(new Error(`memory fan-out deadline exceeded (${deadlineSec}s)`))
363
+ rej(Object.assign(new Error(`memory fan-out deadline exceeded (${deadlineSec}s)`), { _deadline: true }))
364
+ }, deadlineMs)
365
+ })
366
+ let settled
367
+ try {
368
+ // Pre-warm only when the embedding model is already resident. If the
369
+ // process is still cold, keep recall responsive and let the background
370
+ // warmup finish independently instead of making the first query pay the
371
+ // ONNX session-create cost.
372
+ if (isEmbeddingModelReady()) {
373
+ // Race against the same deadline as the fan-out itself: a stuck
374
+ // embedding worker would previously park here indefinitely because
375
+ // the timer hadn't been started yet from the fan-out's perspective.
376
+ await Promise.race([embedTexts(queries), deadlineRace])
377
+ } else if (embeddingWarmupCanStart()) {
378
+ void warmupEmbeddingProvider().catch((err) => {
379
+ log(`[memory-service] embedding warmup after cold fan-out skipped dense search: ${err?.message || err}\n`)
380
+ })
381
+ }
382
+ settled = await Promise.race([
383
+ Promise.all(queries.map(async (q) => {
384
+ if (fanOutAbort.signal.aborted) throw fanOutAbort.signal.reason
385
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
386
+ const sub = await handleSearch({ ...rest, query: q }, signal)
387
+ return `[${q}]\n${sub.text || '(no results)'}`
388
+ })),
389
+ deadlineRace,
390
+ ])
391
+ } finally {
392
+ clearTimeout(deadlineTimer)
393
+ }
394
+ const parts = settled
395
+ const header = dropped > 0
396
+ ? `note: ${dedup.length} queries received, ${queries.length} processed, ${dropped} dropped (cap ${QUERIES_CAP})\n\n`
397
+ : ''
398
+ return { text: header + parts.join('\n\n') }
399
+ }
400
+ const query = String(args.query ?? '').trim()
401
+ let period = String(args.period ?? '').trim() || undefined
402
+ // Period and sort are caller-supplied only. Lead is responsible for
403
+ // mapping vague time phrases / chronological intent into the period
404
+ // argument before calling; the engine does not infer them from query
405
+ // text.
406
+ const RECALL_LIMIT_CAP = 100
407
+ const RECALL_OFFSET_CAP = 500
408
+ const requestedLimit = Number(args.limit)
409
+ const requestedOffset = Number(args.offset)
410
+ let limit = Math.max(1, Number.isFinite(requestedLimit) ? requestedLimit : 10)
411
+ let offset = Math.max(0, Number.isFinite(requestedOffset) ? requestedOffset : 0)
412
+ const recallCapNotes = []
413
+ if (Number.isFinite(requestedLimit) && requestedLimit > RECALL_LIMIT_CAP) {
414
+ limit = RECALL_LIMIT_CAP
415
+ recallCapNotes.push(`limit capped to ${RECALL_LIMIT_CAP} (requested ${requestedLimit})`)
416
+ } else {
417
+ limit = Math.min(RECALL_LIMIT_CAP, limit)
418
+ }
419
+ if (Number.isFinite(requestedOffset) && requestedOffset > RECALL_OFFSET_CAP) {
420
+ offset = RECALL_OFFSET_CAP
421
+ recallCapNotes.push(`offset capped to ${RECALL_OFFSET_CAP} (requested ${requestedOffset})`)
422
+ } else {
423
+ offset = Math.min(RECALL_OFFSET_CAP, offset)
424
+ }
425
+ const recallCapPrefix = recallCapNotes.length ? `${recallCapNotes.join('; ')}\n` : ''
426
+ // Recent-browsing default: a query-less recall is a "show me the latest
427
+ // messages" browse, not a relevance search — chronological order is the
428
+ // only ordering that makes sense there, so sort defaults to 'date' when
429
+ // no query is present (explicit args.sort still wins). Query recalls keep
430
+ // the importance default.
431
+ const hasQueryForSort = Array.isArray(args.query)
432
+ ? args.query.some((v) => String(v || '').trim())
433
+ : String(args.query ?? '').trim() !== ''
434
+ const sort = args.sort != null ? String(args.sort) : (hasQueryForSort ? 'importance' : 'date')
435
+ // Chunk content is the primary recall output. Members default to true so
436
+ // callers receive the raw chunk leaves (the cycle1-produced semantic
437
+ // chunks) rather than just the root's cycle2-compressed summary line.
438
+ // Explicit `includeMembers:false` keeps the legacy summary-only mode.
439
+ const includeMembers = args.includeMembers !== false
440
+ // Raw leg defaults ON: recall never drains/chunks inline anymore, so raw
441
+ // (unchunked) rows — embedded by the always-on post-ingest/tick flush —
442
+ // are the only way to see content cycle1 hasn't reached yet, regardless
443
+ // of the recap toggle. The importance/query search leg only ranks chunked
444
+ // entries; readRawRowsInWindow interleaves the raw rows into the hybrid
445
+ // list. Explicit includeRaw:false keeps the chunked-only view.
446
+ const includeRaw = args.includeRaw !== false
447
+ const includeArchived = args.includeArchived !== false
448
+ const category = args.category
449
+ const temporal = parsePeriod(period, Boolean(query))
450
+
451
+ // Derive projectScope from caller cwd (falls back to process.cwd()).
452
+ // Explicit args.projectScope (string) takes priority so callers can
453
+ // override to 'all', 'common', or a specific slug.
454
+ let projectScope
455
+ if (typeof args.projectScope === 'string' && args.projectScope) {
456
+ projectScope = args.projectScope
457
+ } else {
458
+ const projectId = resolveProjectScope(typeof args.cwd === 'string' && args.cwd ? args.cwd : null)
459
+ projectScope = projectId !== null ? projectId : 'common'
460
+ }
461
+
462
+ // R11 reviewer M4: calendar-bounded periods disable freshness decay
463
+ // so within-period ranking doesn't downgrade Mon entries vs Sun.
464
+ const CALENDAR_PERIODS = new Set(['yesterday', 'today', 'this_week', 'last_week'])
465
+ const isCalendarPeriod = period != null
466
+ && (CALENDAR_PERIODS.has(period) || /^\d{4}-\d{2}-\d{2}/.test(period) || /^\d{1,2}:\d{2}~/.test(period))
467
+ const applyFreshness = !isCalendarPeriod
468
+
469
+ // period='last': no time window and no session exclusion — 'last' is a
470
+ // plain "walk back through ALL prior conversation, newest first" browse.
471
+ // No boot-timestamp cap (the old cap hid every session that ran while a
472
+ // long-lived daemon stayed up), no gap-bounded burst, no current-session
473
+ // filter: limit/offset page through history and the grouped renderer
474
+ // separates sessions. temporal stays unbounded (mode marker only).
475
+
476
+ if (query) {
477
+ const _t0 = Date.now()
478
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
479
+ let queryVector = null
480
+ if (isEmbeddingModelReady()) {
481
+ queryVector = await embedText(query)
482
+ } else if (embeddingWarmupCanStart()) {
483
+ // Cold model: WAIT for warmup (bounded) instead of silently degrading
484
+ // to lexical-only. Raw rows are already embedded at ingest time, so
485
+ // the query vector is the only missing piece of the dense leg — a few
486
+ // seconds of warmup buys full hybrid quality on the first recall.
487
+ // On timeout the warmup keeps running in the background and this
488
+ // recall falls back to lexical (same as before).
489
+ const COLD_RECALL_WARMUP_WAIT_MS = Math.max(0, Number(process.env.MIXDOG_RECALL_WARMUP_WAIT_MS) || 8_000)
490
+ let timer
491
+ try {
492
+ queryVector = await Promise.race([
493
+ warmupEmbeddingProvider().then(() => embedText(query)),
494
+ new Promise((resolve) => { timer = setTimeout(() => resolve(null), COLD_RECALL_WARMUP_WAIT_MS) }),
495
+ ])
496
+ } catch (err) {
497
+ log(`[memory-service] embedding warmup during cold recall failed: ${err?.message || err}\n`)
498
+ queryVector = null
499
+ } finally {
500
+ clearTimeout(timer)
501
+ }
502
+ if (!Array.isArray(queryVector)) {
503
+ const now = Date.now()
504
+ if (now - _embeddingColdRecallLogAt > 10_000) {
505
+ _embeddingColdRecallLogAt = now
506
+ log(`[recall] embedding still cold after ${COLD_RECALL_WARMUP_WAIT_MS}ms wait; returning lexical results while background warmup continues\n`)
507
+ }
508
+ }
509
+ } else {
510
+ const now = Date.now()
511
+ if (now - _embeddingColdRecallLogAt > 10_000) {
512
+ _embeddingColdRecallLogAt = now
513
+ log('[recall] embedding model cold; returning lexical results while background warmup continues\n')
514
+ }
515
+ }
516
+ if (signal?.aborted) throw signal.reason ?? new Error('aborted')
517
+ const _t1 = Date.now()
518
+ if (process.env.MIXDOG_DEBUG_MEMORY) {
519
+ log(`[search-time] embed=${_t1 - _t0}ms query="${query.slice(0, 60)}"\n`)
520
+ }
521
+ // Push ts and status filters into the hybrid candidate query so FTS / vec
522
+ // rank inside the requested window, not the whole tree. The previous post-
523
+ // filter approach silently emptied results when relevant matches sat
524
+ // outside `period` (default 30d) and could not bubble through.
525
+ // Recall is history-first: archived roots hold most prior work. Callers
526
+ // that need only live invariants can pass includeArchived:false.
527
+ const excludeStatuses = includeArchived ? [] : ['archived']
528
+ const results = await searchRelevantHybrid(db, query, {
529
+ limit: limit + offset,
530
+ queryVector: Array.isArray(queryVector) ? queryVector : null,
531
+ includeMembers,
532
+ ts_from: temporal?.startMs,
533
+ ts_to: temporal?.endMs,
534
+ applyFreshness,
535
+ projectScope,
536
+ category,
537
+ excludeStatuses,
538
+ // useHotActive was set to true here so default (no-period) calls
539
+ // routed through the mv_hot_active materialized view — a narrow
540
+ // active-roots-only pool. Live usage is dominated by vague-time
541
+ // queries ("recent / lately") where Lead callers omit the period
542
+ // filter, leaving the MV as the sole source. That hid every
543
+ // orphan leaf and every pending root — fresh work from the last 1-60
544
+ // minutes never surfaced. Now that the entries-table CTE legs run
545
+ // against broaden HNSW + GIN trgm partial indexes (the
546
+ // is_root=1 predicate was dropped in the same revision), the
547
+ // entries path is fast enough (1-2 ms ANN on ~10K rows, O(log N)
548
+ // through 1M+) to be the single source of truth. The MV is left in
549
+ // place for now but no longer routed to from search; cycle2 may stop
550
+ // refreshing it in a follow-up commit once nothing else reads it.
551
+ useHotActive: false,
552
+ })
553
+ let filtered = results
554
+ if (sort === 'date') {
555
+ // R11 reviewer L5: NaN guard — entries with null/undefined ts default
556
+ // to 0 so the comparator stays numeric and stable.
557
+ filtered.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0))
558
+ } else {
559
+ filtered.sort((a, b) => {
560
+ const sa = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0 }
561
+ return (sa(b.retrievalScore ?? b.rrf ?? 0) - sa(a.retrievalScore ?? a.rrf ?? 0))
562
+ || (sa(b.score ?? 0) - sa(a.score ?? 0))
563
+ || (sa(b.ts ?? 0) - sa(a.ts ?? 0))
564
+ || (Number(a.id ?? 0) - Number(b.id ?? 0))
565
+ })
566
+ }
567
+ if (includeRaw) {
568
+ // Raw rows (chunk_root IS NULL) carry no retrievalScore, so a naive
569
+ // append-after-hybrid under sort=importance always lands them past
570
+ // slice(offset, offset+limit) once the hybrid pool exceeds one page —
571
+ // every page beyond the first silently drops them. Fetch a wider raw
572
+ // window (bounded like the hybrid candidate pool) and spread the
573
+ // fetched raw rows evenly across the WHOLE hybrid list before slicing,
574
+ // so every offset page gets its proportional share instead of only
575
+ // page 0. Same projectScope/ts window as the hybrid leg — filter
576
+ // parity (item 3) is deliberate, not accidental.
577
+ const RAW_FETCH = Math.min(500, Math.max(20, limit + offset))
578
+ const rawRows = await readRawRowsInWindow(
579
+ db,
580
+ temporal?.startMs ?? null,
581
+ temporal?.endMs ?? Date.now(),
582
+ RAW_FETCH,
583
+ { projectScope, terms: sessionRecallTerms(query) },
584
+ )
585
+ const seenIds = new Set(filtered.map(r => r.id))
586
+ const newRaw = rawRows.filter(r => !seenIds.has(r.id))
587
+ if (sort === 'date') {
588
+ for (const r of newRaw) filtered.push(r)
589
+ filtered.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0))
590
+ } else {
591
+ // sort=importance: interleave raw rows at a fixed stride through the
592
+ // full (pre-slice) hybrid list instead of appending at the tail, so
593
+ // offset > 0 pages also draw from the raw pool proportionally.
594
+ filtered = interleaveRawRows(filtered, newRaw)
595
+ }
596
+ }
597
+ const coreRows = await recallCoreRows(query, { projectScope, category, limit })
598
+ if (coreRows.length > 0) {
599
+ filtered = [...coreRows, ...filtered]
600
+ }
601
+ const sliced = filtered.slice(offset, offset + limit)
602
+ const _t2 = Date.now()
603
+ if (process.env.MIXDOG_DEBUG_MEMORY) {
604
+ log(`[search-time] hybrid+sort+raw=${_t2 - _t1}ms rows=${filtered.length} sliced=${sliced.length}\n`)
605
+ }
606
+ // Emit a recall trace event so getTraceWithEntries() can correlate
607
+ // this search with the top-ranked memory entry. One event per
608
+ // handleSearch call (not per returned row) — cheapest meaningful link.
609
+ // parent_span_id left null: the agent-side span id is only known after
610
+ // the DB insert of the loop/tool events, which happens async on the
611
+ // client side and is not available here.
612
+ if (_traceDb && filtered.length > 0) {
613
+ const topHit = filtered[0]
614
+ const topId = topHit?.id != null ? Number(topHit.id) : null
615
+ if (topId !== null && Number.isFinite(topId)) {
616
+ insertTraceEvents(_traceDb, [{
617
+ ts: Date.now(),
618
+ kind: 'recall',
619
+ entry_id: topId,
620
+ payload: { query: query.slice(0, 200), hit_count: filtered.length },
621
+ }]).catch(e => log(`[trace] insertTraceEvents error: ${e?.message}\n`))
622
+ }
623
+ }
624
+ const out = { text: recallCapPrefix + renderEntryLines(sliced) }
625
+ if (process.env.MIXDOG_DEBUG_MEMORY) {
626
+ log(`[search-time] render+trace=${Date.now() - _t2}ms total=${Date.now() - _t0}ms textLen=${out.text.length}\n`)
627
+ }
628
+ return out
629
+ }
630
+
631
+ const filters = { limit: limit + offset }
632
+ if (temporal?.startMs != null) { filters.ts_from = temporal.startMs; filters.ts_to = temporal.endMs }
633
+ filters.projectScope = projectScope
634
+ if (category != null) filters.category = category
635
+ filters.sort = sort
636
+ if (!includeArchived) filters.excludeStatuses = ['archived']
637
+ if (includeMembers) filters.includeMembers = true
638
+ const rows = await retrieveEntries(db, filters)
639
+ // Recent-browsing raw merge: a query-less recall must show the freshest
640
+ // turns even when cycle1 hasn't chunked them yet. Roots lag ingest by up
641
+ // to a cycle interval, so on sort=date pull the raw (unchunked) window
642
+ // too and merge chronologically — original text first, no summaries.
643
+ // Query-less + includeRaw:false callers keep the roots-only view.
644
+ let merged = rows
645
+ if (sort === 'date' && args.includeRaw !== false) {
646
+ const rawRows = await readRawRowsInWindow(
647
+ db,
648
+ filters.ts_from ?? temporal?.startMs ?? null,
649
+ filters.ts_to ?? temporal?.endMs ?? Date.now(),
650
+ Math.min(500, Math.max(20, limit + offset)),
651
+ { projectScope },
652
+ )
653
+ const seenIds = new Set(rows.map(r => Number(r.id)))
654
+ // Drop raw leaves already inlined as some returned root's member.
655
+ for (const r of rows) {
656
+ if (Array.isArray(r.members)) for (const m of r.members) seenIds.add(Number(m.id))
657
+ }
658
+ const newRaw = rawRows.filter(r => !seenIds.has(Number(r.id)))
659
+ if (newRaw.length > 0) {
660
+ merged = [...rows, ...newRaw]
661
+ merged.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0) || (Number(b.id) || 0) - (Number(a.id) || 0))
662
+ }
663
+ }
664
+ const sliced = merged.slice(offset, offset + limit)
665
+ // Multi-session grouping: a GLOBAL query-less browse ("recent work") spans
666
+ // sessions — render grouped per session (newest activity first) with the
667
+ // caller's own session marked "(current)" via the currentSessionId hint.
668
+ // Falls through to the flat list when everything is one session.
669
+ const _currentSessionHint = String(args?.currentSessionId || '').trim()
670
+ return { text: recallCapPrefix + renderSessionGroupedLines(sliced, { currentSessionId: _currentSessionHint }) }
671
+ }
672
+
673
+ async function dumpSessionRootChunks(args = {}) {
674
+ const db = getDb()
675
+ const sessionId = String(args.sessionId || args.session_id || '').trim()
676
+ if (!sessionId) return { text: '(no current session)', rows: [], chunks: [], isError: true }
677
+ const includeRaw = args.includeRaw !== false
678
+ const limit = Math.max(1, Math.min(1000, Number(args.limit) || 1000))
679
+ const rootRows = (await db.query(`
680
+ SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root,
681
+ element, category, summary, status, score, last_seen_at, project_id
682
+ FROM entries
683
+ WHERE session_id = $1 AND is_root = 1
684
+ ORDER BY COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
685
+ LIMIT $2
686
+ `, [sessionId, limit])).rows
687
+ const roots = rootRows.map((r) => ({ ...r, members: [] }))
688
+ const rootIds = roots.map((r) => Number(r.id)).filter((id) => Number.isFinite(id))
689
+ const memberRows = rootIds.length > 0
690
+ ? (await db.query(`
691
+ SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root, project_id
692
+ FROM entries
693
+ WHERE chunk_root = ANY($1::bigint[]) AND is_root = 0
694
+ ORDER BY chunk_root ASC, COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
695
+ `, [rootIds])).rows
696
+ : []
697
+ const byRoot = new Map(roots.map((r) => [Number(r.id), r]))
698
+ for (const m of memberRows) {
699
+ const root = byRoot.get(Number(m.chunk_root))
700
+ if (root) root.members.push(m)
701
+ }
702
+ let rawRows = []
703
+ if (includeRaw) {
704
+ rawRows = (await db.query(`
705
+ SELECT id, ts, role, content, session_id, source_turn, chunk_root, is_root, project_id
706
+ FROM entries
707
+ WHERE session_id = $1
708
+ AND is_root = 0
709
+ AND (chunk_root IS NULL OR chunk_root = id)
710
+ ORDER BY COALESCE(source_turn, 2147483647) ASC, ts ASC, id ASC
711
+ LIMIT $2
712
+ `, [sessionId, limit])).rows
713
+ }
714
+ const chunks = []
715
+ for (const root of roots) {
716
+ const memberText = root.members
717
+ .map((m) => `${m.role === 'assistant' ? 'assistant' : m.role === 'user' ? 'user' : m.role}: ${cleanMemoryText(String(m.content ?? ''))}`)
718
+ .filter(Boolean)
719
+ .join('\n')
720
+ const summary = [root.element, root.summary].map((v) => String(v || '').trim()).filter(Boolean).join(' — ')
721
+ chunks.push({
722
+ id: Number(root.id),
723
+ kind: 'root',
724
+ ts: Number(root.ts) || 0,
725
+ sourceTurn: root.source_turn ?? null,
726
+ category: root.category || null,
727
+ summary,
728
+ text: memberText || cleanMemoryText(String(root.content ?? '')),
729
+ members: root.members,
730
+ })
731
+ }
732
+ for (const raw of rawRows) {
733
+ chunks.push({
734
+ id: Number(raw.id),
735
+ kind: 'raw',
736
+ chunkRoot: raw.chunk_root ?? null,
737
+ ts: Number(raw.ts) || 0,
738
+ sourceTurn: raw.source_turn ?? null,
739
+ category: null,
740
+ summary: '',
741
+ text: `${raw.role === 'assistant' ? 'assistant' : raw.role === 'user' ? 'user' : raw.role}: ${cleanMemoryText(String(raw.content ?? ''))}`,
742
+ members: [],
743
+ })
744
+ }
745
+ chunks.sort((a, b) => {
746
+ const at = Number.isFinite(Number(a.sourceTurn)) ? Number(a.sourceTurn) : 2147483647
747
+ const bt = Number.isFinite(Number(b.sourceTurn)) ? Number(b.sourceTurn) : 2147483647
748
+ return (at - bt) || ((a.ts || 0) - (b.ts || 0)) || ((a.id || 0) - (b.id || 0))
749
+ })
750
+ const text = chunks.length
751
+ ? chunks.map((chunk, idx) => {
752
+ const label = chunk.kind === 'root'
753
+ ? `# chunk ${idx + 1} root=${chunk.id}${chunk.category ? ` category=${chunk.category}` : ''}`
754
+ : `${chunk.chunkRoot == null ? '# raw_pending' : '# raw_terminal'} ${idx + 1} id=${chunk.id}`
755
+ const summary = chunk.summary ? `summary: ${chunk.summary}\n` : ''
756
+ return `${label}\n${summary}${chunk.text}`.trim()
757
+ }).join('\n\n')
758
+ : '(no results)'
759
+ return { text, rows: [...roots, ...rawRows], chunks }
760
+ }
761
+
762
+ async function entryStats() {
763
+ const db = getDb()
764
+ return await db.transaction(async (tx) => {
765
+ const total = (await tx.query(`SELECT COUNT(*) c FROM entries`)).rows[0].c
766
+ const roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1`)).rows[0].c
767
+ const active_roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'active'`)).rows[0].c
768
+ const archived_roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'archived'`)).rows[0].c
769
+ const unchunked_leaves = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE chunk_root IS NULL`)).rows[0].c
770
+ const cycle2_pending_roots = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'pending'`)).rows[0].c
771
+ const core_entries = (await tx.query(`SELECT COUNT(*) c FROM core_entries`)).rows[0].c
772
+ const core_embed_null = (await tx.query(`SELECT COUNT(*) c FROM core_entries WHERE embedding IS NULL`)).rows[0].c
773
+ const active_core_summaries = (await tx.query(`SELECT COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'active' AND core_summary IS NOT NULL`)).rows[0].c
774
+ const active_core_summary_missing = (await tx.query(`
775
+ SELECT COUNT(*) c
776
+ FROM entries
777
+ WHERE is_root = 1
778
+ AND status = 'active'
779
+ AND (core_summary IS NULL OR btrim(core_summary) = '')
780
+ `)).rows[0].c
781
+ const byStatus = (await tx.query(`SELECT status, COUNT(*) c FROM entries WHERE is_root = 1 GROUP BY status`)).rows
782
+ const byCategory = (await tx.query(`SELECT category, COUNT(*) c FROM entries WHERE is_root = 1 AND status = 'active' GROUP BY category ORDER BY c DESC`)).rows
783
+ const mvRows = (await tx.query(`SELECT relispopulated FROM pg_class WHERE relname = 'mv_hot_active' LIMIT 1`)).rows
784
+ const mv_hot_active_populated = mvRows.length ? Boolean(mvRows[0].relispopulated) : null
785
+ return {
786
+ total, roots, active_roots, archived_roots, unchunked_leaves, cycle2_pending_roots,
787
+ core_entries, core_embed_null, active_core_summaries, active_core_summary_missing,
788
+ mv_hot_active_populated,
789
+ byStatus, byCategory,
790
+ }
791
+ })
792
+ }
793
+
794
+ return {
795
+ readRawRowsInWindow,
796
+ recallSessionRows,
797
+ recallCoreRows,
798
+ handleSearch,
799
+ dumpSessionRootChunks,
800
+ entryStats,
801
+ }
802
+ }