mixdog 0.9.18 → 0.9.19
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.
- package/package.json +3 -2
- package/scripts/output-style-smoke.mjs +2 -2
- package/scripts/recall-bench-cases.json +11 -0
- package/scripts/recall-bench.mjs +91 -2
- package/scripts/tool-efficiency-diag.mjs +4 -1
- package/scripts/tool-smoke.mjs +101 -27
- package/src/agents/debugger/AGENT.md +3 -3
- package/src/agents/heavy-worker/AGENT.md +7 -10
- package/src/agents/maintainer/AGENT.md +1 -2
- package/src/agents/reviewer/AGENT.md +1 -2
- package/src/agents/worker/AGENT.md +5 -8
- package/src/defaults/agents.json +3 -0
- package/src/mixdog-session-runtime.mjs +23 -6
- package/src/rules/agent/00-core.md +4 -3
- package/src/rules/agent/30-explorer.md +53 -22
- package/src/rules/lead/lead-tool.md +3 -2
- package/src/rules/shared/01-tool.md +24 -29
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
- package/src/runtime/channels/backends/discord-gateway.mjs +14 -1
- package/src/runtime/channels/backends/discord.mjs +43 -1
- package/src/runtime/channels/index.mjs +145 -58
- package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
- package/src/runtime/channels/lib/memory-client.mjs +32 -14
- package/src/runtime/channels/lib/output-forwarder.mjs +38 -7
- package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
- package/src/runtime/channels/lib/runtime-paths.mjs +35 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
- package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
- package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
- package/src/runtime/memory/lib/query-handlers.mjs +36 -4
- package/src/runtime/memory/lib/recall-format.mjs +106 -6
- package/src/runtime/memory/lib/session-ingest.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +10 -4
- package/src/runtime/shared/background-tasks.mjs +4 -2
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-result-summary.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +30 -1
- package/src/session-runtime/config-lifecycle.mjs +1 -1
- package/src/session-runtime/cwd-plugins.mjs +46 -3
- package/src/session-runtime/mcp-glue.mjs +24 -3
- package/src/session-runtime/output-styles.mjs +44 -10
- package/src/session-runtime/workflow.mjs +16 -1
- package/src/standalone/channel-worker.mjs +74 -5
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/tui/App.jsx +57 -77
- package/src/tui/app/channel-pickers.mjs +45 -0
- package/src/tui/app/slash-commands.mjs +0 -1
- package/src/tui/app/slash-dispatch.mjs +0 -16
- package/src/tui/app/transcript-window.mjs +44 -1
- package/src/tui/app/use-mouse-input.mjs +9 -2
- package/src/tui/app/use-prompt-handlers.mjs +7 -94
- package/src/tui/app/use-transcript-scroll.mjs +5 -1
- package/src/tui/app/use-transcript-window.mjs +65 -5
- package/src/tui/components/PromptInput.jsx +33 -64
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +5330 -5443
- package/src/tui/engine.mjs +49 -10
- package/src/tui/lib/voice-setup.mjs +166 -0
- package/src/tui/paste-attachments.mjs +12 -5
- package/src/tui/prompt-history-store.mjs +125 -12
- package/scripts/bench/cache-probe-tasks.json +0 -8
- package/scripts/bench/lead-review-tasks-r3.json +0 -20
- package/scripts/bench/lead-review-tasks.json +0 -20
- package/scripts/bench/r4-mixed-tasks.json +0 -20
- package/scripts/bench/r5-orchestrated-task.json +0 -7
- package/scripts/bench/review-tasks.json +0 -20
- package/scripts/bench/round-codex.json +0 -114
- package/scripts/bench/round-mixdog-lead-r3.json +0 -269
- package/scripts/bench/round-mixdog-lead.json +0 -269
- package/scripts/bench/round-mixdog.json +0 -126
- package/scripts/bench/round-r10-bigsample.json +0 -679
- package/scripts/bench/round-r11-codexalign.json +0 -257
- package/scripts/bench/round-r13-clientmeta.json +0 -464
- package/scripts/bench/round-r14-betafeatures.json +0 -466
- package/scripts/bench/round-r15-fulldefault.json +0 -462
- package/scripts/bench/round-r16-sessionid.json +0 -466
- package/scripts/bench/round-r17-wirebytes.json +0 -456
- package/scripts/bench/round-r18-prewarm.json +0 -468
- package/scripts/bench/round-r19-clean.json +0 -472
- package/scripts/bench/round-r20-prewarm-clean.json +0 -475
- package/scripts/bench/round-r21-delta-retry.json +0 -473
- package/scripts/bench/round-r22-full-probe.json +0 -693
- package/scripts/bench/round-r23-itemprobe.json +0 -701
- package/scripts/bench/round-r24-shapefix.json +0 -677
- package/scripts/bench/round-r25-serial.json +0 -464
- package/scripts/bench/round-r26-parallel3.json +0 -671
- package/scripts/bench/round-r27-parallel10.json +0 -894
- package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
- package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
- package/scripts/bench/round-r30-instid.json +0 -253
- package/scripts/bench/round-r31-upgradeprobe.json +0 -256
- package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
- package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
- package/scripts/bench/round-r34-orchestrated.json +0 -120
- package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
- package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
- package/scripts/bench/round-r4-codex.json +0 -114
- package/scripts/bench/round-r4-mixed.json +0 -225
- package/scripts/bench/round-r5-gpt-lead.json +0 -259
- package/scripts/bench/round-r6-codex.json +0 -114
- package/scripts/bench/round-r6-solo.json +0 -257
- package/scripts/bench/round-r7-full.json +0 -254
- package/scripts/bench/round-r8-fulldefault.json +0 -255
- package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
- package/src/tui/lib/voice-recorder.mjs +0 -469
|
@@ -17,6 +17,27 @@ const MAX_INT = 100000;
|
|
|
17
17
|
// defaults to 25 lines; this is only the upper bound for caller-supplied -A/-B/-C.
|
|
18
18
|
export const GREP_CONTEXT_MAX = 200;
|
|
19
19
|
|
|
20
|
+
// Tighter cap for CONTEXT-MODE grep (content_with_context or explicit -A/-B/-C):
|
|
21
|
+
// head_limit counts MATCH BLOCKS in that mode, so ~40 blocks is already a large
|
|
22
|
+
// read. Context width itself (-A/-B/-C) is deliberately NOT special-cased —
|
|
23
|
+
// explicit requests are honored up to the generic GREP_CONTEXT_MAX; total
|
|
24
|
+
// output stays bounded by the block clamp, the stream line cap, and the
|
|
25
|
+
// tool-output byte cap.
|
|
26
|
+
export const GREP_CTX_HEAD_LIMIT_MAX = 40;
|
|
27
|
+
|
|
28
|
+
// Unbounded (no offset/limit) plain full reads default to this window instead of
|
|
29
|
+
// pulling the whole file; the read tool's ranged-read footer then hands the
|
|
30
|
+
// caller the next offset to page with.
|
|
31
|
+
export const READ_GUARD_DEFAULT_LIMIT = 800;
|
|
32
|
+
|
|
33
|
+
// Best-effort clamp notice channel: stash a one-line note on the args so a
|
|
34
|
+
// surfacing consumer can echo it. Underscore-prefixed; ignored by executors.
|
|
35
|
+
function pushClampNotice(a, msg) {
|
|
36
|
+
if (!a || typeof a !== 'object') return;
|
|
37
|
+
if (!Array.isArray(a._clampNotices)) a._clampNotices = [];
|
|
38
|
+
a._clampNotices.push(msg);
|
|
39
|
+
}
|
|
40
|
+
|
|
20
41
|
// ripgrep-flavored aliases: models trained on `rg` emit short flags (-A/-B/-C),
|
|
21
42
|
// long flags (--after-context / --before-context / --context), or snake/camel
|
|
22
43
|
// spellings. All fold onto the canonical -A/-B/-C so a caller can write grep
|
|
@@ -154,6 +175,73 @@ function coerceIntegerString(v) {
|
|
|
154
175
|
// tells the model exactly what to do instead.
|
|
155
176
|
const RANGE_SHAPED_INT_STRING = /^\d+\s*[,\-:]\s*\d+$/;
|
|
156
177
|
|
|
178
|
+
// Coerce a window arg that may arrive as a real array or a JSON-stringified
|
|
179
|
+
// array ("[0, 0]") into a real array; returns null when it is neither.
|
|
180
|
+
function coerceWindowArray(v) {
|
|
181
|
+
if (Array.isArray(v)) return v;
|
|
182
|
+
if (typeof v === 'string') {
|
|
183
|
+
let c = coerceShapeFlex(v);
|
|
184
|
+
// Double-encoded window strings ('"[0, 0]"') unwrap to the inner
|
|
185
|
+
// string '[0, 0]' on the first flex pass — re-run once to reach the
|
|
186
|
+
// array. Lossless: only applies when the outer layer was a JSON
|
|
187
|
+
// string wrapper.
|
|
188
|
+
if (typeof c === 'string' && c !== v) c = coerceShapeFlex(c);
|
|
189
|
+
if (Array.isArray(c)) return c;
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Absorb: offset/limit arriving as parallel arrays (or JSON-stringified
|
|
195
|
+
// arrays) alongside a path[] batch — zip each window onto its file as a
|
|
196
|
+
// {path,offset,limit} region object, mirroring the lossless path JSON-string
|
|
197
|
+
// recovery. A length-1 window array applies to every file. Per-entry integer
|
|
198
|
+
// coercion/validation then runs on the resulting region objects. Length
|
|
199
|
+
// mismatches zip positionally (absorb, don't hard-error); a single path with
|
|
200
|
+
// multiple windows expands into regions of that path.
|
|
201
|
+
function maybeZipPathWindowArrays(a) {
|
|
202
|
+
// A single-string path also zips when the windows are ARRAYS (e.g.
|
|
203
|
+
// path:'x.mjs', offset:'[0, 100]') — coerceReadFamilyPathArg unwraps
|
|
204
|
+
// one-element path[] to a string before this runs, so without this the
|
|
205
|
+
// window string would survive to the integer guard and hard-error.
|
|
206
|
+
if (typeof a.path === 'string' && a.path
|
|
207
|
+
&& (coerceWindowArray(a.offset) || coerceWindowArray(a.limit))) {
|
|
208
|
+
a.path = [a.path];
|
|
209
|
+
}
|
|
210
|
+
if (!Array.isArray(a.path) || a.path.length < 1) return;
|
|
211
|
+
if (!a.path.every((e) => typeof e === 'string')) return;
|
|
212
|
+
const offs = coerceWindowArray(a.offset);
|
|
213
|
+
const lims = coerceWindowArray(a.limit);
|
|
214
|
+
if (!offs && !lims) return;
|
|
215
|
+
const n = a.path.length;
|
|
216
|
+
const pick = (arr, i) => (arr ? (arr.length === 1 ? arr[0] : arr[i]) : undefined);
|
|
217
|
+
// Single path + multiple windows -> regions of the SAME path (the model
|
|
218
|
+
// asked for several spans of one file).
|
|
219
|
+
const k = Math.max(offs?.length || 0, lims?.length || 0);
|
|
220
|
+
if (n === 1 && k > 1) {
|
|
221
|
+
const p = a.path[0];
|
|
222
|
+
a.path = Array.from({ length: k }, (_, i) => {
|
|
223
|
+
const r = { path: p };
|
|
224
|
+
const o = pick(offs, i); if (o !== undefined && o !== null) r.offset = o;
|
|
225
|
+
const l = pick(lims, i); if (l !== undefined && l !== null) r.limit = l;
|
|
226
|
+
return r;
|
|
227
|
+
});
|
|
228
|
+
delete a.offset;
|
|
229
|
+
delete a.limit;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// Positional zip. Length mismatches absorb instead of hard-erroring
|
|
233
|
+
// (seen live: 3 paths + offset "[350, 1]"): missing entries mean
|
|
234
|
+
// full-file read for that path; extra window entries are dropped.
|
|
235
|
+
a.path = a.path.map((p, i) => {
|
|
236
|
+
const r = { path: p };
|
|
237
|
+
const o = pick(offs, i); if (o !== undefined && o !== null) r.offset = o;
|
|
238
|
+
const l = pick(lims, i); if (l !== undefined && l !== null) r.limit = l;
|
|
239
|
+
return r;
|
|
240
|
+
});
|
|
241
|
+
delete a.offset;
|
|
242
|
+
delete a.limit;
|
|
243
|
+
}
|
|
244
|
+
|
|
157
245
|
// Mutates a[field] in place when it is a lossless integer string, then
|
|
158
246
|
// validates the (possibly coerced) value against [min, max].
|
|
159
247
|
function checkIntInRange(a, field, min, max, opts = {}) {
|
|
@@ -167,10 +255,16 @@ function checkIntInRange(a, field, min, max, opts = {}) {
|
|
|
167
255
|
}
|
|
168
256
|
}
|
|
169
257
|
if (typeof value === 'string' && RANGE_SHAPED_INT_STRING.test(value.trim())) {
|
|
170
|
-
return `Error:
|
|
258
|
+
return `Error: "${field}" takes one integer, not a range "${value}" — use offset+limit (e.g. offset:0, limit:40)`;
|
|
259
|
+
}
|
|
260
|
+
// Soft-cap fields only: floor a fractional number (e.g. -B: 2.5) instead
|
|
261
|
+
// of erroring — intent is unambiguous for a context-line count.
|
|
262
|
+
if (opts.clamp && typeof value === 'number' && Number.isFinite(value) && !Number.isInteger(value)) {
|
|
263
|
+
value = Math.floor(value);
|
|
264
|
+
a[field] = value;
|
|
171
265
|
}
|
|
172
266
|
if (!isFiniteInt(value)) {
|
|
173
|
-
return `Error:
|
|
267
|
+
return `Error: "${field}" must be an integer (got ${describeType(value)}) — e.g. ${field}:40`;
|
|
174
268
|
}
|
|
175
269
|
// Soft-cap fields (grep -A/-B/-C/context): silently clamp out-of-range
|
|
176
270
|
// instead of erroring — a model-guessed "300" for a 200-line cap is not
|
|
@@ -270,6 +364,18 @@ function guardGrep(a) {
|
|
|
270
364
|
}
|
|
271
365
|
// path/root (optional, string or string[])
|
|
272
366
|
for (const k of ['path', 'root']) {
|
|
367
|
+
// Absorb JSON-stringified arrays ('["src","docs"]') like the read
|
|
368
|
+
// family does; an empty array ('[]' or []) means "no path filter" —
|
|
369
|
+
// drop the key so the search defaults to cwd instead of handing rg a
|
|
370
|
+
// literal "[]" path (parsed as an unclosed character class).
|
|
371
|
+
if (hasOwn(a, k) && typeof a[k] === 'string') {
|
|
372
|
+
const c = coerceShapeFlex(a[k].trim());
|
|
373
|
+
if (Array.isArray(c)) a[k] = c;
|
|
374
|
+
}
|
|
375
|
+
if (hasOwn(a, k) && Array.isArray(a[k]) && a[k].length === 0) {
|
|
376
|
+
delete a[k];
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
273
379
|
if (hasOwn(a, k) && !isStringOrStringArray(a[k])) {
|
|
274
380
|
return `Error: grep arg "${k}" must be string or string[] (got ${describeType(a[k])})`;
|
|
275
381
|
}
|
|
@@ -309,6 +415,22 @@ function guardGrep(a) {
|
|
|
309
415
|
}
|
|
310
416
|
}
|
|
311
417
|
}
|
|
418
|
+
// Context-mode tightening: clamp head_limit (which counts MATCH BLOCKS)
|
|
419
|
+
// harder than the generic caps above, and note the clamp for surfacing.
|
|
420
|
+
// Only applies where the executor actually honors context flags — i.e.
|
|
421
|
+
// content_with_context, or content mode with explicit -A/-B/-C. In
|
|
422
|
+
// files_with_matches/count the context flags are ignored, so head_limit
|
|
423
|
+
// there means output lines/paths, not blocks — never clamp it.
|
|
424
|
+
const grepMode = a.output_mode || a.mode;
|
|
425
|
+
const hasExplicitCtx = ['-A', '-B', '-C', 'context'].some((k) => grepContextKeyPresent(a, k));
|
|
426
|
+
const isCountOrFiles = grepMode === 'files_with_matches' || grepMode === 'count';
|
|
427
|
+
if (!isCountOrFiles && (grepMode === 'content_with_context' || hasExplicitCtx)) {
|
|
428
|
+
const hl = Number(a.head_limit);
|
|
429
|
+
if (grepContextKeyPresent(a, 'head_limit') && Number.isFinite(hl) && hl > GREP_CTX_HEAD_LIMIT_MAX) {
|
|
430
|
+
a.head_limit = GREP_CTX_HEAD_LIMIT_MAX;
|
|
431
|
+
pushClampNotice(a, `notice: grep head_limit clamped to ${GREP_CTX_HEAD_LIMIT_MAX} match blocks (context mode)`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
312
434
|
return null;
|
|
313
435
|
}
|
|
314
436
|
|
|
@@ -339,18 +461,18 @@ function applyLineContextWindow(obj, labelPrefix) {
|
|
|
339
461
|
return `Error: read arg "${labelPrefix}context" requires "${labelPrefix}line" to compute a window`;
|
|
340
462
|
}
|
|
341
463
|
if (typeof obj.line === 'string' && RANGE_SHAPED_INT_STRING.test(obj.line.trim())) {
|
|
342
|
-
return `Error:
|
|
464
|
+
return `Error: "${labelPrefix}line" takes one integer, not a range "${obj.line}" — use offset+limit (e.g. offset:0, limit:40)`;
|
|
343
465
|
}
|
|
344
466
|
if (!isFiniteInt(obj.line) || obj.line < 1) {
|
|
345
|
-
return `Error:
|
|
467
|
+
return `Error: "${labelPrefix}line" must be an integer >= 1 (got ${describeType(obj.line)}) — e.g. line:40`;
|
|
346
468
|
}
|
|
347
469
|
let ctx = 0;
|
|
348
470
|
if (hasContext) {
|
|
349
471
|
if (typeof obj.context === 'string' && RANGE_SHAPED_INT_STRING.test(obj.context.trim())) {
|
|
350
|
-
return `Error:
|
|
472
|
+
return `Error: "${labelPrefix}context" takes one integer, not a range "${obj.context}" — e.g. context:5`;
|
|
351
473
|
}
|
|
352
474
|
if (!isFiniteInt(obj.context) || obj.context < 0) {
|
|
353
|
-
return `Error:
|
|
475
|
+
return `Error: "${labelPrefix}context" must be an integer >= 0 (got ${describeType(obj.context)}) — e.g. context:5`;
|
|
354
476
|
}
|
|
355
477
|
ctx = obj.context;
|
|
356
478
|
}
|
|
@@ -361,6 +483,43 @@ function applyLineContextWindow(obj, labelPrefix) {
|
|
|
361
483
|
return null;
|
|
362
484
|
}
|
|
363
485
|
|
|
486
|
+
// Item 3: an unbounded plain full read (no offset/limit/line/context/mode/n/
|
|
487
|
+
// full, and a bare path with no #Lx / :line coordinate) pulls the whole file.
|
|
488
|
+
// Inject a default window so large files come back capped with a next-offset
|
|
489
|
+
// footer instead. Small files still return in full (limit exceeds line count);
|
|
490
|
+
// explicit windows, modes, and line-spec paths are left untouched.
|
|
491
|
+
function maybeCapUnboundedRead(a) {
|
|
492
|
+
// Effective path may arrive via the file_path alias; cap those the same way.
|
|
493
|
+
let pathStrs = null;
|
|
494
|
+
if (typeof a.path === 'string' && a.path.trim() !== '') {
|
|
495
|
+
pathStrs = [a.path];
|
|
496
|
+
} else if (typeof a.file_path === 'string' && a.file_path.trim() !== '') {
|
|
497
|
+
pathStrs = [a.file_path];
|
|
498
|
+
} else if (Array.isArray(a.path) && a.path.length > 0
|
|
499
|
+
&& a.path.every((e) => typeof e === 'string' && e.trim() !== '')) {
|
|
500
|
+
// string[] batch: read-tool expands top-level limit uniformly onto
|
|
501
|
+
// every entry, so bare-path batches take the same default window.
|
|
502
|
+
// Region-object arrays never reach here (they carry their own
|
|
503
|
+
// windows and are rejected above when mixed with top-level limit).
|
|
504
|
+
pathStrs = a.path;
|
|
505
|
+
}
|
|
506
|
+
if (pathStrs === null) return;
|
|
507
|
+
if (a.full === true) return;
|
|
508
|
+
// symbol (and any similar body-selector) composes its own window inside the
|
|
509
|
+
// matched body — a read({path,symbol}) must never be window-truncated.
|
|
510
|
+
for (const k of ['offset', 'limit', 'line', 'context', 'n', 'mode', 'pages', 'symbol']) {
|
|
511
|
+
if (isPresent(a, k)) return;
|
|
512
|
+
}
|
|
513
|
+
// Any #Lx / :line coordinate opts the whole call out: the injected limit
|
|
514
|
+
// would override that entry's own line-spec window during per-entry
|
|
515
|
+
// normalization.
|
|
516
|
+
for (const p of pathStrs) {
|
|
517
|
+
if (p.includes('#')) return;
|
|
518
|
+
if (/:\d+(?:-\d+)?\s*$/.test(p)) return;
|
|
519
|
+
}
|
|
520
|
+
a.limit = READ_GUARD_DEFAULT_LIMIT;
|
|
521
|
+
}
|
|
522
|
+
|
|
364
523
|
function guardRead(a) {
|
|
365
524
|
// path / file_path alias OR path may itself be array
|
|
366
525
|
const hasPath = hasOwn(a, 'path') || hasOwn(a, 'file_path');
|
|
@@ -374,6 +533,9 @@ function guardRead(a) {
|
|
|
374
533
|
if (hasOwn(a, 'path')) {
|
|
375
534
|
a.path = coerceReadFamilyPathArg(a.path);
|
|
376
535
|
}
|
|
536
|
+
// Absorb: parallel/JSON-stringified offset+limit arrays paired with a
|
|
537
|
+
// path[] batch — zip them into per-file region objects before validation.
|
|
538
|
+
maybeZipPathWindowArrays(a);
|
|
377
539
|
// path can be string | string[] | object[]; file_path is string
|
|
378
540
|
if (hasOwn(a, 'path')) {
|
|
379
541
|
const p = a.path;
|
|
@@ -382,6 +544,20 @@ function guardRead(a) {
|
|
|
382
544
|
return `Error: read arg "path" must be string, string[], or object[] (got ${describeType(p)})`;
|
|
383
545
|
}
|
|
384
546
|
if (Array.isArray(p)) {
|
|
547
|
+
// Absorb: a region array ({path,offset,limit}[]) carries its window
|
|
548
|
+
// per-entry; a top-level offset/limit becomes the default for any
|
|
549
|
+
// region missing its own window, then the top-level keys are dropped
|
|
550
|
+
// so they don't double-apply in the plain-read checks below.
|
|
551
|
+
const hasRegionObj = p.some((e) => e && typeof e === 'object' && !Array.isArray(e));
|
|
552
|
+
if (hasRegionObj && (isPresent(a, 'offset') || isPresent(a, 'limit'))) {
|
|
553
|
+
for (const e of p) {
|
|
554
|
+
if (!e || typeof e !== 'object' || Array.isArray(e)) continue;
|
|
555
|
+
if (isPresent(a, 'offset') && !isPresent(e, 'offset')) e.offset = a.offset;
|
|
556
|
+
if (isPresent(a, 'limit') && !isPresent(e, 'limit')) e.limit = a.limit;
|
|
557
|
+
}
|
|
558
|
+
delete a.offset;
|
|
559
|
+
delete a.limit;
|
|
560
|
+
}
|
|
385
561
|
for (let i = 0; i < p.length; i++) {
|
|
386
562
|
const entry = p[i];
|
|
387
563
|
if (typeof entry === 'string') continue;
|
|
@@ -400,6 +576,9 @@ function guardRead(a) {
|
|
|
400
576
|
if (hasOwn(a, 'file_path') && !isNonEmptyString(a.file_path)) {
|
|
401
577
|
return `Error: read arg "file_path" must be a non-empty string (got ${describeType(a.file_path)})`;
|
|
402
578
|
}
|
|
579
|
+
// Item 3: cap unbounded full reads to a paging window (after array/region
|
|
580
|
+
// handling so batched region reads are never touched).
|
|
581
|
+
maybeCapUnboundedRead(a);
|
|
403
582
|
// Read's public surface is offset/limit, but a top-level line/context pair
|
|
404
583
|
// is a deterministic, lossless spelling of the same window (matching
|
|
405
584
|
// read-args.mjs's internal file:line normalizer semantics); convert it
|
|
@@ -586,6 +765,19 @@ function guardCodeGraph(a) {
|
|
|
586
765
|
if (!CODE_GRAPH_MODES.has(mode)) {
|
|
587
766
|
return `Error: code_graph arg "mode" must be one of ${[...CODE_GRAPH_MODES].join('|')} (got ${JSON.stringify(a.mode)})`;
|
|
588
767
|
}
|
|
768
|
+
// Absorb: file/files arriving as a JSON-stringified array
|
|
769
|
+
// (file:"[\"a.mjs\",\"b.mjs\"]") — parse to a real array so the graph
|
|
770
|
+
// lookup batches per file instead of treating the JSON text as one path.
|
|
771
|
+
for (const k of ['file', 'files']) {
|
|
772
|
+
if (hasOwn(a, k) && typeof a[k] === 'string') {
|
|
773
|
+
const c = coerceShapeFlex(a[k]);
|
|
774
|
+
if (Array.isArray(c)) a[k] = c;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (Array.isArray(a.file)) {
|
|
778
|
+
a.files = Array.isArray(a.files) ? [...a.file, ...a.files] : a.file;
|
|
779
|
+
delete a.file;
|
|
780
|
+
}
|
|
589
781
|
return null;
|
|
590
782
|
}
|
|
591
783
|
|
|
@@ -114,7 +114,7 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
114
114
|
const requestedCwd = args.cwd ?? args.workdir;
|
|
115
115
|
const cwdResult = resolveOptionalCwd(requestedCwd, workDir);
|
|
116
116
|
if (cwdResult.error) return cwdResult.error;
|
|
117
|
-
// Session cwd carry-over (
|
|
117
|
+
// Session cwd carry-over (no live shell): when the model
|
|
118
118
|
// passes an explicit cwd it wins and updates the store on the next probe;
|
|
119
119
|
// otherwise reuse the last stored cwd for this session if it still exists.
|
|
120
120
|
const _hasExplicitCwd = typeof requestedCwd === 'string' && requestedCwd.trim() !== '';
|
|
@@ -231,7 +231,15 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
231
231
|
// run_in_background (already detached) or persistent sessions (handled
|
|
232
232
|
// far above). Capped below the hard timeout so the 600 s upper bound
|
|
233
233
|
// stays a separate, later ceiling.
|
|
234
|
-
|
|
234
|
+
// Soft config: reuse the sync task-wait budget (30 s, see waitForShellJob
|
|
235
|
+
// default in executeTaskTool) as the default promotion threshold. Override
|
|
236
|
+
// with MIXDOG_SHELL_AUTO_BACKGROUND_MS (positive ms). This is a soft hint,
|
|
237
|
+
// not a hard cap — the value is clamped below `timeout` so the hard ceiling
|
|
238
|
+
// stays a separate, later bound.
|
|
239
|
+
const _autoBgEnvMs = Number(process.env.MIXDOG_SHELL_AUTO_BACKGROUND_MS);
|
|
240
|
+
const DEFAULT_AUTO_BACKGROUND_MS = Number.isFinite(_autoBgEnvMs) && _autoBgEnvMs > 0
|
|
241
|
+
? Math.floor(_autoBgEnvMs)
|
|
242
|
+
: 30_000;
|
|
235
243
|
const autoBackgroundMs = runInBackground
|
|
236
244
|
? 0
|
|
237
245
|
: Math.min(DEFAULT_AUTO_BACKGROUND_MS, timeout);
|
|
@@ -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)
|
|
20
|
+
description: 'Read known file path(s) with optional numeric offset+limit windows. Batch paths/regions as real arrays in one call. Not for directory listing.',
|
|
21
21
|
inputSchema: {
|
|
22
22
|
type: 'object',
|
|
23
23
|
properties: {
|
|
@@ -53,7 +53,7 @@ export const BUILTIN_TOOLS = [
|
|
|
53
53
|
name: 'shell',
|
|
54
54
|
title: 'Mixdog Shell',
|
|
55
55
|
annotations: { title: 'Mixdog Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
|
|
56
|
-
description: `Run
|
|
56
|
+
description: `Run programs or change system state. Set shell: powershell or bash. Not for reading, listing, or searching files. ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
|
|
57
57
|
inputSchema: {
|
|
58
58
|
type: 'object',
|
|
59
59
|
properties: {
|
|
@@ -87,7 +87,7 @@ export const BUILTIN_TOOLS = [
|
|
|
87
87
|
name: 'grep',
|
|
88
88
|
title: 'Mixdog Grep',
|
|
89
89
|
annotations: { title: 'Mixdog Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
90
|
-
description: '
|
|
90
|
+
description: 'Exact text/regex in a KNOWN scope. files_with_matches/count for broad anchors, content_with_context for narrow answers.',
|
|
91
91
|
inputSchema: {
|
|
92
92
|
type: 'object',
|
|
93
93
|
properties: {
|
|
@@ -96,30 +96,28 @@ export const BUILTIN_TOOLS = [
|
|
|
96
96
|
{ type: 'string' },
|
|
97
97
|
{ type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
98
98
|
],
|
|
99
|
-
description: 'Text/regex.
|
|
99
|
+
description: 'Text/regex. Array = variants in one call.',
|
|
100
100
|
},
|
|
101
101
|
path: {
|
|
102
102
|
anyOf: [
|
|
103
103
|
{ type: 'string' },
|
|
104
104
|
{ type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
105
105
|
],
|
|
106
|
-
description: 'Known
|
|
106
|
+
description: 'Known file or dir. Array = several scopes.',
|
|
107
107
|
},
|
|
108
108
|
glob: {
|
|
109
109
|
anyOf: [
|
|
110
110
|
{ type: 'string' },
|
|
111
111
|
{ type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
112
112
|
],
|
|
113
|
-
description: '
|
|
113
|
+
description: 'Glob filter to narrow scope.',
|
|
114
114
|
},
|
|
115
|
-
output_mode: { type: 'string', enum: ['content_with_context', 'content', 'files_with_matches', 'count'], description: 'Broad
|
|
116
|
-
head_limit: { type: 'number', minimum: 0, description: 'Max
|
|
117
|
-
offset: { type: 'number', minimum: 0, description: 'Skip
|
|
115
|
+
output_mode: { type: 'string', enum: ['content_with_context', 'content', 'files_with_matches', 'count'], description: 'Broad: files_with_matches/count; narrow: content_with_context.' },
|
|
116
|
+
head_limit: { type: 'number', minimum: 0, description: 'Max results.' },
|
|
117
|
+
offset: { type: 'number', minimum: 0, description: 'Skip results for paging.' },
|
|
118
118
|
'-A': { type: 'number', minimum: 0, description: 'Lines after each match.' },
|
|
119
119
|
'-B': { type: 'number', minimum: 0, description: 'Lines before each match.' },
|
|
120
|
-
|
|
121
|
-
// grep→read same-file re-open round-trips (2026-07 diag).
|
|
122
|
-
'-C': { type: 'number', minimum: 0, description: 'Lines before/after each match. Content modes: pass -C 5+ so the hit is edit-ready; skipping it forces a follow-up read.' },
|
|
120
|
+
'-C': { type: 'number', minimum: 0, description: 'Lines before/after each match.' },
|
|
123
121
|
},
|
|
124
122
|
required: [],
|
|
125
123
|
},
|
|
@@ -128,7 +126,7 @@ export const BUILTIN_TOOLS = [
|
|
|
128
126
|
name: 'glob',
|
|
129
127
|
title: 'Mixdog Glob',
|
|
130
128
|
annotations: { title: 'Mixdog Glob', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
131
|
-
description: 'Find files by exact glob.
|
|
129
|
+
description: 'Find files by exact glob. Batch patterns and roots as arrays in one call.',
|
|
132
130
|
inputSchema: {
|
|
133
131
|
type: 'object',
|
|
134
132
|
properties: {
|
|
@@ -156,7 +154,7 @@ export const BUILTIN_TOOLS = [
|
|
|
156
154
|
name: 'find',
|
|
157
155
|
title: 'Mixdog Find Files',
|
|
158
156
|
annotations: { title: 'Mixdog Find Files', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
159
|
-
description: 'Find files by partial path/name.
|
|
157
|
+
description: 'Find files by partial path/name. Returns verified paths. Batch names as query[].',
|
|
160
158
|
inputSchema: {
|
|
161
159
|
type: 'object',
|
|
162
160
|
properties: {
|
|
@@ -177,7 +175,7 @@ export const BUILTIN_TOOLS = [
|
|
|
177
175
|
name: 'list',
|
|
178
176
|
title: 'Mixdog List Directory',
|
|
179
177
|
annotations: { title: 'Mixdog List Directory', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
180
|
-
description: 'List known directory entries.
|
|
178
|
+
description: 'List known directory entries. Batch dirs as path[].',
|
|
181
179
|
inputSchema: {
|
|
182
180
|
type: 'object',
|
|
183
181
|
properties: {
|
|
@@ -49,8 +49,13 @@ export function sliceReadBodyByLines(body, origOffset, origLimit) {
|
|
|
49
49
|
: firstLine));
|
|
50
50
|
const totalPart = haveTotal ? ` of ${totalNum}` : '';
|
|
51
51
|
const moreToRead = haveTotal ? emittedLast < totalNum : finiteLast;
|
|
52
|
+
// Anti-fragmentation: modest remainders get the exact one-window
|
|
53
|
+
// continuation (offset+limit) instead of an open-ended "continue".
|
|
54
|
+
const _remaining = haveTotal ? totalNum - emittedLast : null;
|
|
52
55
|
const continuationPart = moreToRead && Number.isFinite(emittedLast)
|
|
53
|
-
?
|
|
56
|
+
? (_remaining !== null && _remaining > 0 && _remaining <= 600
|
|
57
|
+
? `; ${_remaining} lines left — take the rest in ONE window: offset:${emittedLast}, limit:${_remaining}`
|
|
58
|
+
: `; pass offset:${emittedLast} to continue`)
|
|
54
59
|
: '';
|
|
55
60
|
const newFooter = `[lines ${firstLine}-${emittedLast}${totalPart}${continuationPart}]`;
|
|
56
61
|
return kept.join('\n') + (kept.length ? '\n' : '') + newFooter;
|
|
@@ -49,6 +49,20 @@ async function detectReadEncoding(fullPath) {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// Reactive anti-fragmentation merge: per-session (readStateScope) memory of
|
|
53
|
+
// the last requested window per file. When the next windowed read of the
|
|
54
|
+
// same file starts shortly AFTER the previous window (gap <= 200 lines) and
|
|
55
|
+
// the widened result stays modest (<= 400 lines), widen the request to
|
|
56
|
+
// include the gap so the model never needs a third paging call.
|
|
57
|
+
const _readWindowHistoryByScope = new WeakMap();
|
|
58
|
+
function _readWindowHistory(scope) {
|
|
59
|
+
let m = _readWindowHistoryByScope.get(scope);
|
|
60
|
+
if (!m) { m = new Map(); _readWindowHistoryByScope.set(scope, m); }
|
|
61
|
+
return m;
|
|
62
|
+
}
|
|
63
|
+
const _WIDEN_GAP_MAX_LINES = 200;
|
|
64
|
+
const _WIDEN_RESULT_MAX_LINES = 400;
|
|
65
|
+
|
|
52
66
|
export async function executeSingleReadTool(args, workDir, readStateScope, options = {}, helpers = {}) {
|
|
53
67
|
const {
|
|
54
68
|
appendReadContextAdvisory,
|
|
@@ -136,12 +150,13 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
136
150
|
const hasLimitArg = args.limit !== undefined && args.limit !== null;
|
|
137
151
|
const hasRangeArgs = hasOffsetArg || hasLimitArg;
|
|
138
152
|
const wantFull = args.full === true;
|
|
139
|
-
|
|
153
|
+
let offset = parseOffsetArg(args.offset);
|
|
140
154
|
// full:true bypasses the default 2000-line cap so the whole file
|
|
141
155
|
// can be returned in one call; the byte-cap path below still
|
|
142
156
|
// emits a compact truncation marker when rendered bytes overflow
|
|
143
157
|
// READ_MAX_OUTPUT_BYTES.
|
|
144
|
-
|
|
158
|
+
let limit = parseLineLimitArg(args.limit, wantFull ? Infinity : 2000);
|
|
159
|
+
let _widenNote = '';
|
|
145
160
|
let st;
|
|
146
161
|
let _statErr;
|
|
147
162
|
try {
|
|
@@ -200,6 +215,23 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
200
215
|
}
|
|
201
216
|
} catch { /* lstat failure is non-fatal; the original `st` is authoritative */ }
|
|
202
217
|
}
|
|
218
|
+
// Reactive widen + window-history bookkeeping (ranged text reads only).
|
|
219
|
+
if (st && readStateScope && typeof readStateScope === 'object'
|
|
220
|
+
&& hasOffsetArg && hasLimitArg && Number.isFinite(limit) && limit > 0) {
|
|
221
|
+
const _wins = _readWindowHistory(readStateScope);
|
|
222
|
+
const prev = _wins.get(fullPath);
|
|
223
|
+
if (prev && offset > prev.end && limit <= _WIDEN_RESULT_MAX_LINES) {
|
|
224
|
+
const gap = offset - prev.end; // unread lines between windows
|
|
225
|
+
const mergedLines = (offset + limit) - prev.end;
|
|
226
|
+
if (gap <= _WIDEN_GAP_MAX_LINES && mergedLines <= _WIDEN_RESULT_MAX_LINES) {
|
|
227
|
+
_widenNote = `[widened: included the ${gap}-line gap after your last window (lines ${prev.start}-${prev.end}) — one span instead of another page]`;
|
|
228
|
+
offset = prev.end;
|
|
229
|
+
limit = mergedLines;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
_wins.set(fullPath, { start: offset + 1, end: offset + limit });
|
|
233
|
+
if (_wins.size > 64) _wins.delete(_wins.keys().next().value);
|
|
234
|
+
}
|
|
203
235
|
if (!st) {
|
|
204
236
|
const err = _statErr;
|
|
205
237
|
const redirected = await tryReadFamilyEnoentRedirect({
|
|
@@ -639,8 +671,18 @@ export async function executeSingleReadTool(args, workDir, readStateScope, optio
|
|
|
639
671
|
} else if (Buffer.byteLength(rendered, 'utf8') <= READ_MAX_OUTPUT_BYTES) {
|
|
640
672
|
const emittedStart = offset + 1;
|
|
641
673
|
const emittedEnd = offset + sliced.length;
|
|
642
|
-
|
|
674
|
+
// Anti-fragmentation: when the remainder is modest, hand the
|
|
675
|
+
// model the exact one-window continuation instead of inviting
|
|
676
|
+
// another small page.
|
|
677
|
+
const _remaining = lineCount - emittedEnd;
|
|
678
|
+
const _cont = emittedEnd < lineCount
|
|
679
|
+
? (_remaining <= 600
|
|
680
|
+
? `; ${_remaining} lines left — take the rest in ONE window: offset:${emittedEnd}, limit:${_remaining}`
|
|
681
|
+
: `; pass offset:${emittedEnd} to continue`)
|
|
682
|
+
: '';
|
|
683
|
+
const footer = `[lines ${emittedStart}-${emittedEnd} of ${lineCount}${_cont}]`;
|
|
643
684
|
out += `${out ? '\n' : ''}${footer}`;
|
|
685
|
+
if (_widenNote) out += `\n${_widenNote}`;
|
|
644
686
|
}
|
|
645
687
|
}
|
|
646
688
|
// Smart cap. Only engages when the caller asked for
|
|
@@ -163,10 +163,13 @@ export function buildNotFoundHint(workDir, missingPath, actionVerb, errCode = 'E
|
|
|
163
163
|
const parentRel = nearestExistingParentRel(workDir, missingPath);
|
|
164
164
|
if (parentRel) {
|
|
165
165
|
const resolvedParent = parentRel === '.' ? workDir : resolveAgainstCwd(parentRel, workDir);
|
|
166
|
-
|
|
166
|
+
// Compact hint: drop log/artifact noise, keep at most 3 candidates.
|
|
167
|
+
const siblings = listSiblings(resolvedParent, 12)
|
|
168
|
+
.filter((n) => !/\.(log|log\.\d+|tmp|bak)$/i.test(n))
|
|
169
|
+
.slice(0, 3);
|
|
167
170
|
if (siblings.length) {
|
|
168
171
|
const shown = parentRel === '.' ? '.' : normalizeOutputPath(parentRel);
|
|
169
|
-
return ` Not found
|
|
172
|
+
return ` Not found; under "${shown}" try: ${siblings.map((n) => `"${n}"`).join(', ')}.`;
|
|
170
173
|
}
|
|
171
174
|
}
|
|
172
175
|
}
|