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,156 @@
1
+ /**
2
+ * Transcript writer for remote (Discord) mode.
3
+ *
4
+ * The channel worker's OutputForwarder tails a newline-delimited JSON
5
+ * transcript and forwards the "surface" view (assistant text + one-line tool
6
+ * summaries) to Discord. Nothing wrote that file for standalone sessions, so
7
+ * remote outbound never worked. This module writes the JSONL in the exact
8
+ * schema the forwarder parses (see channels/lib/output-forwarder.mjs
9
+ * extractNewText) plus a session record the forwarder's discovery reads
10
+ * (channels/lib/session-discovery.mjs readSessionRecord).
11
+ *
12
+ * All writes are best-effort: a failure must never break the ask() turn. We
13
+ * log the FIRST occurrence of each distinct failure string to stderr (prefix
14
+ * `mixdog: transcript-writer: `) and suppress duplicates so a broken path
15
+ * cannot spam the terminal.
16
+ */
17
+ import { existsSync, mkdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
18
+ import { dirname, join, resolve } from 'node:path';
19
+ import { appendBuffered, drainPathSync, hasInFlightWrite } from './buffered-appender.mjs';
20
+
21
+ // Rotate the JSONL transcript once it exceeds this size, keeping one prior
22
+ // generation (`<path>.1`). Checked on each append; the check itself is a
23
+ // cheap statSync so no extra timer/interval is needed.
24
+ const TRANSCRIPT_ROTATE_BYTES = 10 * 1024 * 1024;
25
+
26
+ // Byte-identical to cwdToProjectSlug() in
27
+ // src/runtime/channels/lib/session-discovery.mjs. Inlined to avoid a
28
+ // shared -> channels import coupling; keep the two in sync if either changes.
29
+ function cwdToProjectSlug(cwd) {
30
+ return resolve(cwd).replace(/\\/g, '/').replace(/^([A-Za-z]):/, '$1-').replace(/\//g, '-');
31
+ }
32
+
33
+ export function createTranscriptWriter({ mixdogHome, sessionId, cwd, pid } = {}) {
34
+ if (!mixdogHome) throw new Error('transcript-writer: mixdogHome is required');
35
+ if (!sessionId) throw new Error('transcript-writer: sessionId is required');
36
+ if (!cwd) throw new Error('transcript-writer: cwd is required');
37
+
38
+ const resolvedCwd = resolve(cwd);
39
+ const projectDir = join(mixdogHome, 'projects', cwdToProjectSlug(resolvedCwd));
40
+ const transcriptPath = join(projectDir, `${sessionId}.jsonl`);
41
+ const sessionRecordPath = join(mixdogHome, 'sessions', `${pid ?? process.pid}.json`);
42
+ const startedAt = Date.now();
43
+
44
+ // Repeated-failure guard: log the first time each distinct message is seen,
45
+ // then stay quiet so a persistently-broken path does not flood stderr.
46
+ const seenErrors = new Set();
47
+ function logOnce(err) {
48
+ const msg = err && err.message ? err.message : String(err);
49
+ if (seenErrors.has(msg)) return;
50
+ seenErrors.add(msg);
51
+ try { process.stderr.write(`mixdog: transcript-writer: ${msg}\n`); } catch { /* stderr broken */ }
52
+ }
53
+
54
+ let projectDirReady = false;
55
+ function ensureProjectDir() {
56
+ if (projectDirReady) return;
57
+ try {
58
+ mkdirSync(projectDir, { recursive: true });
59
+ projectDirReady = true;
60
+ } catch (err) {
61
+ logOnce(err);
62
+ }
63
+ }
64
+
65
+ function rotateIfNeeded() {
66
+ try {
67
+ if (!existsSync(transcriptPath)) return;
68
+ const { size } = statSync(transcriptPath);
69
+ if (size < TRANSCRIPT_ROTATE_BYTES) return;
70
+ // An async appendFile may be in flight for this path; renaming out
71
+ // from under it races the write on Windows. Skip this round and
72
+ // retry rotation on the next append instead.
73
+ if (hasInFlightWrite(transcriptPath)) return;
74
+ // Force any still-buffered bytes onto disk before renaming, so the
75
+ // rotated-out file ends with everything queued for it and the fresh
76
+ // file post-rename doesn't inherit stale in-memory chunks.
77
+ drainPathSync(transcriptPath);
78
+ const rotatedPath = `${transcriptPath}.1`;
79
+ try { renameSync(transcriptPath, rotatedPath); } catch (err) { logOnce(err); }
80
+ } catch (err) {
81
+ logOnce(err);
82
+ }
83
+ }
84
+
85
+ function appendLine(entry) {
86
+ ensureProjectDir();
87
+ rotateIfNeeded();
88
+ try {
89
+ appendBuffered(transcriptPath, `${JSON.stringify(entry)}\n`);
90
+ } catch (err) {
91
+ logOnce(err);
92
+ }
93
+ }
94
+
95
+ function writeSessionRecord() {
96
+ try {
97
+ mkdirSync(dirname(sessionRecordPath), { recursive: true });
98
+ writeFileSync(sessionRecordPath, JSON.stringify({
99
+ sessionId,
100
+ cwd: resolvedCwd,
101
+ transcriptPath,
102
+ startedAt,
103
+ updatedAt: Date.now(),
104
+ kind: 'interactive',
105
+ entrypoint: 'cli',
106
+ }));
107
+ } catch (err) {
108
+ logOnce(err);
109
+ }
110
+ }
111
+
112
+ // Refresh only the session record's updatedAt so discovery keeps ranking
113
+ // this session as live across long-lived remote sessions.
114
+ function refresh() {
115
+ writeSessionRecord();
116
+ }
117
+
118
+ function appendAssistant(text) {
119
+ const value = typeof text === 'string' ? text : (text == null ? '' : String(text));
120
+ if (!value.trim()) return;
121
+ appendLine({
122
+ type: 'assistant',
123
+ sessionId,
124
+ message: { content: [{ type: 'text', text: value }] },
125
+ });
126
+ }
127
+
128
+ function appendToolUse(name, input) {
129
+ if (!name) return;
130
+ appendLine({
131
+ type: 'assistant',
132
+ sessionId,
133
+ message: { content: [{ type: 'tool_use', name, input: input || {} }] },
134
+ });
135
+ }
136
+
137
+ function appendToolResult(toolUseResult) {
138
+ if (!toolUseResult) return;
139
+ appendLine({
140
+ type: 'user',
141
+ sessionId,
142
+ message: { content: [{ type: 'tool_result' }] },
143
+ toolUseResult,
144
+ });
145
+ }
146
+
147
+ return {
148
+ transcriptPath,
149
+ sessionRecordPath,
150
+ writeSessionRecord,
151
+ refresh,
152
+ appendAssistant,
153
+ appendToolUse,
154
+ appendToolResult,
155
+ };
156
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * update-checker.mjs — npm registry version check + self-update for the
3
+ * globally-installed `mixdog` CLI package.
4
+ *
5
+ * checkLatestVersion({force}) hits the npm registry for the `latest`
6
+ * dist-tag, compares against the local package.json version, and caches
7
+ * the result on disk (TTL 24h) so repeated calls (e.g. on every TUI mount)
8
+ * don't hammer the registry. Network/parse failures are silent — this is a
9
+ * best-effort convenience check, never a boot-blocking dependency.
10
+ *
11
+ * runGlobalUpdate() shells out to `npm install -g mixdog@latest` in the
12
+ * background (windowsHide so no console flash on Windows) and resolves once
13
+ * the child exits, reporting the resolved version on success.
14
+ */
15
+
16
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { spawn } from 'node:child_process';
20
+ import { resolvePluginData } from './plugin-paths.mjs';
21
+
22
+ const PACKAGE_NAME = 'mixdog';
23
+ const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
24
+ const FETCH_TIMEOUT_MS = 5_000;
25
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
26
+ const CACHE_FILE_NAME = 'update-check-cache.json';
27
+
28
+ // Resolve the local package.json shipped alongside src/ (two levels up from
29
+ // src/runtime/shared/).
30
+ const _MODULE_DIR = dirname(fileURLToPath(import.meta.url));
31
+ const _PACKAGE_JSON_PATH = join(_MODULE_DIR, '..', '..', '..', 'package.json');
32
+
33
+ let _localVersionCache = null;
34
+ export function localPackageVersion() {
35
+ if (_localVersionCache) return _localVersionCache;
36
+ try {
37
+ const raw = JSON.parse(readFileSync(_PACKAGE_JSON_PATH, 'utf8'));
38
+ _localVersionCache = String(raw?.version || '0.0.0');
39
+ } catch {
40
+ _localVersionCache = '0.0.0';
41
+ }
42
+ return _localVersionCache;
43
+ }
44
+
45
+ function cacheFilePath(dataDir) {
46
+ const dir = dataDir || resolvePluginData();
47
+ return join(dir, CACHE_FILE_NAME);
48
+ }
49
+
50
+ function readCache(dataDir) {
51
+ try {
52
+ const raw = JSON.parse(readFileSync(cacheFilePath(dataDir), 'utf8'));
53
+ if (!raw || typeof raw !== 'object') return null;
54
+ return raw;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function writeCache(dataDir, payload) {
61
+ try {
62
+ const dir = dataDir || resolvePluginData();
63
+ mkdirSync(dir, { recursive: true });
64
+ writeFileSync(cacheFilePath(dataDir), JSON.stringify(payload), 'utf8');
65
+ } catch {
66
+ // Best-effort — cache miss just means the next call re-fetches.
67
+ }
68
+ }
69
+
70
+ /** Parse a semver-ish string into { parts:[maj,min,patch], prerelease }. */
71
+ function parseSemver(value) {
72
+ const text = String(value || '').trim().replace(/^v/i, '');
73
+ const [core, ...preParts] = text.split('-');
74
+ const parts = core.split('.').map((n) => {
75
+ const num = Number.parseInt(n, 10);
76
+ return Number.isFinite(num) ? num : 0;
77
+ });
78
+ while (parts.length < 3) parts.push(0);
79
+ return { parts, prerelease: preParts.join('-') };
80
+ }
81
+
82
+ /**
83
+ * Compare two semver strings. Returns positive if `a` > `b`, negative if
84
+ * `a` < `b`, 0 if equal. A version with a prerelease tag is considered
85
+ * lower than the same core version without one (standard semver rule).
86
+ */
87
+ export function compareSemver(a, b) {
88
+ const pa = parseSemver(a);
89
+ const pb = parseSemver(b);
90
+ for (let i = 0; i < 3; i++) {
91
+ const diff = (pa.parts[i] || 0) - (pb.parts[i] || 0);
92
+ if (diff !== 0) return diff;
93
+ }
94
+ if (pa.prerelease && !pb.prerelease) return -1;
95
+ if (!pa.prerelease && pb.prerelease) return 1;
96
+ if (pa.prerelease !== pb.prerelease) return pa.prerelease < pb.prerelease ? -1 : 1;
97
+ return 0;
98
+ }
99
+
100
+ export function isNewerVersion(latest, current) {
101
+ if (!latest || !current) return false;
102
+ return compareSemver(latest, current) > 0;
103
+ }
104
+
105
+ async function fetchLatestFromRegistry() {
106
+ const controller = new AbortController();
107
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
108
+ try {
109
+ const res = await fetch(REGISTRY_URL, { signal: controller.signal });
110
+ if (!res.ok) return null;
111
+ const body = await res.json();
112
+ const version = body && typeof body === 'object' ? String(body.version || '').trim() : '';
113
+ return version || null;
114
+ } catch {
115
+ return null;
116
+ } finally {
117
+ clearTimeout(timer);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * checkLatestVersion({force, dataDir}) — resolve the latest published
123
+ * version of the `mixdog` npm package.
124
+ *
125
+ * Returns:
126
+ * { currentVersion, latestVersion, updateAvailable, lastCheckedAt, fromCache }
127
+ * `latestVersion` is null when the registry could not be reached and no
128
+ * usable cache exists — this call never throws.
129
+ */
130
+ export async function checkLatestVersion({ force = false, dataDir } = {}) {
131
+ const currentVersion = localPackageVersion();
132
+ const cache = force ? null : readCache(dataDir);
133
+ const now = Date.now();
134
+ if (cache && typeof cache.lastCheckedAt === 'number' && now - cache.lastCheckedAt < CACHE_TTL_MS) {
135
+ return {
136
+ currentVersion,
137
+ latestVersion: cache.latestVersion || null,
138
+ updateAvailable: isNewerVersion(cache.latestVersion, currentVersion),
139
+ lastCheckedAt: cache.lastCheckedAt,
140
+ fromCache: true,
141
+ };
142
+ }
143
+ const latestVersion = await fetchLatestFromRegistry();
144
+ if (latestVersion) {
145
+ const payload = { latestVersion, lastCheckedAt: now };
146
+ writeCache(dataDir, payload);
147
+ return {
148
+ currentVersion,
149
+ latestVersion,
150
+ updateAvailable: isNewerVersion(latestVersion, currentVersion),
151
+ lastCheckedAt: now,
152
+ fromCache: false,
153
+ };
154
+ }
155
+ // Registry unreachable: fall back to a stale cache (if any) rather than
156
+ // reporting "no update info" every time the network hiccups.
157
+ if (cache && cache.latestVersion) {
158
+ return {
159
+ currentVersion,
160
+ latestVersion: cache.latestVersion,
161
+ updateAvailable: isNewerVersion(cache.latestVersion, currentVersion),
162
+ lastCheckedAt: cache.lastCheckedAt || now,
163
+ fromCache: true,
164
+ };
165
+ }
166
+ return {
167
+ currentVersion,
168
+ latestVersion: null,
169
+ updateAvailable: false,
170
+ lastCheckedAt: now,
171
+ fromCache: false,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * runGlobalUpdate() — `npm install -g mixdog@latest` in a background child
177
+ * process (windowsHide so no console flash on Windows). Resolves once the
178
+ * child exits; never throws — failures come back as {ok:false, error}.
179
+ */
180
+ export function runGlobalUpdate() {
181
+ return new Promise((resolvePromise) => {
182
+ let child;
183
+ try {
184
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
185
+ child = spawn(npmCmd, ['install', '-g', `${PACKAGE_NAME}@latest`], {
186
+ stdio: 'ignore',
187
+ windowsHide: true,
188
+ shell: process.platform === 'win32',
189
+ });
190
+ } catch (err) {
191
+ resolvePromise({ ok: false, error: err?.message || String(err) });
192
+ return;
193
+ }
194
+ child.once('error', (err) => {
195
+ resolvePromise({ ok: false, error: err?.message || String(err) });
196
+ });
197
+ // Detach from the event-loop keep-alive so a boot-time auto-update can
198
+ // never hold the TUI/node process open after the user quits; the exit
199
+ // listener still fires as long as the process is otherwise alive.
200
+ child.unref?.();
201
+ child.once('exit', async (code) => {
202
+ if (code === 0) {
203
+ // Best-effort: report whichever version the registry now reports as
204
+ // latest (the just-installed one), falling back to 'unknown'.
205
+ const version = await fetchLatestFromRegistry();
206
+ resolvePromise({ ok: true, version: version || 'unknown' });
207
+ } else {
208
+ resolvePromise({ ok: false, error: `npm install exited with code ${code}` });
209
+ }
210
+ });
211
+ });
212
+ }
213
+
214
+ export { PACKAGE_NAME as UPDATE_PACKAGE_NAME };
@@ -0,0 +1,209 @@
1
+ // Config normalization helpers: default provider, presets/routes, module
2
+ // enable flags, shell/auto-clear/compaction config, and duration formatting.
3
+ import { clean, hasOwn } from './session-text.mjs';
4
+ import { normalizeEffortInput, normalizeSavedEffort } from './effort.mjs';
5
+ import { routeFastKey, fastPreferenceFor } from './model-capabilities.mjs';
6
+
7
+ export const DEFAULT_PROVIDER = 'anthropic-oauth';
8
+ export const DEFAULT_MODEL = '';
9
+
10
+ // Resolve the provider to use when a route carries no explicit provider.
11
+ // Priority: config.defaultProvider (when it names a known provider) > DEFAULT_PROVIDER.
12
+ export function makeResolveDefaultProvider(isKnownProvider) {
13
+ return function resolveDefaultProvider(config) {
14
+ const configured = clean(config?.defaultProvider);
15
+ if (configured && isKnownProvider(configured)) return configured;
16
+ return DEFAULT_PROVIDER;
17
+ };
18
+ }
19
+
20
+ export function modelSettingsFor(config, provider, model) {
21
+ const key = routeFastKey(provider, model);
22
+ const value = key ? config?.modelSettings?.[key] : null;
23
+ return value && typeof value === 'object' ? value : {};
24
+ }
25
+
26
+ export function findPreset(config, key) {
27
+ const wanted = clean(key).toLowerCase();
28
+ if (!wanted) return null;
29
+ const presets = Array.isArray(config?.presets) ? config.presets : [];
30
+ return presets.find((p) => {
31
+ const id = clean(p?.id).toLowerCase();
32
+ const name = clean(p?.name).toLowerCase();
33
+ return id === wanted || name === wanted;
34
+ }) || null;
35
+ }
36
+
37
+ export function makeResolveRoute(resolveDefaultProvider) {
38
+ return function resolveRoute(config, { provider, model, effort, fast } = {}) {
39
+ const explicitProvider = clean(provider);
40
+ const explicitModel = clean(model);
41
+ const hasExplicitEffort = effort !== undefined;
42
+ const explicitEffort = hasExplicitEffort ? normalizeEffortInput(effort) : undefined;
43
+ const hasExplicitFast = fast !== undefined;
44
+ const explicitFast = fast === true;
45
+
46
+ if (explicitModel && !explicitProvider) {
47
+ const preset = findPreset(config, explicitModel);
48
+ if (preset) {
49
+ const p = clean(preset.provider) || DEFAULT_PROVIDER;
50
+ const m = clean(preset.model) || DEFAULT_MODEL;
51
+ const saved = modelSettingsFor(config, p, m);
52
+ return {
53
+ provider: p,
54
+ model: m,
55
+ preset,
56
+ effort: hasExplicitEffort ? explicitEffort : normalizeSavedEffort(saved.effort ?? preset.effort),
57
+ fast: hasExplicitFast ? explicitFast : (hasOwn(saved, 'fast') ? saved.fast === true : (preset.fast === true || fastPreferenceFor(config, p, m))),
58
+ };
59
+ }
60
+ }
61
+
62
+ if (!explicitProvider && !explicitModel) {
63
+ const defaultKey = config?.default;
64
+ const preset = findPreset(config, defaultKey);
65
+ if (preset) {
66
+ const p = clean(preset.provider) || DEFAULT_PROVIDER;
67
+ const m = clean(preset.model) || DEFAULT_MODEL;
68
+ const saved = modelSettingsFor(config, p, m);
69
+ return {
70
+ provider: p,
71
+ model: m,
72
+ preset,
73
+ effort: hasExplicitEffort ? explicitEffort : normalizeSavedEffort(saved.effort ?? preset.effort),
74
+ fast: hasExplicitFast ? explicitFast : (hasOwn(saved, 'fast') ? saved.fast === true : (preset.fast === true || fastPreferenceFor(config, p, m))),
75
+ };
76
+ }
77
+ }
78
+
79
+ const p = explicitProvider || resolveDefaultProvider(config);
80
+ const m = explicitModel || DEFAULT_MODEL;
81
+ const saved = modelSettingsFor(config, p, m);
82
+ return {
83
+ provider: p,
84
+ model: m,
85
+ preset: null,
86
+ effort: hasExplicitEffort ? explicitEffort : normalizeSavedEffort(saved.effort),
87
+ fast: hasExplicitFast ? explicitFast : (hasOwn(saved, 'fast') ? saved.fast === true : fastPreferenceFor(config, p, m)),
88
+ };
89
+ };
90
+ }
91
+
92
+ export function isLikelyRawModelId(value) {
93
+ const model = clean(value);
94
+ if (!model || model.length > 160) return false;
95
+ if (/\s/.test(model)) return false;
96
+ return /^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$/.test(model);
97
+ }
98
+
99
+ export function validateRequestedModelSelector(config, requested = {}) {
100
+ const model = clean(requested.model);
101
+ if (!model) return;
102
+ if (findPreset(config, model)) return;
103
+ if (isLikelyRawModelId(model)) return;
104
+ throw new Error(`Invalid model selector "${model}". Use a preset or a model id; free-form text cannot be used as a model.`);
105
+ }
106
+
107
+ export function ensureProviderEnabled(config, provider) {
108
+ const providers = { ...(config?.providers || {}) };
109
+ providers[provider] = { ...(providers[provider] || {}), enabled: true };
110
+ return providers;
111
+ }
112
+
113
+ const AUTO_CLEAR_DEFAULT_IDLE_MS = 60 * 60 * 1000;
114
+
115
+ export function normalizeSystemShellConfig(value = {}) {
116
+ const raw = value && typeof value === 'object' ? value : {};
117
+ const command = clean(raw.command ?? raw.path ?? raw.executable ?? raw.shell);
118
+ const envCommand = clean(process.env.MIXDOG_SHELL);
119
+ return {
120
+ command,
121
+ effective: command || envCommand || '',
122
+ source: command ? 'config' : (envCommand ? 'env' : 'auto'),
123
+ };
124
+ }
125
+
126
+ export function normalizeSystemShellCommand(value) {
127
+ const command = clean(value).replace(/^auto$/i, '').replace(/^['"](.+)['"]$/, '$1').trim();
128
+ if (!command) return '';
129
+ if (process.platform === 'win32') {
130
+ const stem = command.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '');
131
+ if (stem !== 'powershell' && stem !== 'pwsh') {
132
+ throw new Error('system shell command must be powershell.exe or pwsh on Windows');
133
+ }
134
+ }
135
+ return command;
136
+ }
137
+
138
+ export function normalizeAutoClearConfig(value = {}) {
139
+ const raw = value && typeof value === 'object' ? value : {};
140
+ const idleMs = Number(raw.idleMs ?? raw.thresholdMs ?? raw.idleMillis);
141
+ return {
142
+ enabled: raw.enabled !== false,
143
+ idleMs: Number.isFinite(idleMs) && idleMs > 0 ? Math.max(60_000, Math.round(idleMs)) : AUTO_CLEAR_DEFAULT_IDLE_MS,
144
+ };
145
+ }
146
+
147
+ export function normalizeCompactTypeSetting(value, fallback = 'semantic') {
148
+ const raw = clean(value).toLowerCase().replace(/_/g, '-');
149
+ if (!raw) return fallback;
150
+ if (raw === '1' || raw === 'type1' || raw === 'type-1' || raw === 'semantic' || raw === 'summary' || raw === 'default') return 'semantic';
151
+ if (raw === '2' || raw === 'type2' || raw === 'type-2' || raw === 'recall' || raw === 'recall-fast' || raw === 'recall-fasttrack' || raw === 'recall-fast-track' || raw === 'fasttrack' || raw === 'fast-track') return 'recall-fasttrack';
152
+ return fallback;
153
+ }
154
+
155
+ export function normalizeCompactionConfig(value = {}, { memoryEnabled = true } = {}) {
156
+ const raw = value && typeof value === 'object' ? value : {};
157
+ let compactType = normalizeCompactTypeSetting(raw.compactType ?? raw.compact_type ?? raw.type, 'semantic');
158
+ if (compactType === 'recall-fasttrack' && memoryEnabled === false) compactType = 'semantic';
159
+ return {
160
+ ...raw,
161
+ auto: raw.auto !== false && raw.enabled !== false,
162
+ type: compactType,
163
+ compactType,
164
+ };
165
+ }
166
+
167
+ export function moduleEnabled(configLike, name, fallback = true) {
168
+ const entry = configLike?.modules?.[name];
169
+ if (entry && typeof entry === 'object' && entry.enabled === false) return false;
170
+ return fallback !== false;
171
+ }
172
+
173
+ export function setModuleEnabledInConfig(configLike, name, enabled) {
174
+ const next = { ...(configLike || {}) };
175
+ next.modules = { ...(next.modules || {}) };
176
+ next.modules[name] = {
177
+ ...(next.modules[name] || {}),
178
+ enabled: enabled !== false,
179
+ };
180
+ return next;
181
+ }
182
+
183
+ export function formatDurationMs(ms) {
184
+ const value = Math.max(0, Number(ms) || 0);
185
+ if (value % 3_600_000 === 0) return `${value / 3_600_000}h`;
186
+ if (value % 60_000 === 0) return `${value / 60_000}m`;
187
+ return `${Math.round(value / 1000)}s`;
188
+ }
189
+
190
+ export function parseDurationMs(input) {
191
+ const text = clean(input).toLowerCase();
192
+ if (!text) return null;
193
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(text);
194
+ if (!match) return null;
195
+ const n = Number(match[1]);
196
+ if (!Number.isFinite(n) || n <= 0) return null;
197
+ const unit = match[2] || 'm';
198
+ const mult = unit === 'h' ? 3_600_000 : unit === 'm' ? 60_000 : unit === 's' ? 1000 : 1;
199
+ return Math.max(60_000, Math.round(n * mult));
200
+ }
201
+
202
+ // A resolved model meta carries catalog-derived fields (contextWindow, pricing,
203
+ // capabilities, …). The lookupModelMeta() fallback for an unknown id is the
204
+ // bare shape `{ id, provider }`, so "more than id/provider" reliably tells a
205
+ // real catalog hit apart from that placeholder.
206
+ export function modelMetaLooksResolved(meta) {
207
+ if (!meta || typeof meta !== 'object') return false;
208
+ return Object.keys(meta).some((key) => key !== 'id' && key !== 'provider');
209
+ }