mixdog 0.9.1 → 0.9.2

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 (214) hide show
  1. package/package.json +8 -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/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/task-bench.mjs +207 -0
  23. package/scripts/tool-failures.mjs +6 -6
  24. package/scripts/tool-smoke.mjs +306 -66
  25. package/scripts/toolcall-args-test.mjs +81 -0
  26. package/src/agents/debugger/AGENT.md +4 -4
  27. package/src/agents/heavy-worker/AGENT.md +4 -2
  28. package/src/agents/reviewer/AGENT.md +4 -4
  29. package/src/agents/worker/AGENT.md +4 -2
  30. package/src/app.mjs +10 -6
  31. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  32. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  33. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  34. package/src/headless-role.mjs +14 -14
  35. package/src/help.mjs +1 -0
  36. package/src/lib/rules-builder.cjs +32 -54
  37. package/src/mixdog-session-runtime.mjs +710 -318
  38. package/src/output-styles/default.md +12 -7
  39. package/src/output-styles/minimal.md +25 -0
  40. package/src/output-styles/oneline.md +21 -0
  41. package/src/output-styles/simple.md +10 -9
  42. package/src/repl.mjs +12 -2
  43. package/src/rules/agent/00-common.md +7 -5
  44. package/src/rules/agent/30-explorer.md +7 -8
  45. package/src/rules/lead/01-general.md +3 -1
  46. package/src/rules/lead/lead-tool.md +7 -0
  47. package/src/rules/shared/01-tool.md +17 -12
  48. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  50. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  51. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  52. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  53. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  54. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  55. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  56. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  57. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  58. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  59. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  60. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  61. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  62. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +332 -57
  63. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +59 -32
  64. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  65. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  66. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  67. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  68. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  69. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +78 -3
  70. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +202 -98
  71. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +183 -20
  72. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  73. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  74. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  75. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -9
  76. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  77. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  78. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  80. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  81. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  82. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  83. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  84. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  85. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  86. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  87. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  88. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  90. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  91. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  92. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  93. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  94. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  95. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  96. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  97. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  99. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  100. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  101. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  102. package/src/runtime/channels/backends/discord.mjs +99 -9
  103. package/src/runtime/channels/backends/telegram.mjs +501 -0
  104. package/src/runtime/channels/index.mjs +441 -1224
  105. package/src/runtime/channels/lib/config.mjs +54 -2
  106. package/src/runtime/channels/lib/format.mjs +4 -2
  107. package/src/runtime/channels/lib/output-forwarder.mjs +80 -67
  108. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  109. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  110. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  111. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  112. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  113. package/src/runtime/channels/lib/webhook.mjs +59 -31
  114. package/src/runtime/channels/tool-defs.mjs +1 -1
  115. package/src/runtime/memory/index.mjs +184 -19
  116. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  117. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  118. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  119. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  120. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  121. package/src/runtime/memory/lib/memory.mjs +101 -4
  122. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  123. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  124. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  125. package/src/runtime/memory/tool-defs.mjs +6 -3
  126. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  127. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  128. package/src/runtime/shared/config.mjs +9 -0
  129. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  130. package/src/runtime/shared/schedules-store.mjs +21 -19
  131. package/src/runtime/shared/tool-surface.mjs +98 -13
  132. package/src/runtime/shared/transcript-writer.mjs +129 -0
  133. package/src/runtime/shared/update-checker.mjs +214 -0
  134. package/src/standalone/agent-tool.mjs +255 -109
  135. package/src/standalone/channel-admin.mjs +133 -40
  136. package/src/standalone/channel-worker.mjs +8 -291
  137. package/src/standalone/explore-tool.mjs +2 -2
  138. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  139. package/src/standalone/provider-admin.mjs +11 -0
  140. package/src/standalone/usage-dashboard.mjs +1 -1
  141. package/src/tui/App.jsx +2096 -732
  142. package/src/tui/components/ConfirmBar.jsx +47 -0
  143. package/src/tui/components/ContextPanel.jsx +5 -3
  144. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  145. package/src/tui/components/Markdown.jsx +22 -98
  146. package/src/tui/components/Message.jsx +14 -35
  147. package/src/tui/components/Picker.jsx +87 -12
  148. package/src/tui/components/PromptInput.jsx +83 -7
  149. package/src/tui/components/QueuedCommands.jsx +1 -1
  150. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  151. package/src/tui/components/Spinner.jsx +7 -7
  152. package/src/tui/components/StatusLine.jsx +40 -21
  153. package/src/tui/components/TextEntryPanel.jsx +51 -7
  154. package/src/tui/components/ToolExecution.jsx +170 -98
  155. package/src/tui/components/TurnDone.jsx +4 -4
  156. package/src/tui/components/UsagePanel.jsx +1 -1
  157. package/src/tui/components/tool-output-format.mjs +159 -21
  158. package/src/tui/components/tool-output-format.test.mjs +87 -0
  159. package/src/tui/display-width.mjs +69 -0
  160. package/src/tui/display-width.test.mjs +35 -0
  161. package/src/tui/dist/index.mjs +6965 -2391
  162. package/src/tui/engine.mjs +287 -126
  163. package/src/tui/index.jsx +117 -7
  164. package/src/tui/keyboard-protocol.mjs +42 -0
  165. package/src/tui/lib/voice-recorder.mjs +453 -0
  166. package/src/tui/markdown/format-token.mjs +129 -76
  167. package/src/tui/markdown/format-token.test.mjs +61 -19
  168. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  169. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  170. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  171. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  172. package/src/tui/markdown/table-layout.mjs +9 -9
  173. package/src/tui/paste-attachments.mjs +0 -11
  174. package/src/tui/prompt-history-store.mjs +129 -0
  175. package/src/tui/prompt-history-store.test.mjs +52 -0
  176. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  177. package/src/tui/theme.mjs +41 -657
  178. package/src/tui/themes/base.mjs +86 -0
  179. package/src/tui/themes/basic.mjs +85 -0
  180. package/src/tui/themes/catppuccin.mjs +72 -0
  181. package/src/tui/themes/dracula.mjs +70 -0
  182. package/src/tui/themes/everforest.mjs +71 -0
  183. package/src/tui/themes/gruvbox.mjs +71 -0
  184. package/src/tui/themes/index.mjs +71 -0
  185. package/src/tui/themes/indigo.mjs +78 -0
  186. package/src/tui/themes/kanagawa.mjs +80 -0
  187. package/src/tui/themes/light.mjs +81 -0
  188. package/src/tui/themes/nord.mjs +72 -0
  189. package/src/tui/themes/onedark.mjs +16 -0
  190. package/src/tui/themes/rosepine.mjs +70 -0
  191. package/src/tui/themes/teal.mjs +81 -0
  192. package/src/tui/themes/tokyonight.mjs +79 -0
  193. package/src/tui/themes/utils.mjs +106 -0
  194. package/src/tui/themes/warm.mjs +79 -0
  195. package/src/tui/transcript-tool-failures.mjs +13 -2
  196. package/src/ui/markdown.mjs +1 -1
  197. package/src/ui/model-display.mjs +2 -2
  198. package/src/ui/statusline.mjs +26 -27
  199. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  200. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  201. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  202. package/src/workflows/default/WORKFLOW.md +39 -12
  203. package/src/workflows/sequential/WORKFLOW.md +46 -0
  204. package/src/workflows/solo/WORKFLOW.md +7 -0
  205. package/vendor/ink/build/display-width.js +62 -0
  206. package/vendor/ink/build/ink.js +100 -12
  207. package/vendor/ink/build/measure-text.js +4 -1
  208. package/vendor/ink/build/output.js +115 -9
  209. package/vendor/ink/build/render-node-to-output.js +4 -1
  210. package/vendor/ink/build/render.js +4 -0
  211. package/src/output-styles/extreme-simple.md +0 -20
  212. package/src/rules/lead/04-workflow.md +0 -51
  213. package/src/workflows/default/workflow.json +0 -13
  214. package/src/workflows/solo/workflow.json +0 -7
