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
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ // recall-bench.mjs — case-based recall quality bench against the LIVE memory
3
+ // DB. READ-ONLY: every case goes through handleToolCall('search_memories')
4
+ // which only performs SELECTs (handleSearch path). No mutation actions
5
+ // ('memory' tool / cycle1 / cycle2 / prune / purge / etc.) are ever invoked
6
+ // here — do not add any.
7
+ //
8
+ // node scripts/recall-bench.mjs [--cases scripts/recall-bench-cases.json] [--json]
9
+ //
10
+ // Loads src/runtime/memory/index.mjs in-process (same module the daemon
11
+ // uses), calls init() once, runs each bench case through handleToolCall,
12
+ // prints params/result-count/top-3/latency/PASS-WARN per case, then a
13
+ // summary table, then calls stop().
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { resolve, dirname } from 'node:path';
16
+ import { fileURLToPath, pathToFileURL } from 'node:url';
17
+
18
+ const __dir = dirname(fileURLToPath(import.meta.url));
19
+ const ROOT = resolve(__dir, '..');
20
+ const DEFAULT_CASES_PATH = resolve(__dir, 'recall-bench-cases.json');
21
+
22
+ function argValue(name, fallback = null) {
23
+ const idx = process.argv.indexOf(`--${name}`);
24
+ if (idx >= 0 && idx + 1 < process.argv.length) return process.argv[idx + 1];
25
+ const pref = `--${name}=`;
26
+ const hit = process.argv.find((a) => a.startsWith(pref));
27
+ return hit ? hit.slice(pref.length) : fallback;
28
+ }
29
+ function hasFlag(name) { return process.argv.includes(`--${name}`); }
30
+
31
+ const WARN_LATENCY_MS = 3000;
32
+
33
+ const DEFAULT_CASES = [
34
+ { id: 'kw-ko', label: 'keyword query (ko)', args: { query: '메모리 재현' }, expect: 'results' },
35
+ { id: 'kw-en', label: 'keyword query (en)', args: { query: 'memory recall pipeline' }, expect: 'results' },
36
+ { id: 'short-1tok', label: 'short 1-token query', args: { query: 'recall' }, expect: 'results' },
37
+ { id: 'short-2tok', label: 'short 2-token query', args: { query: 'cycle1 drain' }, expect: 'results' },
38
+ { id: 'browse-last', label: 'query-less recent browse (period=last)', args: { period: 'last', limit: 10 }, expect: 'browse' },
39
+ { id: 'period-24h', label: 'period window 24h', args: { period: '24h', limit: 10 }, expect: 'browse' },
40
+ { id: 'period-7d', label: 'period window 7d', args: { period: '7d', limit: 10 }, expect: 'browse' },
41
+ { id: 'category-filter', label: 'category filter (decision)', args: { period: '30d', category: 'decision', limit: 10 }, expect: 'browse' },
42
+ { id: 'id-lookup', label: 'id lookup', args: { id: 1 }, expect: 'idlookup' },
43
+ { id: 'scope-project', label: 'project-scoped query', args: { query: 'recall', cwd: ROOT, limit: 10 }, expect: 'results' },
44
+ { id: 'scope-all', label: 'all-scope query', args: { query: 'recall', projectScope: 'all', limit: 10 }, expect: 'results' },
45
+ { id: 'raw-on', label: 'includeRaw on', args: { query: 'recall', includeRaw: true, limit: 10 }, expect: 'results' },
46
+ { id: 'raw-off', label: 'includeRaw off', args: { query: 'recall', includeRaw: false, limit: 10 }, expect: 'results' },
47
+ ];
48
+
49
+ function loadCases(path) {
50
+ if (path && existsSync(resolve(path))) {
51
+ try {
52
+ const parsed = JSON.parse(readFileSync(resolve(path), 'utf8'));
53
+ if (Array.isArray(parsed) && parsed.length) return parsed;
54
+ } catch (e) {
55
+ process.stderr.write(`[recall-bench] failed to parse cases file ${path}: ${e.message}; using built-in cases\n`);
56
+ }
57
+ }
58
+ return DEFAULT_CASES;
59
+ }
60
+
61
+ function textOfResult(result) {
62
+ if (result && typeof result === 'object' && Array.isArray(result.content)) {
63
+ return result.content.map((p) => (p?.type === 'text' ? p.text || '' : JSON.stringify(p))).join('\n');
64
+ }
65
+ if (result && typeof result === 'object' && typeof result.text === 'string') return result.text;
66
+ if (typeof result === 'string') return result;
67
+ return JSON.stringify(result ?? '');
68
+ }
69
+
70
+ function countEntryLines(text) {
71
+ const t = String(text || '').trim();
72
+ if (!t || t === '(no results)' || t === '(no valid ids)') return 0;
73
+ return t.split('\n').filter((line) => line.trim() && !line.startsWith('[recall truncated') && !line.startsWith('note:')).length;
74
+ }
75
+
76
+ function topN(text, n = 3, maxLen = 140) {
77
+ const t = String(text || '').trim();
78
+ if (!t || t === '(no results)') return [];
79
+ return t.split('\n')
80
+ .filter((line) => line.trim() && !line.startsWith('[recall truncated') && !line.startsWith('note:'))
81
+ .slice(0, n)
82
+ .map((line) => (line.length > maxLen ? `${line.slice(0, maxLen - 1)}…` : line));
83
+ }
84
+
85
+ function evaluateCase(kase, { count, ms, isError }) {
86
+ const warnings = [];
87
+ if (isError) warnings.push('error result');
88
+ if (ms > WARN_LATENCY_MS) warnings.push(`latency ${ms}ms > ${WARN_LATENCY_MS}ms`);
89
+ if ((kase.expect === 'browse' || kase.expect === 'idlookup') && count === 0) {
90
+ warnings.push('0 results for a browse/id-lookup case (expected data present)');
91
+ }
92
+ const status = warnings.length ? 'WARN' : 'PASS';
93
+ return { status, warnings };
94
+ }
95
+
96
+ async function runCase(memoryModule, kase) {
97
+ const started = Date.now();
98
+ let result;
99
+ let isError = false;
100
+ let errMsg = null;
101
+ try {
102
+ result = await memoryModule.handleToolCall('search_memories', kase.args || {});
103
+ isError = Boolean(result?.isError);
104
+ } catch (e) {
105
+ isError = true;
106
+ errMsg = e?.message || String(e);
107
+ result = { text: `(error: ${errMsg})` };
108
+ }
109
+ const ms = Date.now() - started;
110
+ const text = textOfResult(result);
111
+ const count = countEntryLines(text);
112
+ const evalResult = evaluateCase(kase, { count, ms, isError });
113
+ return {
114
+ id: kase.id,
115
+ label: kase.label,
116
+ args: kase.args,
117
+ count,
118
+ ms,
119
+ isError,
120
+ errMsg,
121
+ top3: topN(text, 3),
122
+ status: evalResult.status,
123
+ warnings: evalResult.warnings,
124
+ };
125
+ }
126
+
127
+ function printCase(row) {
128
+ process.stdout.write(`\n[${row.status}] ${row.id} — ${row.label}\n`);
129
+ process.stdout.write(` params: ${JSON.stringify(row.args)}\n`);
130
+ process.stdout.write(` results: ${row.count} latency: ${row.ms}ms${row.isError ? ` ERROR: ${row.errMsg}` : ''}\n`);
131
+ if (row.top3.length) {
132
+ for (const line of row.top3) process.stdout.write(` - ${line}\n`);
133
+ } else {
134
+ process.stdout.write(' (no results)\n');
135
+ }
136
+ for (const w of row.warnings) process.stdout.write(` WARN: ${w}\n`);
137
+ }
138
+
139
+ function printSummary(rows) {
140
+ process.stdout.write('\n=== recall-bench summary ===\n');
141
+ const widths = { id: 18, results: 8, ms: 8, status: 6 };
142
+ process.stdout.write(
143
+ `${'case'.padEnd(widths.id)}${'results'.padEnd(widths.results)}${'ms'.padEnd(widths.ms)}${'status'.padEnd(widths.status)}notes\n`,
144
+ );
145
+ for (const r of rows) {
146
+ process.stdout.write(
147
+ `${String(r.id).padEnd(widths.id)}${String(r.count).padEnd(widths.results)}${String(r.ms).padEnd(widths.ms)}${String(r.status).padEnd(widths.status)}${r.warnings.join('; ')}\n`,
148
+ );
149
+ }
150
+ const warnCount = rows.filter((r) => r.status === 'WARN').length;
151
+ const totalMs = rows.reduce((s, r) => s + r.ms, 0);
152
+ process.stdout.write(`\ncases=${rows.length} pass=${rows.length - warnCount} warn=${warnCount} total_latency=${totalMs}ms\n`);
153
+ }
154
+
155
+ async function main() {
156
+ const casesPath = argValue('cases', DEFAULT_CASES_PATH);
157
+ const jsonMode = hasFlag('json');
158
+ const cases = loadCases(casesPath);
159
+
160
+ let memoryModule;
161
+ try {
162
+ memoryModule = await import(pathToFileURL(resolve(ROOT, 'src/runtime/memory/index.mjs')).href);
163
+ } catch (e) {
164
+ process.stderr.write(`[recall-bench] failed to load memory module: ${e?.stack || e?.message || e}\n`);
165
+ process.exitCode = 1;
166
+ return;
167
+ }
168
+
169
+ try {
170
+ await memoryModule.init();
171
+ } catch (e) {
172
+ process.stdout.write(`recall-bench: DB unreachable / init failed — reporting clearly, no fabricated results.\n`);
173
+ process.stdout.write(`error: ${e?.stack || e?.message || e}\n`);
174
+ process.exitCode = 1;
175
+ return;
176
+ }
177
+
178
+ const rows = [];
179
+ try {
180
+ for (const kase of cases) {
181
+ const row = await runCase(memoryModule, kase);
182
+ rows.push(row);
183
+ if (!jsonMode) printCase(row);
184
+ }
185
+ } finally {
186
+ try { await memoryModule.stop?.(); } catch {}
187
+ }
188
+
189
+ const allZero = rows.length > 0 && rows.every((r) => r.count === 0 && !r.isError);
190
+ if (allZero) {
191
+ process.stdout.write('\nNOTE: every case returned 0 results — DB is likely empty (or unreachable pool). Treat WARNs below as expected-empty, not a recall bug, until data is present.\n');
192
+ }
193
+
194
+ if (jsonMode) {
195
+ process.stdout.write(JSON.stringify({ cases: rows }, null, 2) + '\n');
196
+ } else {
197
+ printSummary(rows);
198
+ }
199
+
200
+ const hardErrors = rows.filter((r) => r.isError);
201
+ if (hardErrors.length) process.exitCode = 1;
202
+ }
203
+
204
+ main().catch((e) => {
205
+ process.stderr.write(`${e?.stack || e?.message || e}\n`);
206
+ process.exitCode = 1;
207
+ });
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env node
2
+ // routing-corpus.mjs — mine the local agent trace for REAL tool-routing
3
+ // patterns and freeze them as a fixed corpus. The corpus (input) is captured
4
+ // ONCE with --save; rule changes are then A/B-evaluated against that frozen
5
+ // set by re-running --eval before/after (input constant, only rules change).
6
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { basename, resolve } from 'node:path';
9
+ import { resolvePluginData } from '../src/runtime/shared/plugin-paths.mjs';
10
+
11
+ function argValue(name, fallback = null) {
12
+ const idx = process.argv.indexOf(name);
13
+ if (idx >= 0 && idx + 1 < process.argv.length) return process.argv[idx + 1];
14
+ const pref = `${name}=`;
15
+ const hit = process.argv.find((a) => a.startsWith(pref));
16
+ return hit ? hit.slice(pref.length) : fallback;
17
+ }
18
+ function hasFlag(name) { return process.argv.includes(name); }
19
+
20
+ function parseDuration(value) {
21
+ const raw = String(value || '').trim();
22
+ if (!raw) return null;
23
+ if (/^now$/i.test(raw)) return Date.now();
24
+ if (/^\d+$/.test(raw)) { const n = Number(raw); return n > 10_000_000_000 ? n : n * 1000; }
25
+ const rel = raw.match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/i);
26
+ if (rel) {
27
+ const n = Number(rel[1]); const u = rel[2].toLowerCase();
28
+ const mult = u === 'ms' ? 1 : u === 's' ? 1000 : u === 'm' ? 60_000 : u === 'h' ? 3_600_000 : 86_400_000;
29
+ return Date.now() - n * mult;
30
+ }
31
+ const parsed = Date.parse(raw);
32
+ return Number.isFinite(parsed) ? parsed : null;
33
+ }
34
+
35
+ function defaultTracePath() {
36
+ const data = process.env.MIXDOG_DATA_DIR || resolvePluginData() || resolve(homedir(), '.mixdog', 'data');
37
+ return resolve(data, 'history', 'agent-trace.jsonl');
38
+ }
39
+
40
+ function readRows(path) {
41
+ if (!existsSync(path)) return [];
42
+ const out = [];
43
+ for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
44
+ if (!line) continue;
45
+ try { out.push(JSON.parse(line)); } catch { /* best-effort */ }
46
+ }
47
+ return out;
48
+ }
49
+ function payload(row) { return row?.payload && typeof row.payload === 'object' ? row.payload : {}; }
50
+ function field(row, name) { if (row && row[name] != null) return row[name]; const p = payload(row); return p[name] != null ? p[name] : null; }
51
+ function sessionId(row) { return String(row?.session_id || row?.sessionId || field(row, 'session_id') || ''); }
52
+ function shortId(id) { const s = String(id || ''); return s.length <= 18 ? s : `${s.slice(0, 10)}…${s.slice(-6)}`; }
53
+ function shortModel(m) { const s = String(m || '-'); const i = s.lastIndexOf('/'); return i >= 0 ? s.slice(i + 1) : s; }
54
+ function groupBy(rows, keyFn) {
55
+ const map = new Map();
56
+ for (const r of rows) { const k = keyFn(r); if (!map.has(k)) map.set(k, []); map.get(k).push(r); }
57
+ return map;
58
+ }
59
+
60
+ function argsSummary(tool, args) {
61
+ if (!args || typeof args !== 'object') return '';
62
+ const clip = (v, n = 60) => { const s = String(v ?? '').replace(/\s+/g, ' ').trim(); return s.length > n ? `${s.slice(0, n - 1)}…` : s; };
63
+ const arr = (v) => Array.isArray(v) ? `[${v.length}:${clip(v[0], 40)}]` : clip(v, 50);
64
+ switch (tool) {
65
+ case 'grep': return clip(Array.isArray(args.pattern) ? `[${args.pattern.length}]${args.pattern[0]}` : args.pattern);
66
+ case 'read': return clip(basename(String(Array.isArray(args.path) ? args.path[0] : args.path || '')));
67
+ case 'code_graph': return clip(`${args.mode || '?'}:${args.symbol || args.file || ''}`);
68
+ case 'explore': return arr(args.query);
69
+ case 'find': return clip(args.query);
70
+ case 'glob': return clip(Array.isArray(args.pattern) ? args.pattern[0] : args.pattern);
71
+ case 'list': return clip(basename(String(args.path || '')));
72
+ case 'apply_patch': return '(edit)';
73
+ case 'shell': return clip(args.command);
74
+ default: return clip(JSON.stringify(args), 40);
75
+ }
76
+ }
77
+
78
+ function normGrep(args) {
79
+ const p = args?.pattern;
80
+ return String(Array.isArray(p) ? p.join('|') : (p ?? '')).trim().toLowerCase();
81
+ }
82
+
83
+ // shell used to INSPECT the filesystem (read/list/search/exists) instead of a
84
+ // dedicated tool — the anti-pattern the shell description targets. Concept, not
85
+ // an exhaustive list: a command whose leading verb only reads/lists/searches
86
+ // files and does not change state or run a program (git/node/npm/build/test).
87
+ const SHELL_INSPECT_VERB = /(?:^|[\s;|&(]|\bforeach-object\s*\{)\s*(get-content|get-childitem|gci|select-string|cat|type|ls|dir|head|tail|find|findstr|grep|rg|wc|test-path|resolve-path|readlink|realpath|stat)\b/i;
88
+ const SHELL_EXEC_VERB = /(?:^|[\s;|&])\s*(git|node|npm|npx|pnpm|yarn|python|cargo|go|make|docker|rm|remove-item|mkdir|new-item|set-content|out-file|move-item|copy-item|>>|>)\b/i;
89
+ function isShellInspect(command) {
90
+ const c = String(command || '');
91
+ if (!c.trim()) return false;
92
+ // If the command also changes state or runs a program, it is legitimate.
93
+ if (SHELL_EXEC_VERB.test(c)) return false;
94
+ return SHELL_INSPECT_VERB.test(c);
95
+ }
96
+
97
+ function buildCase(sid, toolRows) {
98
+ const sorted = [...toolRows].sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0));
99
+ const agent = field(sorted[0], 'agent') || field(sorted.find((r) => field(r, 'agent')), 'agent') || null;
100
+ const model = field(sorted[0], 'model') || null;
101
+ const iters = new Set();
102
+ const sequence = [];
103
+ for (const r of sorted) {
104
+ const it = field(r, 'iteration');
105
+ if (it != null) iters.add(it);
106
+ const tool = field(r, 'tool_name') || '?';
107
+ sequence.push({ it: it ?? null, tool, args: argsSummary(tool, field(r, 'tool_args')), rawArgs: field(r, 'tool_args') });
108
+ }
109
+ // per-iteration tool counts for serial detection
110
+ const perIt = new Map();
111
+ for (const s of sequence) { if (s.it == null) continue; perIt.set(s.it, (perIt.get(s.it) || 0) + 1); }
112
+ const itOrder = [...perIt.keys()].sort((a, b) => a - b);
113
+ let longestSingleRun = 0, run = 0;
114
+ for (const it of itOrder) { if (perIt.get(it) === 1) { run += 1; longestSingleRun = Math.max(longestSingleRun, run); } else run = 0; }
115
+
116
+ const names = sequence.map((s) => s.tool);
117
+ const hasAnchor = names.some((n) => n === 'grep' || n === 'code_graph' || n === 'find' || n === 'read');
118
+ const exploreCalls = sequence.filter((s) => s.tool === 'explore');
119
+ const flags = [];
120
+ if (exploreCalls.length && hasAnchor) flags.push('explore_overuse');
121
+ if (exploreCalls.some((s) => Array.isArray(s.rawArgs?.query))) flags.push('explore_multiquery');
122
+ if (names[0] === 'explore') flags.push('explore_first');
123
+ if (sequence.some((s) => s.tool === 'code_graph' && s.rawArgs?.mode === 'find_symbol' && !s.rawArgs?.file)) flags.push('find_symbol_noscope');
124
+ if (longestSingleRun >= 4) flags.push('serial_single_tool');
125
+ // read fragmentation
126
+ const readCounts = new Map();
127
+ for (const s of sequence) { if (s.tool !== 'read') continue; const k = String(s.rawArgs?.path ?? s.args); readCounts.set(k, (readCounts.get(k) || 0) + 1); }
128
+ if ([...readCounts.values()].some((c) => c >= 3)) flags.push('read_fragmentation');
129
+ // grep retry
130
+ const grepCounts = new Map();
131
+ for (const s of sequence) { if (s.tool !== 'grep') continue; const k = normGrep(s.rawArgs); if (!k) continue; grepCounts.set(k, (grepCounts.get(k) || 0) + 1); }
132
+ if ([...grepCounts.values()].some((c) => c >= 2)) flags.push('grep_retry');
133
+ // shell used for filesystem inspection instead of dedicated tools
134
+ if (sequence.some((s) => s.tool === 'shell' && isShellInspect(s.rawArgs?.command))) flags.push('shell_inspect');
135
+
136
+ return {
137
+ session_id: sid,
138
+ short_id: shortId(sid),
139
+ agent, model,
140
+ turns: iters.size || itOrder.length,
141
+ tools: sequence.length,
142
+ first_tool: names[0] || null,
143
+ longest_single_run: longestSingleRun,
144
+ flags,
145
+ max_ts: Math.max(...sorted.map((r) => Number(r.ts || 0))),
146
+ sequence: sequence.map((s) => ({ it: s.it, tool: s.tool, args: s.args })),
147
+ };
148
+ }
149
+
150
+ const FLAG_KEYS = ['explore_overuse', 'explore_multiquery', 'explore_first', 'find_symbol_noscope', 'serial_single_tool', 'read_fragmentation', 'grep_retry', 'shell_inspect'];
151
+
152
+ function buildCorpus(rows, { limit, sinceTs, agentFilter }) {
153
+ let filtered = rows.filter((r) => r.kind === 'tool' && sessionId(r));
154
+ if (sinceTs != null) filtered = filtered.filter((r) => Number(r.ts || 0) >= sinceTs);
155
+ if (agentFilter) { const q = String(agentFilter).toLowerCase(); filtered = filtered.filter((r) => String(field(r, 'agent') || '').toLowerCase().includes(q)); }
156
+ const bySession = groupBy(filtered, sessionId);
157
+ const cases = [...bySession.entries()].map(([sid, tr]) => buildCase(sid, tr))
158
+ .sort((a, b) => Number(b.max_ts || 0) - Number(a.max_ts || 0));
159
+ const emitted = cases.slice(0, limit);
160
+ const summary = {};
161
+ for (const k of FLAG_KEYS) {
162
+ const count = emitted.filter((c) => c.flags.includes(k)).length;
163
+ summary[k] = { count, pct: emitted.length ? Math.round((count / emitted.length) * 100) : 0 };
164
+ }
165
+ return { scanned: bySession.size, emitted: emitted.length, summary, cases: emitted };
166
+ }
167
+
168
+ function scoreCorpus(cases) {
169
+ // A single健全 index: sum of flagged cases per KPI (lower is better).
170
+ const summary = {};
171
+ let flaggedCases = 0;
172
+ for (const k of FLAG_KEYS) summary[k] = cases.filter((c) => c.flags.includes(k)).length;
173
+ for (const c of cases) if (c.flags.length) flaggedCases += 1;
174
+ return { total: cases.length, flagged_cases: flaggedCases, clean_pct: cases.length ? Math.round(((cases.length - flaggedCases) / cases.length) * 100) : 0, by_flag: summary };
175
+ }
176
+
177
+ function renderText(corpus, showCases) {
178
+ const L = [];
179
+ L.push(`routing corpus: scanned ${corpus.scanned} sessions, emitted ${corpus.emitted} cases`);
180
+ L.push('anti-pattern summary:');
181
+ for (const k of FLAG_KEYS) L.push(`- ${k.padEnd(20)} ${String(corpus.summary[k].count).padStart(3)} (${corpus.summary[k].pct}%)`);
182
+ if (showCases) {
183
+ L.push('');
184
+ corpus.cases.forEach((c, i) => {
185
+ L.push(`#${i + 1} agent=${c.agent || '-'} model=${shortModel(c.model)} turns=${c.turns} tools=${c.tools} flags=[${c.flags.join(',')}] sess=${c.short_id}`);
186
+ const shown = c.sequence.slice(0, 8).map((s) => `${s.tool}(${s.args})`).join(' → ');
187
+ const more = c.sequence.length > 8 ? ` +${c.sequence.length - 8} more` : '';
188
+ L.push(` seq: ${shown}${more}`);
189
+ });
190
+ }
191
+ return L.join('\n');
192
+ }
193
+
194
+ // Extract one case by session id (or short-id prefix) from the current trace.
195
+ function extractCaseById(rows, wanted) {
196
+ const q = String(wanted || '');
197
+ const tr = rows.filter((r) => r.kind === 'tool' && sessionId(r) && (sessionId(r) === q || sessionId(r).startsWith(q) || shortId(sessionId(r)).startsWith(q)));
198
+ if (!tr.length) return null;
199
+ const sid = sessionId(tr[0]);
200
+ return buildCase(sid, tr.filter((r) => sessionId(r) === sid));
201
+ }
202
+
203
+ // tool-name -> count for a case (the tool mix).
204
+ function toolMix(cas) {
205
+ const mix = {};
206
+ for (const s of cas.sequence) mix[s.tool] = (mix[s.tool] || 0) + 1;
207
+ return mix;
208
+ }
209
+
210
+ function renderVs(before, after) {
211
+ const L = [];
212
+ const fmtMix = (m) => Object.entries(m).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}:${v}`).join(' ') || '(none)';
213
+ L.push(`routing A/B before=${before.short_id} (${before.agent || '-'}/${shortModel(before.model)}) after=${after.short_id} (${after.agent || '-'}/${shortModel(after.model)})`);
214
+ L.push('');
215
+ const metric = (label, b, a, lowerBetter = true) => {
216
+ const d = a - b; const arrow = d === 0 ? '=' : (lowerBetter ? (d < 0 ? 'v' : '^') : (d > 0 ? 'v' : '^'));
217
+ L.push(`- ${label.padEnd(14)} ${String(b).padStart(5)} -> ${String(a).padStart(5)} (${d > 0 ? '+' : ''}${d}) ${arrow}`);
218
+ };
219
+ metric('turns', before.turns, after.turns);
220
+ metric('tools', before.tools, after.tools);
221
+ metric('flags', before.flags.length, after.flags.length);
222
+ L.push(` before tool-mix: ${fmtMix(toolMix(before))}`);
223
+ L.push(` after tool-mix: ${fmtMix(toolMix(after))}`);
224
+ const gained = after.flags.filter((f) => !before.flags.includes(f));
225
+ const cleared = before.flags.filter((f) => !after.flags.includes(f));
226
+ if (cleared.length) L.push(` cleared flags: ${cleared.join(', ')}`);
227
+ if (gained.length) L.push(` NEW flags: ${gained.join(', ')}`);
228
+ if (!cleared.length && !gained.length) L.push(` flags unchanged: [${before.flags.join(', ') || 'none'}]`);
229
+ return L.join('\n');
230
+ }
231
+
232
+ // ---- main ----
233
+ const tracePath = argValue('--trace') ? resolve(argValue('--trace')) : defaultTracePath();
234
+ const sinceTs = parseDuration(argValue('--since', null));
235
+ const limit = Number.parseInt(argValue('--limit', '50'), 10) || 50;
236
+ const agentFilter = argValue('--agent', null);
237
+ const jsonMode = hasFlag('--json');
238
+ const savePath = argValue('--save', null); // freeze the corpus (session id list + cases) to a file
239
+ const evalPath = argValue('--eval', null); // re-score a frozen corpus's sessions against CURRENT trace
240
+ const vsIdx = process.argv.indexOf('--vs'); // --vs <before-sid> <after-sid>: routing diff
241
+
242
+ if (vsIdx >= 0) {
243
+ const beforeId = process.argv[vsIdx + 1];
244
+ const afterId = process.argv[vsIdx + 2];
245
+ if (!beforeId || !afterId) { console.error('usage: --vs <before-session> <after-session>'); process.exit(1); }
246
+ const rows = readRows(tracePath);
247
+ const before = extractCaseById(rows, beforeId);
248
+ const after = extractCaseById(rows, afterId);
249
+ if (!before) { console.error(`before session not found: ${beforeId}`); process.exit(1); }
250
+ if (!after) { console.error(`after session not found: ${afterId}`); process.exit(1); }
251
+ if (jsonMode) console.log(JSON.stringify({ before, after }, null, 2));
252
+ else console.log(renderVs(before, after));
253
+ process.exit(0);
254
+ }
255
+
256
+ if (evalPath) {
257
+ // A/B: load frozen corpus, re-extract those exact sessions from the current
258
+ // trace, and score. (Sessions are immutable once finished, so this is stable;
259
+ // the value is comparing two frozen corpora captured before/after a rule change.)
260
+ const frozen = JSON.parse(readFileSync(resolve(evalPath), 'utf8'));
261
+ const wantIds = new Set((frozen.cases || []).map((c) => c.session_id));
262
+ const rows = readRows(tracePath).filter((r) => r.kind === 'tool' && wantIds.has(sessionId(r)));
263
+ const bySession = groupBy(rows, sessionId);
264
+ const cases = [...bySession.entries()].map(([sid, tr]) => buildCase(sid, tr));
265
+ const score = scoreCorpus(cases);
266
+ if (jsonMode) console.log(JSON.stringify({ eval: resolve(evalPath), ...score }, null, 2));
267
+ else {
268
+ console.log(`routing eval of frozen corpus (${resolve(evalPath)})`);
269
+ console.log(`cases=${score.total} flagged=${score.flagged_cases} clean=${score.clean_pct}%`);
270
+ for (const k of FLAG_KEYS) console.log(`- ${k.padEnd(20)} ${score.by_flag[k]}`);
271
+ }
272
+ process.exit(0);
273
+ }
274
+
275
+ const corpus = buildCorpus(readRows(tracePath), { limit, sinceTs, agentFilter });
276
+ if (savePath) {
277
+ writeFileSync(resolve(savePath), JSON.stringify(corpus, null, 2));
278
+ console.error(`saved ${corpus.emitted} cases → ${resolve(savePath)}`);
279
+ }
280
+ if (jsonMode) console.log(JSON.stringify(corpus, null, 2));
281
+ else console.log(renderText(corpus, true));