mixdog 0.9.2 → 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 (154) hide show
  1. package/package.json +2 -1
  2. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  3. package/scripts/build-tui.mjs +13 -1
  4. package/scripts/explore-bench.mjs +124 -0
  5. package/scripts/hook-bus-test.mjs +191 -0
  6. package/scripts/path-suffix-test.mjs +57 -0
  7. package/scripts/recall-bench.mjs +207 -0
  8. package/scripts/tool-smoke.mjs +7 -4
  9. package/src/agents/debugger/AGENT.md +2 -2
  10. package/src/agents/heavy-worker/AGENT.md +20 -11
  11. package/src/agents/reviewer/AGENT.md +2 -2
  12. package/src/agents/worker/AGENT.md +17 -11
  13. package/src/mixdog-session-runtime.mjs +424 -1812
  14. package/src/repl.mjs +5 -5
  15. package/src/rules/agent/30-explorer.md +8 -11
  16. package/src/rules/lead/lead-tool.md +9 -5
  17. package/src/rules/shared/01-tool.md +11 -5
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +51 -0
  19. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  20. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +1 -1
  21. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -68
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +46 -7
  24. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +1 -13
  25. package/src/runtime/agent/orchestrator/providers/gemini.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  27. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  28. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  29. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -12
  30. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +7 -5
  31. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +33 -23
  32. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -14
  33. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +38 -12
  34. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +7 -8
  35. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  36. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  37. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  38. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  39. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  40. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  41. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  42. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  43. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  44. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  45. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  46. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  47. package/src/runtime/agent/orchestrator/session/loop.mjs +169 -918
  48. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  49. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  50. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  51. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  52. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +65 -1032
  54. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -3
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +2 -2
  56. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  62. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +5 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  64. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -0
  65. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  69. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  73. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  74. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  75. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  76. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  77. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  78. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  79. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +36 -4277
  80. package/src/runtime/agent/orchestrator/tools/patch.mjs +3 -3
  81. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  82. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  83. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  84. package/src/runtime/channels/index.mjs +14 -233
  85. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  86. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  87. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  88. package/src/runtime/channels/lib/output-forwarder.mjs +8 -0
  89. package/src/runtime/channels/lib/telegram-format.mjs +19 -22
  90. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  91. package/src/runtime/memory/index.mjs +314 -359
  92. package/src/runtime/memory/lib/core-memory-store.mjs +351 -1
  93. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  94. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  95. package/src/runtime/memory/lib/memory-cycle2.mjs +56 -3
  96. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  97. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  98. package/src/runtime/memory/lib/memory.mjs +20 -0
  99. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  100. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  101. package/src/runtime/memory/tool-defs.mjs +4 -4
  102. package/src/runtime/shared/abort-controller.mjs +1 -1
  103. package/src/runtime/shared/background-tasks.mjs +2 -3
  104. package/src/runtime/shared/buffered-appender.mjs +149 -0
  105. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  106. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  107. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  108. package/src/runtime/shared/transcript-writer.mjs +29 -2
  109. package/src/session-runtime/config-helpers.mjs +209 -0
  110. package/src/session-runtime/effort.mjs +128 -0
  111. package/src/session-runtime/fs-utils.mjs +10 -0
  112. package/src/session-runtime/model-capabilities.mjs +130 -0
  113. package/src/session-runtime/output-styles.mjs +124 -0
  114. package/src/session-runtime/plugin-mcp.mjs +114 -0
  115. package/src/session-runtime/session-text.mjs +100 -0
  116. package/src/session-runtime/statusline-route.mjs +35 -0
  117. package/src/session-runtime/tool-catalog.mjs +720 -0
  118. package/src/session-runtime/workflow.mjs +358 -0
  119. package/src/standalone/agent-tool.mjs +49 -10
  120. package/src/standalone/channel-worker.mjs +3 -2
  121. package/src/standalone/explore-tool.mjs +10 -3
  122. package/src/standalone/hook-bus.mjs +165 -8
  123. package/src/standalone/opencode-go-login.mjs +121 -0
  124. package/src/standalone/provider-admin.mjs +14 -3
  125. package/src/tui/App.jsx +884 -143
  126. package/src/tui/components/PromptInput.jsx +272 -15
  127. package/src/tui/components/ToolExecution.jsx +20 -10
  128. package/src/tui/components/tool-output-format.mjs +2 -2
  129. package/src/tui/dist/index.mjs +1771 -1947
  130. package/src/tui/engine/agent-envelope.mjs +296 -0
  131. package/src/tui/engine/boot-profile.mjs +21 -0
  132. package/src/tui/engine/labels.mjs +67 -0
  133. package/src/tui/engine/notice-text.mjs +112 -0
  134. package/src/tui/engine/queue-helpers.mjs +161 -0
  135. package/src/tui/engine/session-stats.mjs +46 -0
  136. package/src/tui/engine/tool-call-fields.mjs +23 -0
  137. package/src/tui/engine/tool-result-text.mjs +126 -0
  138. package/src/tui/engine.mjs +311 -851
  139. package/src/tui/input-editing.mjs +58 -8
  140. package/src/tui/input-editing.selection.test.mjs +75 -0
  141. package/src/tui/keyboard-protocol.mjs +2 -2
  142. package/src/tui/lib/voice-recorder.mjs +35 -19
  143. package/src/tui/markdown/format-token.mjs +7 -8
  144. package/src/tui/markdown/format-token.test.mjs +3 -3
  145. package/src/tui/paste-attachments.mjs +38 -0
  146. package/src/tui/paste-fix.test.mjs +119 -0
  147. package/src/tui/themes/base.mjs +2 -2
  148. package/src/tui/themes/kanagawa.mjs +4 -4
  149. package/src/tui/themes/teal.mjs +4 -5
  150. package/src/tui/themes/utils.mjs +1 -1
  151. package/src/ui/statusline.mjs +49 -0
  152. package/src/workflows/default/WORKFLOW.md +16 -9
  153. package/src/workflows/sequential/WORKFLOW.md +16 -11
  154. package/src/workflows/solo/WORKFLOW.md +5 -1