@@ -0,0 +1,143 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { validateBuiltinArgs } from './arg-guard.mjs';
4
+
5
+ test('read: numeric-string offset/limit coerce losslessly', () => {
6
+ const a = { path: 'x.js', offset: '850', limit: '850.0' };
7
+ assert.equal(validateBuiltinArgs('read', a), null);
8
+ assert.equal(a.offset, 850);
9
+ assert.equal(a.limit, 850);
10
+ });
11
+
12
+ test('read: non-integer numeric string still rejected', () => {
13
+ const a = { path: 'x.js', offset: '3.5' };
14
+ assert.match(validateBuiltinArgs('read', a), /must be a finite integer/);
15
+ });
16
+
17
+ test('read: non-numeric string still rejected', () => {
18
+ const a = { path: 'x.js', offset: 'soon' };
19
+ assert.match(validateBuiltinArgs('read', a), /must be a finite integer/);
20
+ });
21
+
22
+ test('read: top-level line/context convert to offset/limit', () => {
23
+ const a = { path: 'x.js', line: 100, context: 10 };
24
+ assert.equal(validateBuiltinArgs('read', a), null);
25
+ assert.equal(a.offset, 89);
26
+ assert.equal(a.limit, 21);
27
+ assert.equal('line' in a, false);
28
+ assert.equal('context' in a, false);
29
+ });
30
+
31
+ test('read: line without context defaults to a single-line window', () => {
32
+ const a = { path: 'x.js', line: 1 };
33
+ assert.equal(validateBuiltinArgs('read', a), null);
34
+ assert.equal(a.offset, 0);
35
+ assert.equal(a.limit, 1);
36
+ });
37
+
38
+ test('read: explicit offset/limit wins over line/context (dropped)', () => {
39
+ const a = { path: 'x.js', line: 100, context: 10, offset: 5, limit: 20 };
40
+ assert.equal(validateBuiltinArgs('read', a), null);
41
+ assert.equal(a.offset, 5);
42
+ assert.equal(a.limit, 20);
43
+ assert.equal('line' in a, false);
44
+ assert.equal('context' in a, false);
45
+ });
46
+
47
+ test('read: path[] entry object line/context converts the same way', () => {
48
+ const a = { path: [{ path: 'x.js', line: 50, context: 5 }] };
49
+ assert.equal(validateBuiltinArgs('read', a), null);
50
+ assert.equal(a.path[0].offset, 44);
51
+ assert.equal(a.path[0].limit, 11);
52
+ });
53
+
54
+ test('read: path[] entry negative numeric-string offset is rejected after coercion', () => {
55
+ const a = { path: [{ path: 'x.js', offset: '-5' }] };
56
+ const err = validateBuiltinArgs('read', a);
57
+ assert.match(err, /path\[0\]\.offset/);
58
+ assert.match(err, />= 0/);
59
+ });
60
+
61
+ test('read: path[] entry negative numeric-string limit is rejected after coercion', () => {
62
+ const a = { path: [{ path: 'x.js', limit: '-1' }] };
63
+ const err = validateBuiltinArgs('read', a);
64
+ assert.match(err, /path\[0\]\.limit/);
65
+ assert.match(err, />= 0/);
66
+ });
67
+
68
+ test('read: path[] entry valid numeric-string offset/limit still coerces and passes', () => {
69
+ const a = { path: [{ path: 'x.js', offset: '10', limit: '5' }] };
70
+ assert.equal(validateBuiltinArgs('read', a), null);
71
+ assert.equal(a.path[0].offset, 10);
72
+ assert.equal(a.path[0].limit, 5);
73
+ });
74
+
75
+ test('read: JSON object string path is accepted as a single-entry batch', () => {
76
+ const a = { path: '{"path":"x.js","offset":10,"limit":5}' };
77
+ assert.equal(validateBuiltinArgs('read', a), null);
78
+ assert.equal(Array.isArray(a.path), true);
79
+ assert.equal(a.path[0].path, 'x.js');
80
+ assert.equal(a.path[0].offset, 10);
81
+ });
82
+
83
+ test('shell: win32 drive path with no shell set defaults to powershell', (t) => {
84
+ if (process.platform !== 'win32') { t.skip('win32 only'); return; }
85
+ const a = { command: 'dir C:\\Project\\mixdog' };
86
+ assert.equal(validateBuiltinArgs('shell', a), null);
87
+ assert.equal(a.shell, 'powershell');
88
+ });
89
+
90
+ test('glob: missing pattern with a path defaults pattern to "*"', () => {
91
+ const a = { path: 'src' };
92
+ assert.equal(validateBuiltinArgs('glob', a), null);
93
+ assert.equal(a.pattern, '*');
94
+ });
95
+
96
+ test('glob: path carrying glob magic is left for the path-magic fallback, not overridden', () => {
97
+ const a = { path: 'src/**/*.mjs' };
98
+ assert.equal(validateBuiltinArgs('glob', a), null);
99
+ assert.equal('pattern' in a, false);
100
+ assert.equal(a.path, 'src/**/*.mjs');
101
+ });
102
+
103
+ test('glob: missing pattern and path is still an error surface (guard passes through)', () => {
104
+ const a = {};
105
+ assert.equal(validateBuiltinArgs('glob', a), null);
106
+ assert.equal('pattern' in a, false);
107
+ });
108
+
109
+ test('grep: output_mode with newline-concatenated debris truncates to valid enum', () => {
110
+ const a = { pattern: 'foo', output_mode: 'content_with_context\ntrue' };
111
+ assert.equal(validateBuiltinArgs('grep', a), null);
112
+ assert.equal(a.output_mode, 'content_with_context');
113
+ });
114
+
115
+ test('grep: trailing literal \\n artifact stripped from pattern', () => {
116
+ const a = { pattern: 'foo bar\\n' };
117
+ assert.equal(validateBuiltinArgs('grep', a), null);
118
+ assert.equal(a.pattern, 'foo bar');
119
+ });
120
+
121
+ test('grep: trailing concatenation artifact ">\\n" stripped from pattern', () => {
122
+ const a = { pattern: 'foo bar">\n' };
123
+ assert.equal(validateBuiltinArgs('grep', a), null);
124
+ assert.equal(a.pattern, 'foo bar');
125
+ });
126
+
127
+ test('grep: mid-pattern \\n is left untouched', () => {
128
+ const a = { pattern: 'foo\\nbar' };
129
+ assert.equal(validateBuiltinArgs('grep', a), null);
130
+ assert.equal(a.pattern, 'foo\\nbar');
131
+ });
132
+
133
+ test('grep: bare trailing ">" with no newline artifact survives untouched', () => {
134
+ const a = { pattern: 'class="active">' };
135
+ assert.equal(validateBuiltinArgs('grep', a), null);
136
+ assert.equal(a.pattern, 'class="active">');
137
+ });
138
+
139
+ test('grep: numeric-string -C context coerces losslessly', () => {
140
+ const a = { pattern: 'foo', '-C': '5' };
141
+ assert.equal(validateBuiltinArgs('grep', a), null);
142
+ assert.equal(a['-C'], 5);
143
+ });
@@ -17,7 +17,7 @@ export const BUILTIN_TOOLS = [
17
17
  name: 'read',
18
18
  title: 'Mixdog Read',
19
19
  annotations: { title: 'Mixdog Read', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false },
20
- description: 'Read known file path(s). Use line+context for small windows. Batch paths.',
20
+ description: 'Read known file path(s). Prefer grep content_with_context or code_graph anchors first. Window with numeric offset+limit only. Batch paths/regions as real arrays. Dirs use list.',
21
21
  inputSchema: {
22
22
  type: 'object',
23
23
  properties: {
@@ -26,14 +26,26 @@ export const BUILTIN_TOOLS = [
26
26
  { type: 'string' },
27
27
  {
28
28
  type: 'array',
29
- items: { type: 'string' },
29
+ items: {
30
+ anyOf: [
31
+ { type: 'string' },
32
+ {
33
+ type: 'object',
34
+ properties: {
35
+ path: { type: 'string' },
36
+ offset: { type: 'number', minimum: 0 },
37
+ limit: { type: 'number', minimum: 1 },
38
+ },
39
+ },
40
+ ],
41
+ },
30
42
  minItems: 1,
31
43
  },
32
44
  ],
33
- description: 'File path or array. Dirs use list.',
45
+ description: 'File path, path[], or {path,offset,limit}[] region objects. Pass arrays directly; JSON strings are legacy recovery only. Dirs use list.',
34
46
  },
35
- line: { type: 'number', minimum: 1, description: 'Anchor line.' },
36
- context: { type: 'number', minimum: 0, maximum: 200, description: 'Lines around line; max 200.' },
47
+ offset: { type: 'number', minimum: 0, description: 'Numeric lines to skip before reading; 0 starts at line 1. Continue with offset:N.' },
48
+ limit: { type: 'number', minimum: 1, description: 'Numeric max lines to return after offset.' },
37
49
  },
38
50
  },