@@ -14,8 +14,14 @@
14
14
  * `mixdog: transcript-writer: `) and suppress duplicates so a broken path
15
15
  * cannot spam the terminal.
16
16
  */
17
- import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs';
17
+ import { existsSync, mkdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
18
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;
19
25
 
20
26
  // Byte-identical to cwdToProjectSlug() in
21
27
  // src/runtime/channels/lib/session-discovery.mjs. Inlined to avoid a
@@ -56,10 +62,31 @@ export function createTranscriptWriter({ mixdogHome, sessionId, cwd, pid } = {})
56
62
  }
57
63
  }
58
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
+
59
85
  function appendLine(entry) {
60
86
  ensureProjectDir();
87
+ rotateIfNeeded();
61
88
  try {
62
- appendFileSync(transcriptPath, `${JSON.stringify(entry)}\n`);
89
+ appendBuffered(transcriptPath, `${JSON.stringify(entry)}\n`);
63
90
  } catch (err) {
64
91
  logOnce(err);
65
92
  }
@@ -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
+ }
@@ -0,0 +1,128 @@
1
+ // Reasoning-effort catalogs and coercion. Pure helpers.
2
+ import { clean } from './session-text.mjs';
3
+
4
+ export const TOOL_MODES = new Set(['full', 'readonly', 'lead']);
5
+ export const ALL_EFFORT_LEVELS = new Set(['none', 'low', 'medium', 'high', 'xhigh', 'max']);
6
+ export const EFFORT_LABELS = {
7
+ none: 'None',
8
+ low: 'Low',
9
+ medium: 'Medium',
10
+ high: 'High',
11
+ xhigh: 'Extra High',
12
+ max: 'Max',
13
+ };
14
+
15
+ export const EFFORT_OPTIONS_BY_PROVIDER = {
16
+ openai: ['none', 'low', 'medium', 'high', 'xhigh'],
17
+ 'openai-oauth': ['none', 'low', 'medium', 'high', 'xhigh'],
18
+ anthropic: ['low', 'medium', 'high', 'xhigh', 'max'],
19
+ 'anthropic-oauth': ['low', 'medium', 'high', 'xhigh', 'max'],
20
+ xai: ['none', 'low', 'medium', 'high'],
21
+ 'grok-oauth': ['none', 'low', 'medium', 'high'],
22
+ 'opencode-go': ['high', 'max'],
23
+ };
24
+ export const EFFORT_BY_FAMILY = {
25
+ opus: ['low', 'medium', 'high', 'xhigh', 'max'],
26
+ sonnet: ['low', 'medium', 'high'],
27
+ haiku: [],
28
+ 'gpt-5.5': ['none', 'low', 'medium', 'high', 'xhigh'],
29
+ 'gpt-5.4': ['none', 'low', 'medium', 'high', 'xhigh'],
30
+ 'gpt-5.2': ['none', 'low', 'medium', 'high', 'xhigh'],
31
+ 'gpt-5': ['none', 'low', 'medium', 'high', 'xhigh'],
32
+ 'gpt-mini': ['none', 'low', 'medium', 'high', 'xhigh'],
33
+ 'gpt-nano': ['none', 'low', 'medium', 'high'],
34
+ 'gpt-codex': ['none', 'low', 'medium', 'high'],
35
+ grok: ['none', 'low', 'medium', 'high'],
36
+ };
37
+ export const EFFORT_FALLBACKS = {
38
+ max: ['max', 'xhigh', 'high', 'medium', 'low'],
39
+ xhigh: ['xhigh', 'high', 'medium', 'low'],
40
+ high: ['high', 'medium', 'low'],
41
+ medium: ['medium', 'low'],
42
+ low: ['low'],
43
+ none: ['none'],
44
+ };
45
+
46
+ export function normalizeToolMode(mode) {
47
+ const value = String(mode || '').trim().toLowerCase();
48
+ return TOOL_MODES.has(value) ? value : 'full';
49
+ }
50
+
51
+ export function normalizeEffortInput(value) {
52
+ const v = clean(value).toLowerCase();
53
+ if (!v || v === 'auto') return null;
54
+ if (!ALL_EFFORT_LEVELS.has(v)) {
55
+ throw new Error(`effort must be one of auto, ${[...ALL_EFFORT_LEVELS].join(', ')}`);
56
+ }
57
+ return v;
58
+ }
59
+
60
+ export function effortOptionsFor(provider, model) {
61
+ const providerAllowed = EFFORT_OPTIONS_BY_PROVIDER[provider] || null;
62
+ const filterProvider = (values) => {
63
+ const unique = [...new Set((values || []).map(clean).filter(Boolean))];
64
+ return providerAllowed ? unique.filter((v) => providerAllowed.includes(v)) : unique;
65
+ };
66
+ const declared = Array.isArray(model?.reasoningLevels)
67
+ ? model.reasoningLevels.map(clean).filter(Boolean)
68
+ : [];
69
+ const family = clean(model?.family).toLowerCase();
70
+ if (Array.isArray(model?.reasoningLevels)) {
71
+ if (declared.length) return filterProvider(declared);
72
+ if (Object.prototype.hasOwnProperty.call(EFFORT_BY_FAMILY, family)) {
73
+ return filterProvider(EFFORT_BY_FAMILY[family]);
74
+ }
75
+ return [];
76
+ }
77
+ const reasoningOptionEffort = Array.isArray(model?.reasoningOptions)
78
+ ? model.reasoningOptions.find((option) => clean(option?.type).toLowerCase() === 'effort')
79
+ : null;
80
+ const reasoningOptionValues = Array.isArray(reasoningOptionEffort?.values)
81
+ ? reasoningOptionEffort.values.map(clean).filter(Boolean)
82
+ : [];
83
+ if (reasoningOptionValues.length) return filterProvider(reasoningOptionValues);
84
+ if (Object.prototype.hasOwnProperty.call(EFFORT_BY_FAMILY, family)) {
85
+ return filterProvider(EFFORT_BY_FAMILY[family]);
86
+ }
87
+ return providerAllowed || [];
88
+ }
89
+
90
+ export function coerceEffortFor(provider, model, effort) {
91
+ if (!effort) return null;
92
+ const allowed = effortOptionsFor(provider, model);
93
+ if (!allowed || allowed.length === 0) return null;
94
+ if (allowed.includes(effort)) return effort;
95
+ for (const candidate of EFFORT_FALLBACKS[effort] || []) {
96
+ if (allowed.includes(candidate)) return candidate;
97
+ }
98
+ return null;
99
+ }
100
+
101
+ export function normalizeSavedEffort(value) {
102
+ try {
103
+ return normalizeEffortInput(value);
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ export function effortItemsFor(provider, model, activeEffort) {
110
+ const allowed = effortOptionsFor(provider, model);
111
+ const items = [];
112
+ for (const value of allowed || []) {
113
+ items.push({
114
+ value,
115
+ label: EFFORT_LABELS[value] || value,
116
+ description: value === activeEffort ? 'current' : '',
117
+ });
118
+ }
119
+ return items;
120
+ }
121
+
122
+ export function toolSpecForMode(mode) {
123
+ return mode === 'readonly' ? ['tools:readonly'] : 'full';
124
+ }
125
+
126
+ export function deferredSurfaceModeForLead(mode) {
127
+ return mode === 'readonly' ? 'readonly' : 'lead';
128
+ }
@@ -0,0 +1,10 @@
1
+ // Small filesystem read helpers used by session-runtime submodules.
2
+ import { readFileSync } from 'node:fs';
3
+
4
+ export function readJsonSafe(path) {
5
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
6
+ }
7
+
8
+ export function readTextSafe(path) {
9
+ try { return readFileSync(path, 'utf8').trim(); } catch { return ''; }
10
+ }
@@ -0,0 +1,130 @@
1
+ // Provider/model capability probes: fast-tier + hosted web-search support, and
2
+ // model-settings persistence. Pure except saveModelSettings (takes cfgMod).
3
+ import { clean, hasOwn } from './session-text.mjs';
4
+
5
+ const FAST_CAPABLE_PROVIDERS = new Set(['anthropic', 'anthropic-oauth', 'openai', 'openai-oauth']);
6
+ export const LAZY_SECRET_PROVIDERS = new Set(['openai-oauth', 'anthropic-oauth', 'grok-oauth', 'ollama', 'lmstudio']);
7
+
8
+ export function routeFastKey(provider, model) {
9
+ const p = clean(provider);
10
+ const m = clean(model);
11
+ return p && m ? `${p}/${m}` : '';
12
+ }
13
+
14
+ function openAiModelMetaSupportsFast(model) {
15
+ const tiers = Array.isArray(model?.serviceTiers) ? model.serviceTiers : [];
16
+ const speedTiers = Array.isArray(model?.additionalSpeedTiers) ? model.additionalSpeedTiers : [];
17
+ if (tiers.length || speedTiers.length || model?.defaultServiceTier) {
18
+ return tiers.some((tier) => tier?.id === 'priority')
19
+ || speedTiers.includes('priority')
20
+ || model?.defaultServiceTier === 'priority';
21
+ }
22
+ const id = clean(model?.id || model).toLowerCase();
23
+ if (id.includes('mini') || id.includes('nano') || id.includes('codex')) return false;
24
+ return /^gpt-5(\.|-|$)/.test(id);
25
+ }
26
+
27
+ function openAiDirectModelSupportsFast(model) {
28
+ const id = clean(model?.id || model);
29
+ return /^gpt-5\.5(?:-\d{4}|$)/.test(id)
30
+ || /^gpt-5\.4(?:-\d{4}|$)/.test(id)
31
+ || /^gpt-5\.4-mini(?:-\d{4}|$)/.test(id);
32
+ }
33
+
34
+ function openAiModelSupportsHostedWebSearch(model) {
35
+ const id = clean(model?.id || model).toLowerCase();
36
+ if (!id) return false;
37
+ if (model?.supportsWebSearch === true) return true;
38
+ const tools = [
39
+ ...(Array.isArray(model?.supportedTools) ? model.supportedTools : []),
40
+ ...(Array.isArray(model?.tools) ? model.tools : []),
41
+ ...(Array.isArray(model?.capabilities?.tools) ? model.capabilities.tools : []),
42
+ ].map((tool) => clean(tool?.type || tool?.name || tool).toLowerCase());
43
+ if (tools.some((tool) => tool === 'web_search' || tool === 'web_search_preview')) return true;
44
+ if (/codex|image|audio|tts|stt|embedding|rerank|moderation|search-preview/.test(id)) return false;
45
+ return /^gpt-(5(?:\.|$|-)|4\.1(?:-|$)|4o(?:-|$)|4\.5(?:-|$))/.test(id)
46
+ || /^o[34](?:-|$)/.test(id);
47
+ }
48
+
49
+ function grokModelSupportsHostedWebSearch(model) {
50
+ const id = clean(model?.id || model).toLowerCase();
51
+ if (!id || /imagine|image|video|composer/.test(id)) return false;
52
+ if (id === 'grok-build') return false;
53
+ return /^grok-/.test(id);
54
+ }
55
+
56
+ function geminiModelSupportsHostedWebSearch(model) {
57
+ const id = clean(model?.id || model).toLowerCase();
58
+ if (!id || /embedding|aqa|imagen|veo|tts|image|computer-use|customtools/.test(id)) return false;
59
+ return /^gemini-(3(?:\.|-|$)|2\.5-|2\.0-flash)/.test(id);
60
+ }
61
+
62
+ function anthropicModelSupportsHostedWebSearch(model) {
63
+ const id = clean(model?.id || model).toLowerCase();
64
+ if (!id) return false;
65
+ const match = id.match(/^claude-(opus|sonnet|haiku)-(\d+)(?:[-.](\d+))?/);
66
+ if (!match) return false;
67
+ const major = Number(match[2]) || 0;
68
+ const minor = Number(match[3]) || 0;
69
+ return major > 4 || (major === 4 && minor >= 0);
70
+ }
71
+
72
+ function anthropicModelMetaSupportsFast(model) {
73
+ const id = clean(model?.id || model).toLowerCase();
74
+ return /^claude-(opus|sonnet)/.test(id);
75
+ }
76
+
77
+ export function fastCapableFor(provider, model) {
78
+ const p = clean(provider);
79
+ if (!FAST_CAPABLE_PROVIDERS.has(p)) return false;
80
+ if (p === 'openai') return openAiDirectModelSupportsFast(model);
81
+ if (p === 'openai-oauth') return openAiModelMetaSupportsFast(model);
82
+ if (p === 'anthropic' || p === 'anthropic-oauth') return anthropicModelMetaSupportsFast(model);
83
+ return false;
84
+ }
85
+
86
+ // searchCapableFor needs the search-route normalizers, which live in
87
+ // search-routes.mjs and themselves are pure. Wire them in via a factory to keep
88
+ // this module free of a circular import at load time.
89
+ export function makeSearchCapableFor(normalizeSearchProviderId, isSearchCapableProvider) {
90
+ return function searchCapableFor(provider, model) {
91
+ const p = normalizeSearchProviderId(provider);
92
+ if (!isSearchCapableProvider(p)) return false;
93
+ if (p === 'openai' || p === 'openai-oauth') return openAiModelSupportsHostedWebSearch(model);
94
+ if (p === 'grok-oauth' || p === 'xai') return grokModelSupportsHostedWebSearch(model);
95
+ if (p === 'gemini') return geminiModelSupportsHostedWebSearch(model);
96
+ if (p === 'anthropic' || p === 'anthropic-oauth') return anthropicModelSupportsHostedWebSearch(model);
97
+ return model?.supportsWebSearch === true;
98
+ };
99
+ }
100
+
101
+ export function fastPreferenceFor(config, provider, model) {
102
+ const key = routeFastKey(provider, model);
103
+ if (!key) return false;
104
+ const saved = config?.modelSettings?.[key];
105
+ if (saved && typeof saved === 'object' && hasOwn(saved, 'fast')) return saved.fast === true;
106
+ return config?.fastModels?.[key] === true;
107
+ }
108
+
109
+ export function saveModelSettings(cfgMod, route, { fastCapable = true, baseConfig = null } = {}) {
110
+ const key = routeFastKey(route?.provider, route?.model);
111
+ if (!key) return baseConfig || cfgMod.loadConfig();
112
+ const nextConfig = baseConfig || cfgMod.loadConfig();
113
+ const modelSettings = { ...(nextConfig.modelSettings || {}) };
114
+ const nextSetting = { ...(modelSettings[key] || {}) };
115
+ if (hasOwn(route, 'effort') && route.effort) nextSetting.effort = route.effort;
116
+ else delete nextSetting.effort;
117
+ if (fastCapable) nextSetting.fast = route.fast === true;
118
+ else nextSetting.fast = false;
119
+ modelSettings[key] = nextSetting;
120
+
121
+ // Legacy compatibility: keep fastModels true entries for old readers, but
122
+ // let modelSettings.fast=false override them in new readers.
123
+ const fastModels = { ...(nextConfig.fastModels || {}) };
124
+ if (nextSetting.fast === true) fastModels[key] = true;
125
+ else delete fastModels[key];
126
+
127
+ const savedConfig = { ...nextConfig, modelSettings, fastModels };
128
+ cfgMod.saveConfig(savedConfig);
129
+ return savedConfig;
130
+ }
@@ -0,0 +1,124 @@
1
+ // Output-style catalog + metadata parsing. Roots are injected so this module
2
+ // stays free of the runtime's path constants.
3
+ import { basename, join } from 'node:path';
4
+ import { readFileSync, readdirSync } from 'node:fs';
5
+ import { clean } from './session-text.mjs';
6
+ import { readJsonSafe } from './fs-utils.mjs';
7
+
8
+ const OUTPUT_STYLE_ORDER = ['default', 'simple', 'minimal', 'oneline'];
9
+ const OUTPUT_STYLE_ALIASES = new Map([
10
+ ['compact', 'default'],
11
+ ['normal', 'default'],
12
+ ['extreme', 'minimal'],
13
+ ['extremesimple', 'minimal'],
14
+ ['extreme-simple', 'minimal'],
15
+ ['extreme_simple', 'minimal'],
16
+ ['mono', 'oneline'],
17
+ ['oneline', 'oneline'],
18
+ ['one-line', 'oneline'],
19
+ ['one_line', 'oneline'],
20
+ ]);
21
+
22
+ export function normalizeOutputStyleId(value) {
23
+ const raw = clean(value).toLowerCase();
24
+ if (!raw) return '';
25
+ const slug = raw.replace(/[_\s]+/g, '-').replace(/^-+|-+$/g, '');
26
+ const compact = slug.replace(/[_.-]+/g, '');
27
+ if (OUTPUT_STYLE_ALIASES.has(slug)) return OUTPUT_STYLE_ALIASES.get(slug);
28
+ if (OUTPUT_STYLE_ALIASES.has(compact)) return OUTPUT_STYLE_ALIASES.get(compact);
29
+ return /^[a-z0-9.-]+$/.test(slug) ? slug : '';
30
+ }
31
+
32
+ function outputStyleCompactKey(value) {
33
+ return normalizeOutputStyleId(value).replace(/[_.-]+/g, '');
34
+ }
35
+
36
+ function titleCaseOutputStyle(id) {
37
+ return clean(id)
38
+ .split(/[_.-]+/)
39
+ .filter(Boolean)
40
+ .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
41
+ .join(' ') || 'Default';
42
+ }
43
+
44
+ function parseOutputStyleFrontmatter(markdown) {
45
+ const match = String(markdown || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
46
+ const meta = {};
47
+ if (!match) return meta;
48
+ for (const line of match[1].split(/\r?\n/)) {
49
+ const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/);
50
+ if (!kv) continue;
51
+ meta[kv[1]] = kv[2].replace(/^['"]|['"]$/g, '').trim();
52
+ }
53
+ return meta;
54
+ }
55
+
56
+ function readOutputStyleMetadata(filePath, source) {
57
+ let raw = '';
58
+ try { raw = readFileSync(filePath, 'utf8'); } catch { return null; }
59
+ const meta = parseOutputStyleFrontmatter(raw);
60
+ const fileId = normalizeOutputStyleId(basename(filePath).replace(/\.md$/i, ''));
61
+ const id = normalizeOutputStyleId(meta.name) || fileId;
62
+ if (!id) return null;
63
+ const aliases = clean(meta.aliases)
64
+ .split(',')
65
+ .map((value) => normalizeOutputStyleId(value))
66
+ .filter(Boolean);
67
+ const label = clean(meta.title || meta.label) || titleCaseOutputStyle(id);
68
+ return {
69
+ id,
70
+ label,
71
+ description: clean(meta.description),
72
+ aliases,
73
+ source,
74
+ };
75
+ }
76
+
77
+ export function listOutputStyleCatalog(rootDir, dataDir) {
78
+ const byId = new Map();
79
+ const dirs = [
80
+ { dir: join(rootDir, 'output-styles'), source: 'builtin' },
81
+ { dir: join(dataDir, 'output-styles'), source: 'user' },
82
+ ];
83
+ for (const { dir, source } of dirs) {
84
+ let entries = [];
85
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
86
+ for (const entry of entries) {
87
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.md')) continue;
88
+ const style = readOutputStyleMetadata(join(dir, entry.name), source);
89
+ if (style) byId.set(style.id, style);
90
+ }
91
+ }
92
+ return [...byId.values()].sort((a, b) => {
93
+ const ai = OUTPUT_STYLE_ORDER.indexOf(a.id);
94
+ const bi = OUTPUT_STYLE_ORDER.indexOf(b.id);
95
+ if (ai !== bi) return (ai < 0 ? 999 : ai) - (bi < 0 ? 999 : bi);
96
+ return a.label.localeCompare(b.label, 'en', { sensitivity: 'base' });
97
+ });
98
+ }
99
+
100
+ export function findOutputStyle(value, styles) {
101
+ const id = normalizeOutputStyleId(value);
102
+ const compact = outputStyleCompactKey(value);
103
+ if (!id && !compact) return null;
104
+ return (styles || []).find((style) => {
105
+ if (style.id === id || outputStyleCompactKey(style.id) === compact) return true;
106
+ if (outputStyleCompactKey(style.label) === compact) return true;
107
+ return (style.aliases || []).some((alias) => alias === id || outputStyleCompactKey(alias) === compact);
108
+ }) || null;
109
+ }
110
+
111
+ function configuredOutputStyleValue(dataDir) {
112
+ const unified = readJsonSafe(join(dataDir, 'mixdog-config.json')) || {};
113
+ return clean(unified.outputStyle || (unified.agent && unified.agent.outputStyle) || 'default') || 'default';
114
+ }
115
+
116
+ export function outputStyleStatus(rootDir, dataDir) {
117
+ const styles = listOutputStyleCatalog(rootDir, dataDir);
118
+ const configured = configuredOutputStyleValue(dataDir);
119
+ const current = findOutputStyle(configured, styles)
120
+ || findOutputStyle('default', styles)
121
+ || styles[0]
122
+ || { id: 'default', label: 'Default', description: '', aliases: [], source: 'builtin' };
123
+ return { configured, current, styles };
124
+ }