39
51
  },
@@ -53,7 +65,10 @@ export const BUILTIN_TOOLS = [
53
65
  {
54
66
  name: 'open_config',
55
67
  title: 'Open Config UI',
56
- annotations: { title: 'Open Config UI', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
68
+ // agentHidden: a worker/reviewer session has no business popping the
69
+ // settings UI on the user's machine; it also wastes schema bytes on
70
+ // every agent request. Lead keeps it.
71
+ annotations: { title: 'Open Config UI', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, agentHidden: true },
57
72
  description: 'Open settings UI; returns URL. No params.',
58
73
  inputSchema: { type: 'object', properties: {}, additionalProperties: false },
59
74
  },
@@ -61,7 +76,7 @@ export const BUILTIN_TOOLS = [
61
76
  name: 'shell',
62
77
  title: 'Mixdog Shell',
63
78
  annotations: { title: 'Mixdog Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
64
- description: `Run shell for git/build/test/run. Not for source browsing; use code_graph/grep/read/list/glob. Set shell: powershell or bash. ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
79
+ description: `Run shell to CHANGE state or RUN programs (git/build/test/run). Never to inspect the filesystem — reading, listing, searching, or checking existence go through the dedicated tools, never a shell command. Set shell: powershell or bash. ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
65
80
  inputSchema: {
66
81
  type: 'object',
67
82
  properties: {
@@ -101,7 +116,7 @@ export const BUILTIN_TOOLS = [
101
116
  name: 'grep',
102
117
  title: 'Mixdog Grep',
103
118
  annotations: { title: 'Mixdog Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
104
- description: 'Search text/logs/regex.',
119
+ description: 'Search file contents by text/regex in a known scope. Use files_with_matches/count for broad anchors, content_with_context for narrow code answers. One concept → one grep.',
105
120
  inputSchema: {
106
121
  type: 'object',
107
122
  properties: {
@@ -110,19 +125,20 @@ export const BUILTIN_TOOLS = [
110
125
  { type: 'string' },
111
126
  { type: 'array', items: { type: 'string' }, minItems: 1 },
112
127
  ],
113
- description: 'Text/regex. Array = OR.',
128
+ description: 'Text/regex. Put synonyms in pattern[] as OR in ONE grep; no serial rewording or equivalent repeats.',
114
129
  },
115
- path: { type: 'string', description: 'File or directory.' },
130
+ path: { type: 'string', description: 'Known narrowest file/dir; broad scopes return paths first, then refine from returned paths.' },
116
131
  glob: {
117
132
  anyOf: [
118
133
  { type: 'string' },
119
134
  { type: 'array', items: { type: 'string' }, minItems: 1 },
120
135
  ],
121
- description: 'Optional narrowing glob(s).',
136
+ description: 'Narrow in same grep; no follow-up grep for equivalent scope changes.',
122
137
  },
123
- output_mode: { type: 'string', enum: ['files_with_matches', 'content', 'count'], description: 'Output mode.' },
124
- head_limit: { type: 'number', description: 'Max lines.' },
125
- offset: { type: 'number', description: 'Skip lines.' },
138
+ output_mode: { type: 'string', enum: ['content_with_context', 'content', 'files_with_matches', 'count'], description: 'Broad scope: files_with_matches/count. Narrow scope: content_with_context; answer from it, skip read unless span is not shown.' },
139
+ context: { type: 'number', minimum: 0, description: 'Lines before/after each match; keep bounded.' },
140
+ head_limit: { type: 'number', minimum: 0, description: 'Max output lines; keep small.' },
141
+ offset: { type: 'number', minimum: 0, description: 'Skip output lines for paging.' },
126
142
  },
127
143
  required: [],
128
144
  },
@@ -131,7 +147,7 @@ export const BUILTIN_TOOLS = [
131
147
  name: 'glob',
132
148
  title: 'Mixdog Glob',
133
149
  annotations: { title: 'Mixdog Glob', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
134
- description: 'Find files by glob.',
150
+ description: 'Find files by exact glob. Unknown path/name uses find.',
135
151
  inputSchema: {
136
152
  type: 'object',
137
153
  properties: {
@@ -140,7 +156,7 @@ export const BUILTIN_TOOLS = [
140
156
  { type: 'string' },
141
157
  { type: 'array', items: { type: 'string' }, minItems: 1 },
142
158
  ],
143
- description: 'Glob pattern(s).',
159
+ description: 'Exact glob pattern(s).',
144
160
  },
145
161
  path: {
146
162
  anyOf: [
@@ -159,11 +175,11 @@ export const BUILTIN_TOOLS = [
159
175
  name: 'find',
160
176
  title: 'Mixdog Find Files',
161
177
  annotations: { title: 'Mixdog Find Files', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
162
- description: 'Fuzzy-find files by partial path/name. Returns paths.',
178
+ description: 'Find files by partial path/name. Exact structure uses glob. Returns verified paths.',
163
179
  inputSchema: {
164
180
  type: 'object',
165
181
  properties: {
166
- query: { type: 'string', description: 'Partial path/name words.' },
182
+ query: { type: 'string', description: 'Partial path/name words; not file contents.' },
167
183
  path: { type: 'string', description: 'Base directory.' },
168
184
  head_limit: { type: 'number', description: 'Max paths.' },
169
185
  },
@@ -5,3 +5,13 @@ export function formatMtime(mtimeMs) {
5
5
  if (!mtimeMs) return '-';
6
6
  return new Date(mtimeMs).toISOString().slice(0, 19).replace('T', ' ');
7
7
  }
8
+
9
+ // Human-readable size column for list/find. Directories have no meaningful
10
+ // byte size (render '-'); files show KB so the model can spot a large file
11
+ // before reading it. Sub-1KB files round up to 1KB.
12
+ export function formatListSize(type, bytes) {
13
+ if (type !== 'file') return '-';
14
+ const n = Number(bytes) || 0;
15
+ if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)}MB`;
16
+ return `${Math.max(1, Math.round(n / 1024))}KB`;
17
+ }
@@ -26,7 +26,8 @@ import {
26
26
  NOISE_DIR_NAMES,
27
27
  walkDir,
28
28
  } from './glob-walk.mjs';
29
- import { formatMtime } from './list-formatting.mjs';
29
+ import { formatMtime, formatListSize } from './list-formatting.mjs';
30
+ import { TOOL_OUTPUT_MAX_BYTES } from './tool-output-limit.mjs';
30
31
  import { runRg } from './rg-runner.mjs';
31
32
  import { fuzzyRank } from './fuzzy-match.mjs';
32
33
  import { assertPathReachable } from './fs-reachability.mjs';
@@ -169,7 +170,7 @@ export async function executeListTool(args, workDir, options = {}) {
169
170
  }
170
171
  }
171
172
  const lines = sliced.map(r =>
172
- `${normalizeOutputPath(r.path)}\t${r.type}\t${r.size}\t${formatMtime(r.mtimeMs)}`);
173
+ `${normalizeOutputPath(r.path)}\t${r.type}\t${formatListSize(r.type, r.size)}\t${formatMtime(r.mtimeMs)}`);
173
174
  if (windowed.length > sliced.length) lines.push(`... [entries ${offset + 1}-${offset + sliced.length} of ${rows.length}; pass offset:${offset + sliced.length} to continue]`);
174
175
  if (truncatedByCap) lines.push(`... walk truncated at ${LIST_ABSOLUTE_CAP} rows or ${LIST_WALK_TIMEOUT_MS}ms timeout; narrow the path or lower depth for a complete listing`);
175
176
  let emptyMsg = '(empty directory)';
@@ -282,7 +283,7 @@ export async function executeTreeTool(args, workDir, options = {}) {
282
283
  const totalLabel = body.length >= gatherCap ? `${body.length}+` : `${body.length}`;
283
284
  outLines.push(`... [entries ${offset + 1}-${offset + sliced.length} of ${totalLabel}; pass offset:${offset + sliced.length} to continue]`);
284
285
  }
285
- const TREE_OUTPUT_CHAR_CAP = 50_000;
286
+ const TREE_OUTPUT_CHAR_CAP = TOOL_OUTPUT_MAX_BYTES;
286
287
  let out = outLines.join('\n');
287
288
  let outputCharTruncated = false;
288
289
  if (out.length > TREE_OUTPUT_CHAR_CAP) {
@@ -603,7 +604,7 @@ export async function executeFindFilesTool(args, workDir, options = {}) {
603
604
  const windowed = offset > 0 ? matches.slice(offset) : matches;
604
605
  const sliced = headLimit > 0 ? windowed.slice(0, headLimit) : windowed;
605
606
  const lines = sliced.map(m =>
606
- `${normalizeOutputPath(m.path)}\t${m.size}\t${formatMtime(m.mtimeMs)}`);
607
+ `${normalizeOutputPath(m.path)}\t${formatListSize('file', m.size)}\t${formatMtime(m.mtimeMs)}`);
607
608
  if (windowed.length > sliced.length) lines.push(`... [entries ${offset + 1}-${offset + sliced.length} of ${matches.length}; pass offset:${offset + sliced.length} to continue]`);
608
609
  if (rgStdoutTruncated) lines.push('... [warning] rg stdout truncated at 20MB cap; results incomplete');
609
610
  if (rgStdoutPartial) lines.push('... [warning] rg exit 2 (partial results); listing may be incomplete');
@@ -220,5 +220,20 @@ export function coerceShapeFlex(value) {
220
220
  if (typeof parsed === 'string') return parsed;
221
221
  } catch {}
222
222
  }
223
+ // A single {path,offset,limit}-shaped object JSON string (e.g. a model
224
+ // emitting `path: "{\"path\":\"x\",\"offset\":10}"` instead of a real
225
+ // object) is a lossless single-entry batch. Wrap it in an array so the
226
+ // downstream object-array batch path (read-tool.mjs) picks it up instead
227
+ // of falling through to the plain-string path guard, which would
228
+ // misdetect the raw JSON text as a literal (and often invalid) filename.
229
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
230
+ try {
231
+ const parsed = JSON.parse(trimmed);
232
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)
233
+ && typeof (parsed.path ?? parsed.file_path) === 'string') {
234
+ return [parsed];
235
+ }
236
+ } catch {}
237
+ }
223
238
  return value;
224
239
  }
@@ -1,21 +1,11 @@
1
1
  import { existsSync } from 'fs';
2
2
  import { normalizeInputPath, resolveAgainstCwd } from './path-utils.mjs';
3
- import { parseLineLimitArg } from './read-formatting.mjs';
4
-
5
- const READ_LINE_CONTEXT_DEFAULT = 20;
6
- const READ_LINE_CONTEXT_MAX = 200;
7
3
 
8
4
  export function parseReadLineNumberArg(value) {
9
5
  const n = Number(value);
10
6
  return Number.isFinite(n) && n > 0 ? Math.trunc(n) : null;
11
7
  }
12
8
 
13
- export function parseReadLineContextArg(value) {
14
- const n = Number(value);
15
- if (!Number.isFinite(n)) return READ_LINE_CONTEXT_DEFAULT;
16
- return Math.min(READ_LINE_CONTEXT_MAX, Math.max(0, Math.trunc(n)));
17
- }
18
-
19
9
  export function parseReadPathLineSpec(rawPath) {
20
10
  if (typeof rawPath !== 'string' || rawPath.length === 0) return null;
21
11
  const text = rawPath.trim();
@@ -51,7 +41,6 @@ export function normalizePathAndStripLineCoordinate(rawPath, workDir) {
51
41
 
52
42
  export function normaliseReadLineWindowArgs(inputArgs, workDir) {
53
43
  const args = { ...inputArgs };
54
- let lineNo = parseReadLineNumberArg(args.line);
55
44
  let pathLineRange = null;
56
45
  if (typeof args.path === 'string' && args.path) {
57
46
  const spec = resolveExistingPathLineCoordinate(args.path, workDir);
@@ -61,45 +50,21 @@ export function normaliseReadLineWindowArgs(inputArgs, workDir) {
61
50
  return args;
62
51
  }
63
52
  args.path = spec.path;
64
- if (!lineNo) lineNo = spec.lineNo;
65
53
  if (spec.endLine) pathLineRange = { startLine: spec.lineNo, endLine: spec.endLine };
54
+ else pathLineRange = { startLine: spec.lineNo, endLine: spec.lineNo };
66
55
  }
67
56
  }
68
57
  const isFullMode = !args.mode || args.mode === 'full';
69
- // line= and offset= are ALTERNATIVE window anchors. Silently preferring
70
- // offset used to discard line/context and (with a bare low offset) fall
71
- // through to a whole-file smart-truncate dump — the exact read explosion
72
- // a 7-line `line+context` request was trying to avoid. Make it a hard
73
- // conflict instead.
74
- if (isFullMode && lineNo && args.offset !== undefined && args.offset !== null) {
75
- args._invertedRangeError = `Error: conflicting window args — both line (${lineNo}) and offset (${args.offset}) were given; line/context and offset/limit are alternative window forms, pass only one`;
76
- return args;
77
- }
78
- if (isFullMode && lineNo) {
79
- if (pathLineRange && args.context === undefined && (args.limit === undefined || args.limit === null)) {
80
- args.offset = Math.max(0, pathLineRange.startLine - 1);
58
+ // Public Read args are offset/limit only. Keep a narrow private
59
+ // compatibility path for file#Lx/file:line strings by converting them into
60
+ // offset/limit; do not interpret line/context fields here.
61
+ if (isFullMode && pathLineRange && args.offset === undefined) {
62
+ args.offset = Math.max(0, pathLineRange.startLine - 1);
63
+ if (args.limit === undefined) {
81
64
  args.limit = Math.max(1, pathLineRange.endLine - pathLineRange.startLine + 1);
82
- } else {
83
- const contextExplicit = args.context !== undefined && args.context !== null;
84
- const limitExplicit = args.limit !== undefined && args.limit !== null;
85
- const context = parseReadLineContextArg(args.context);
86
- if (limitExplicit && !contextExplicit) {
87
- // Explicit limit, no explicit context: anchor the window AT the
88
- // requested line so it is always included. (Was: offset centered
89
- // by the default context, which a small limit then truncated to
90
- // exclude the very line the caller asked for.)
91
- args.offset = Math.max(0, lineNo - 1);
92
- args.limit = parseLineLimitArg(args.limit, (context * 2) + 1);
93
- } else {
94
- const limit = limitExplicit
95
- ? parseLineLimitArg(args.limit, (context * 2) + 1)
96
- : (context * 2) + 1;
97
- args.offset = Math.max(0, lineNo - context - 1);
98
- args.limit = limit;
99
- }
100
65
  }
101
- delete args.line;
102
- delete args.context;
103
66
  }
67
+ delete args.line;
68
+ delete args.context;
104
69
  return args;
105
70
  }
@@ -1,4 +1,5 @@
1
1
  import { SMART_READ_MAX_BYTES } from './read-formatting.mjs';
2
+ import { TOOL_OUTPUT_MAX_BYTES } from './tool-output-limit.mjs';
2
3
 
3
4
  // Read tool caps.
4
5
  //
@@ -28,7 +29,7 @@ import { SMART_READ_MAX_BYTES } from './read-formatting.mjs';
28
29
  // cleanly without a tokenizer.
29
30
  export const READ_MAX_SIZE_BYTES = 10 * 1024 * 1024;
30
31
  export const READ_WHOLE_FILE_MAX_BYTES = 256 * 1024;
31
- export const READ_MAX_OUTPUT_BYTES = 30_000;
32
+ export const READ_MAX_OUTPUT_BYTES = TOOL_OUTPUT_MAX_BYTES;
32
33
  export const READ_STREAM_RANGE_MIN_BYTES = 128 * 1024;
33
34
  export const READ_SMART_STREAM_MIN_BYTES = SMART_READ_MAX_BYTES;
34
35
  export const READ_BATCH_RANGE_COALESCE_GAP_LINES = 256;
@@ -1,9 +1,18 @@
1
1
  import { mergeReadRanges } from './read-ranges.mjs';
2
+ import { TOOL_OUTPUT_MAX_BYTES } from './tool-output-limit.mjs';
2
3
 
3
- export const SMART_READ_MAX_BYTES = 30 * 1024;
4
- export const SMART_READ_MAX_LINES = 600;
5
- export const SMART_READ_HEAD_LINES = 200;
6
- export const SMART_READ_TAIL_LINES = 100;
4
+ // Smart-truncate cap: a no-window read returns the file until this cap, past
5
+ // which head+tail are shown and the model pages the rest with offset (footer
6
+ // says how). Byte budget is the shared TOOL_OUTPUT_MAX_BYTES; line/head/tail
7
+ // stay read-specific. Env-overridable for bench: MIXDOG_READ_MAX_LINES/_HEAD/_TAIL.
8
+ function _readEnvInt(name, fallback) {
9
+ const v = parseInt(process.env[name], 10);
10
+ return Number.isFinite(v) && v > 0 ? v : fallback;
11
+ }
12
+ export const SMART_READ_MAX_BYTES = TOOL_OUTPUT_MAX_BYTES;
13
+ export const SMART_READ_MAX_LINES = _readEnvInt('MIXDOG_READ_MAX_LINES', 2000);
14
+ export const SMART_READ_HEAD_LINES = _readEnvInt('MIXDOG_READ_HEAD_LINES', 1200);
15
+ export const SMART_READ_TAIL_LINES = _readEnvInt('MIXDOG_READ_TAIL_LINES', 400);
7
16
  // Only the genuinely large full reads warrant the anti-re-read advisory; below
8
17
  // this the smart-truncate path (30 KB) already caps normal reads, so a 16 KB
9
18
  // floor mostly fired on full:true mid-size reads where the advisory was pure
@@ -49,8 +49,7 @@ function _guardedReadError(p, helpers) {
49
49
  }
50
50
 
51
51
  // Reachability preflight for EVERY read shape (scalar / array / reads[]). MUST
52
- // run before any sync FS — including line-coordinate disambiguation (existsSync
53
- // in readPathStringGuardError / normaliseReadLineWindowArgs) and the image
52
+ // run before any sync FS — including path normalization and the image
54
53
  // stat/read. A dead mount would otherwise freeze the event loop, defeating even
55
54
  // the 630s dispatch ceiling.
56
55
  async function _readReachPreflight(rawPath, workDir, helpers) {
@@ -58,10 +57,9 @@ async function _readReachPreflight(rawPath, workDir, helpers) {
58
57
  normalizeInputPath, resolveAgainstCwd,
59
58
  } = helpers;
60
59
  // A guarded path (UNC/SMB, Windows device, ADS, /dev/* block) must be
61
- // REJECTED here, not skipped: skipping would let the later sync existsSync
62
- // line-coordinate disambiguation (normaliseReadLineWindowArgs /
63
- // readPathStringGuardError) touch it and trigger NTLM/raw-device access or
64
- // hang. Reject up front with the same message the inline guards emit.
60
+ // REJECTED here, not skipped: skipping would let the later sync guard/open
61
+ // path touch it and trigger NTLM/raw-device access or hang. Reject up front
62
+ // with the same message the inline guards emit.
65
63
  // normalizeInputPath FIRST (FS-pure) so we stat the same path the real read
66
64
  // opens (e.g. /mnt/z/... -> Z:\...). Reachability is per-mount/dir, so the
67
65
  // line-coordinate strip only needs to land in the right directory — exact
@@ -113,7 +111,7 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
113
111
  }
114
112
  args.path = coerceShapeFlex(args.path);
115
113
  // Reachability preflight up front (all shapes) — before readPathStringGuardError /
116
- // normaliseReadLineWindowArgs / image stat, all of which can touch sync FS.
114
+ // image stat, all of which can touch sync FS.
117
115
  {
118
116
  const _reErr = await _readReachPreflight(args.path, workDir, helpers);
119
117
  if (_reErr) return _reErr;
@@ -186,8 +184,6 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
186
184
  }
187
185
  }
188
186
  if (r?.limit !== undefined) entry.limit = r.limit;
189
- if (r?.line !== undefined) entry.line = r.line;
190
- if (r?.context !== undefined) entry.context = r.context;
191
187
  if (r?.full !== undefined) entry.full = r.full;
192
188
  entry = normaliseReadLineWindowArgs(entry, workDir);
193
189
  return entry;
@@ -235,8 +231,6 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
235
231
  if (args.n !== undefined) entry.n = args.n;
236
232
  if (args.offset !== undefined) entry.offset = args.offset;
237
233
  if (args.limit !== undefined) entry.limit = args.limit;
238
- if (args.line !== undefined) entry.line = args.line;
239
- if (args.context !== undefined) entry.context = args.context;
240
234
  if (args.full !== undefined) entry.full = args.full;
241
235
  entry = normaliseReadLineWindowArgs(entry, workDir);
242
236
  return entry;
@@ -464,14 +458,13 @@ export async function executeReadTool(args, workDir, readStateScope, executeChil
464
458
  if (span.note) args._symbolReadNote = `symbol ${sym}: ${span.note}`;
465
459
  }
466
460
  }
467
- // A window (offset/limit/line or a path:line coordinate) beats a glance
461
+ // A window (offset/limit or a path:line coordinate) beats a glance
468
462
  // mode (head/tail/summary), which would otherwise read from a file end and
469
- // silently drop the window. Drop the glance mode BEFORE line-window
470
- // normalization so a line / path:line coordinate is actually converted to
471
- // offset/limit (normaliseReadLineWindowArgs only converts when no mode is
472
- // set). count/hex are not text-window ops and keep their mode.
463
+ // silently drop the window. Drop the glance mode BEFORE path:line
464
+ // compatibility normalization so any path coordinate is converted to
465
+ // offset/limit. count/hex are not text-window ops and keep their mode.
473
466
  {
474
- const _win = args.offset != null || args.limit != null || args.line != null || hasLineCoordinate(args.path);
467
+ const _win = args.offset != null || args.limit != null || hasLineCoordinate(args.path);
475
468
  if (_win && (args.mode === 'head' || args.mode === 'tail' || args.mode === 'summary')) {
476
469
  args = { ...args, mode: undefined };
477
470
  }
@@ -36,6 +36,11 @@ import {
36
36
  import { recordReadSnapshot } from './read-snapshot-runtime.mjs';
37
37
  import { applyGrepContextLeadPolicy, GREP_CONTEXT_MAX } from './arg-guard.mjs';
38
38
 
39
+ // Default surrounding-lines window applied by output_mode:'content_with_context'
40
+ // when the caller does not pass an explicit -A/-B/-C/context. Sized to cover a
41
+ // typical function/block so a match arrives readable without a follow-up read.
42
+ const GREP_AUTO_CONTEXT_LINES = 25;
43
+
39
44
  const MIXDOG_GREP_CASE_HINT_PROBE = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_GREP_CASE_HINT_PROBE || ''));
40
45
 
41
46
  function expandLegacyEscapedAlternationPattern(rawPattern) {
@@ -353,18 +358,23 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
353
358
  .map(canonicalizeGlobSlashes)
354
359
  .filter((g) => !isRedundantAllFilesGlob(g)));
355
360
 
356
- const ALLOWED_OUTPUT_MODES = new Set(['files_with_matches', 'content', 'count']);
361
+ const ALLOWED_OUTPUT_MODES = new Set(['files_with_matches', 'content', 'content_with_context', 'count']);
357
362
  const rawOutputMode = typeof args.output_mode === 'string' ? args.output_mode.trim() : '';
358
363
  if (rawOutputMode && !ALLOWED_OUTPUT_MODES.has(rawOutputMode)) {
359
364
  return `Error: invalid output_mode ${JSON.stringify(args.output_mode)}; expected one of ${[...ALLOWED_OUTPUT_MODES].join(', ')}`;
360
365
  }
366
+ // `content_with_context` is a convenience alias for `content` that auto-applies
367
+ // a generous surrounding-lines window, so the model can read a match in place
368
+ // without following up with `read`. It maps to content mode; the default
369
+ // context is applied below unless the caller passed an explicit -A/-B/-C/context.
370
+ const wantAutoContext = rawOutputMode === 'content_with_context';
361
371
  // Default to `content` when output_mode is omitted. A pattern is always
362
372
  // present here (the no-pattern case returned above), so this is a content
363
373
  // search — it should return the matching lines WITH line numbers, not just
364
374
  // filenames. Filename-only was forcing callers to re-grep for the actual
365
375
  // coordinates (the explorer over-iteration root cause). `files_with_matches`
366
376
  // is now opt-in; pure filename discovery belongs to `glob`.
367
- const outputMode = rawOutputMode || 'content';
377
+ const outputMode = (rawOutputMode === 'content_with_context') ? 'content' : (rawOutputMode || 'content');
368
378
  const headLimitRaw = args.head_limit;
369
379
  const headLimitCoerced = coerceNonNegInt(headLimitRaw);
370
380
  if (Number.isNaN(headLimitCoerced)) {
@@ -391,6 +401,12 @@ export async function executeGrepTool(args, workDir, executeChildBuiltinTool, re
391
401
  let contextN = args['-C'] !== undefined && args['-C'] !== null && args['-C'] !== ''
392
402
  ? coerceContext(args['-C'])
393
403
  : coerceContext(args.context);
404
+ // content_with_context: if no explicit context flag was supplied, apply a
405
+ // generous default so the match arrives with enough surrounding code to
406
+ // understand it (function-sized) without a separate read.
407
+ if (wantAutoContext && afterN === null && beforeN === null && contextN === null) {
408
+ contextN = GREP_AUTO_CONTEXT_LINES;
409
+ }
394
410
  if (contextN !== null && contextN > 0) {
395
411
  if (afterN === 0) afterN = null;
396
412
  if (beforeN === 0) beforeN = null;
@@ -1,9 +1,10 @@
1
1
  import { countSplitLines } from './path-utils.mjs';
2
+ import { TOOL_OUTPUT_MAX_BYTES } from './tool-output-limit.mjs';
2
3
 
3
- export const SHELL_OUTPUT_MAX_CHARS = 30_000;
4
+ export const SHELL_OUTPUT_MAX_CHARS = TOOL_OUTPUT_MAX_BYTES;
4
5
 
5
6
  export const SMART_BASH_MAX_LINES = 400;
6
- export const SMART_BASH_MAX_BYTES = 30_000;
7
+ export const SMART_BASH_MAX_BYTES = TOOL_OUTPUT_MAX_BYTES;
7
8
  export const SMART_BASH_HEAD_LINES = 80;
8
9
  export const SMART_BASH_TAIL_LINES = 80;
9
10
 
@@ -0,0 +1,10 @@
1
+ // Single source of truth for the model-facing tool-output byte budget.
2
+ // read / shell / list-tree all truncate their output to this cap so the
3
+ // context cost of any one tool call is bounded and consistent. Override with
4
+ // MIXDOG_TOOL_OUTPUT_MAX_BYTES. Line-based sub-caps (e.g. bash max lines) stay
5
+ // per-tool; this governs only the byte budget.
6
+ function _envInt(name, fallback) {
7
+ const v = parseInt(process.env[name], 10);
8
+ return Number.isFinite(v) && v > 0 ? v : fallback;
9
+ }
10
+ export const TOOL_OUTPUT_MAX_BYTES = _envInt('MIXDOG_TOOL_OUTPUT_MAX_BYTES', 50 * 1024);