mixdog 0.9.17 → 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/build-runtime-windows.ps1 +242 -242
- 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/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/tool-efficiency-diag.mjs +5 -2
- 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/02-channels.md +3 -3
- 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-attachments.mjs +2 -2
- package/src/runtime/channels/backends/discord-gateway.mjs +26 -3
- package/src/runtime/channels/backends/discord.mjs +139 -7
- package/src/runtime/channels/index.mjs +290 -76
- package/src/runtime/channels/lib/crash-log.mjs +21 -3
- 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 +156 -14
- package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
- package/src/runtime/channels/lib/runtime-paths.mjs +48 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
- package/src/runtime/channels/lib/voice-transcription.mjs +4 -2
- 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 +88 -7
- 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 +66 -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 +74 -8
- package/src/tui/components/PromptInput.jsx +33 -64
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +4744 -4806
- package/src/tui/engine.mjs +109 -20
- 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
|
@@ -8,6 +8,91 @@ import { appendAgentTrace } from '../../agent-trace.mjs';
|
|
|
8
8
|
import { level2SteerMessage } from './completion-guards.mjs';
|
|
9
9
|
import { isEagerDispatchable } from './tool-helpers.mjs';
|
|
10
10
|
|
|
11
|
+
// --- Steering detector helpers (pure) ---
|
|
12
|
+
const _WORD_RE = /[A-Za-z_$][\w$]*/g;
|
|
13
|
+
const _IDENT_RE = /^[A-Za-z_$][\w$]{2,}$/;
|
|
14
|
+
const _SRC_DIR_RE = /(^|[\\/])(src|lib|app|packages|components|pages)([\\/]|$)/i;
|
|
15
|
+
function _grepPatterns(c) {
|
|
16
|
+
const p = c?.arguments?.pattern;
|
|
17
|
+
if (typeof p === 'string') return [p];
|
|
18
|
+
if (Array.isArray(p)) return p.filter((x) => typeof x === 'string');
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
function _patternTokens(c) {
|
|
22
|
+
const out = new Set();
|
|
23
|
+
for (const pat of _grepPatterns(c)) {
|
|
24
|
+
for (const t of (String(pat).match(_WORD_RE) || [])) {
|
|
25
|
+
if (t.length >= 3) out.add(t.toLowerCase());
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
function _pathsOf(arg) {
|
|
31
|
+
const out = [];
|
|
32
|
+
const push = (v) => {
|
|
33
|
+
if (typeof v === 'string' && v) out.push(v);
|
|
34
|
+
else if (v && typeof v === 'object' && typeof v.path === 'string' && v.path) out.push(v.path);
|
|
35
|
+
};
|
|
36
|
+
if (Array.isArray(arg)) arg.forEach(push); else push(arg);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
// Case-insensitive (win32 paths) forward-slash normalization for all path compares.
|
|
40
|
+
function _normPath(p) { return String(p).replace(/\\/g, '/').toLowerCase(); }
|
|
41
|
+
function _baseName(p) { const s = _normPath(p); const i = s.lastIndexOf('/'); return i >= 0 ? s.slice(i + 1) : s; }
|
|
42
|
+
function _dirName(p) { const s = _normPath(p); const i = s.lastIndexOf('/'); return i >= 0 ? s.slice(0, i) : ''; }
|
|
43
|
+
// Exact (normalized) same-file test — used where "related" is too loose.
|
|
44
|
+
function _samePath(a, b) { return !!a && !!b && _normPath(a) === _normPath(b); }
|
|
45
|
+
// Loose sibling/variant relation — cluster detector pairs this WITH token overlap.
|
|
46
|
+
function _pathsRelated(a, b) {
|
|
47
|
+
if (!a || !b) return false;
|
|
48
|
+
const na = _normPath(a); const nb = _normPath(b);
|
|
49
|
+
if (na === nb) return true; // identical
|
|
50
|
+
if (na.includes(nb) || nb.includes(na)) return true; // normalized variant
|
|
51
|
+
if (_dirName(na) && _dirName(na) === _dirName(nb)) return true; // siblings
|
|
52
|
+
const ba = _baseName(na); const bb = _baseName(nb);
|
|
53
|
+
return !!ba && ba === bb; // same file name
|
|
54
|
+
}
|
|
55
|
+
function _setsOverlap(a, b) { for (const x of a) { if (b.has(x)) return true; } return false; }
|
|
56
|
+
// A single chained segment is read-only only if it STARTS with a read-only verb.
|
|
57
|
+
function _isReadOnlyShellSegment(seg) {
|
|
58
|
+
const s = seg.trim();
|
|
59
|
+
if (!s) return null; // empty (trailing operator)
|
|
60
|
+
if (/^git\s+(status|log)\b/.test(s)) return true;
|
|
61
|
+
if (/^git\s+diff\b[^\n]*--stat\b/.test(s)) return true;
|
|
62
|
+
if (/^get-childitem\b/.test(s)) return true;
|
|
63
|
+
return /^(cat|ls|dir|type)\b/.test(s);
|
|
64
|
+
}
|
|
65
|
+
// Read-only only when EVERY chained segment is read-only — a single mutating
|
|
66
|
+
// segment (e.g. `git status && npm test`) disqualifies the whole command.
|
|
67
|
+
function _isReadOnlyShellCmd(cmd) {
|
|
68
|
+
const s = String(cmd || '').toLowerCase().trim();
|
|
69
|
+
if (!s) return false;
|
|
70
|
+
let sawCmd = false;
|
|
71
|
+
for (const seg of s.split(/&&|\|\||[;&|]/)) {
|
|
72
|
+
const verdict = _isReadOnlyShellSegment(seg);
|
|
73
|
+
if (verdict === null) continue; // blank segment
|
|
74
|
+
if (verdict === false) return false; // a mutating segment
|
|
75
|
+
sawCmd = true;
|
|
76
|
+
}
|
|
77
|
+
return sawCmd;
|
|
78
|
+
}
|
|
79
|
+
// Extract (path, offset) windows from a read call: path may be a string, an
|
|
80
|
+
// array of strings, or an array of {path,offset,limit} regions; a top-level
|
|
81
|
+
// offset applies to string entries.
|
|
82
|
+
function _readWindows(c) {
|
|
83
|
+
const a = c?.arguments || {};
|
|
84
|
+
const out = [];
|
|
85
|
+
const push = (p, off) => { if (typeof p === 'string' && p) out.push({ path: p, offset: Number(off) }); };
|
|
86
|
+
const v = a.path;
|
|
87
|
+
if (Array.isArray(v)) {
|
|
88
|
+
for (const e of v) {
|
|
89
|
+
if (typeof e === 'string') push(e, a.offset);
|
|
90
|
+
else if (e && typeof e === 'object') push(e.path, e.offset ?? a.offset);
|
|
91
|
+
}
|
|
92
|
+
} else if (typeof v === 'string') push(v, a.offset);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
11
96
|
// Build the completion-first steering-ladder controller. `ctx` supplies live
|
|
12
97
|
// accessors so every read reflects the loop's current mutable state:
|
|
13
98
|
// - messages, sessionId, sessionAgent, tools (stable refs/values)
|
|
@@ -43,13 +128,26 @@ export function createSteeringLadder(ctx) {
|
|
|
43
128
|
// call (missed parallelism). Not reset per-iteration — only by the
|
|
44
129
|
// steering-hint fire below or by a turn that batches/edits.
|
|
45
130
|
let _serialReadOnlyStreak = 0;
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
let
|
|
51
|
-
let
|
|
131
|
+
// Same-concept grep cluster (broadened serial-rewording spiral): counts
|
|
132
|
+
// consecutive grep-only turns that stay on ONE concept — related by
|
|
133
|
+
// identical/normalized/sibling paths OR pattern-token overlap, not just an
|
|
134
|
+
// identical path. `_sameGrepPrev` holds the prior turn's {paths,tokens}.
|
|
135
|
+
let _sameGrepStreak = 0;
|
|
136
|
+
let _sameGrepPrev = null;
|
|
137
|
+
// Prior turn's uncapped content/context grep paths — seeds the
|
|
138
|
+
// grep-context-then-read detector (a read of the same file next turn).
|
|
139
|
+
let _lastGrepContentPaths = null;
|
|
140
|
+
// Consecutive turns grepping the SAME identifier-like token in source dirs
|
|
141
|
+
// (symbol lookups that belong in code_graph).
|
|
142
|
+
let _identGrepStreak = 0;
|
|
143
|
+
let _identGrepToken = null;
|
|
52
144
|
let _level2FireCount = 0;
|
|
145
|
+
// Read-fragmentation detector state: normalized path -> cumulative
|
|
146
|
+
// single-window offset reads. Mirrors session-bench read_fragmentation
|
|
147
|
+
// (3+ windowed reads of one file within an 800-line span). Multi-window
|
|
148
|
+
// same-path regions in ONE call are the recommended batched form and are
|
|
149
|
+
// never counted. Fires once per path per session.
|
|
150
|
+
const _readWindowsByPath = new Map();
|
|
53
151
|
|
|
54
152
|
// Level-2 steering emitter shared by both ladder paths (single-call
|
|
55
153
|
// level-1 streak and the independent all-read-only streak). Sets the latch
|
|
@@ -78,14 +176,14 @@ export function createSteeringLadder(ctx) {
|
|
|
78
176
|
const iterations = getIterations();
|
|
79
177
|
const editCount = getEditCount();
|
|
80
178
|
// Steering hint gate: at most ONE hint per turn (priority:
|
|
81
|
-
// level-2 >
|
|
179
|
+
// level-2 > grep/shell detectors > level-1).
|
|
82
180
|
let _hintFiredThisTurn = hintAlreadyFired;
|
|
83
|
-
// Missed-parallelism steering:
|
|
181
|
+
// Missed-parallelism steering: 2+ consecutive turns of a single
|
|
84
182
|
// read-only tool call suggest the model isn't batching independent
|
|
85
|
-
// lookups. Nudge once, then reset (fires again after
|
|
183
|
+
// lookups. Nudge once, then reset (fires again after 2 more).
|
|
86
184
|
if (calls.length === 1 && isEagerDispatchable(calls[0].name, tools)) {
|
|
87
185
|
_serialReadOnlyStreak += 1;
|
|
88
|
-
if (_serialReadOnlyStreak >=
|
|
186
|
+
if (_serialReadOnlyStreak >= 2 && !_hintFiredThisTurn) {
|
|
89
187
|
_serialReadOnlyStreak = 0;
|
|
90
188
|
// Escalation ladder (Step 1). Cumulative level-1 fires are
|
|
91
189
|
// tracked and NEVER reset. Once level-1 has fired >=3 times with
|
|
@@ -96,7 +194,7 @@ export function createSteeringLadder(ctx) {
|
|
|
96
194
|
if (_level1FireCount >= 3 && editCount === 0 && (iterations - _level2LatchAtIteration) >= 5) {
|
|
97
195
|
_emitLevel2Steer();
|
|
98
196
|
} else {
|
|
99
|
-
pushSystemReminder('Last
|
|
197
|
+
pushSystemReminder('Last 2 turns each ran a single read-only tool. Batch independent lookups (read/grep/glob/code_graph) into ONE turn, or start editing if you have enough context.');
|
|
100
198
|
}
|
|
101
199
|
_hintFiredThisTurn = true;
|
|
102
200
|
}
|
|
@@ -121,36 +219,153 @@ export function createSteeringLadder(ctx) {
|
|
|
121
219
|
_allReadOnlyStreak = 0;
|
|
122
220
|
}
|
|
123
221
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
//
|
|
222
|
+
const _grepCalls = calls.filter((c) => c?.name === 'grep');
|
|
223
|
+
const _allGrep = calls.length > 0 && _grepCalls.length === calls.length;
|
|
224
|
+
// Detector: repeated identifier-like grep in source dirs → code_graph.
|
|
225
|
+
// Symbol lookups (a bare \w+ token scoped to src/) belong in
|
|
226
|
+
// code_graph (find_symbol/references/callers), not text grep. Fires
|
|
227
|
+
// when the same token is grepped on 2+ consecutive qualifying turns.
|
|
128
228
|
{
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (
|
|
137
|
-
if (
|
|
138
|
-
else {
|
|
139
|
-
if (
|
|
140
|
-
pushSystemReminder(`
|
|
141
|
-
|
|
142
|
-
|
|
229
|
+
let _identTok = null;
|
|
230
|
+
for (const c of _grepCalls) {
|
|
231
|
+
const _srcScoped = _pathsOf(c?.arguments?.path).some((p) => _SRC_DIR_RE.test(p));
|
|
232
|
+
if (!_srcScoped) continue;
|
|
233
|
+
const _hit = _grepPatterns(c).map((p) => p.trim()).find((p) => _IDENT_RE.test(p));
|
|
234
|
+
if (_hit) { _identTok = _hit.toLowerCase(); break; }
|
|
235
|
+
}
|
|
236
|
+
if (_identTok) {
|
|
237
|
+
if (_identTok === _identGrepToken) _identGrepStreak += 1;
|
|
238
|
+
else { _identGrepToken = _identTok; _identGrepStreak = 1; }
|
|
239
|
+
if (_identGrepStreak >= 2 && !_hintFiredThisTurn) {
|
|
240
|
+
pushSystemReminder(`Grepping the symbol "${_identGrepToken}" across source dirs again — use code_graph (find_symbol/references/callers) for symbol lookups instead of text grep.`);
|
|
241
|
+
_identGrepStreak = 0;
|
|
242
|
+
_identGrepToken = null;
|
|
243
|
+
_hintFiredThisTurn = true;
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
_identGrepStreak = 0;
|
|
247
|
+
_identGrepToken = null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Same-concept grep cluster (broadened serial-rewording): 3+
|
|
251
|
+
// consecutive grep-only turns on one concept — related by
|
|
252
|
+
// an exact-same path, OR a sibling/variant path that ALSO shares a
|
|
253
|
+
// pattern token (sibling alone is too loose to count).
|
|
254
|
+
// Fires once per spiral, then resets; read-the-span is the answer.
|
|
255
|
+
{
|
|
256
|
+
if (_allGrep) {
|
|
257
|
+
const _paths = [];
|
|
258
|
+
const _tokens = new Set();
|
|
259
|
+
for (const c of _grepCalls) {
|
|
260
|
+
for (const p of _pathsOf(c?.arguments?.path)) _paths.push(p);
|
|
261
|
+
for (const t of _patternTokens(c)) _tokens.add(t);
|
|
262
|
+
}
|
|
263
|
+
let _related = false;
|
|
264
|
+
if (_sameGrepPrev) {
|
|
265
|
+
// Exact same-path spiral fires alone; a merely sibling/
|
|
266
|
+
// variant path must ALSO share a pattern token to count.
|
|
267
|
+
const _pathExact = _paths.some((a) => _sameGrepPrev.paths.some((b) => _samePath(a, b)));
|
|
268
|
+
const _pathRel = _paths.some((a) => _sameGrepPrev.paths.some((b) => _pathsRelated(a, b)));
|
|
269
|
+
const _tokRel = _setsOverlap(_tokens, _sameGrepPrev.tokens);
|
|
270
|
+
_related = _pathExact || (_pathRel && _tokRel);
|
|
271
|
+
}
|
|
272
|
+
_sameGrepStreak = _related ? _sameGrepStreak + 1 : 1;
|
|
273
|
+
const _label = _paths[0] || [..._tokens][0] || 'same concept';
|
|
274
|
+
_sameGrepPrev = { paths: _paths, tokens: _tokens };
|
|
275
|
+
if (_sameGrepStreak >= 3 && !_hintFiredThisTurn) {
|
|
276
|
+
pushSystemReminder(`3+ consecutive grep turns on the same concept (${_label}) — reworded patterns / sibling paths are not converging. Read the relevant span (read with offset/limit) or act on what you have.`);
|
|
277
|
+
_sameGrepStreak = 0;
|
|
278
|
+
_sameGrepPrev = null;
|
|
143
279
|
_hintFiredThisTurn = true;
|
|
144
280
|
}
|
|
145
281
|
} else {
|
|
146
|
-
|
|
147
|
-
|
|
282
|
+
_sameGrepStreak = 0;
|
|
283
|
+
_sameGrepPrev = null;
|
|
148
284
|
}
|
|
149
285
|
}
|
|
286
|
+
// Detector: content/context grep on X then read of X next turn — the
|
|
287
|
+
// grep context should have sufficed. Fire off the PRIOR turn's
|
|
288
|
+
// uncapped content grep, then recompute for the next turn (skip when
|
|
289
|
+
// the grep was capped via head_limit or paths-only mode).
|
|
290
|
+
{
|
|
291
|
+
if (_lastGrepContentPaths && !_hintFiredThisTurn) {
|
|
292
|
+
const _readCall = calls.find((c) => c?.name === 'read');
|
|
293
|
+
if (_readCall) {
|
|
294
|
+
const _rp = _pathsOf(_readCall?.arguments?.path);
|
|
295
|
+
// Same-file only — a sibling read is a new lookup, not a re-read.
|
|
296
|
+
const _hit = _rp.find((p) => [..._lastGrepContentPaths].some((g) => _samePath(p, g)));
|
|
297
|
+
if (_hit) {
|
|
298
|
+
pushSystemReminder(`Reading ${_baseName(_hit)} right after a content grep on it — the grep context should have sufficed; answer from grep output or widen -C instead of re-reading.`);
|
|
299
|
+
_hintFiredThisTurn = true;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const _next = new Set();
|
|
304
|
+
for (const c of _grepCalls) {
|
|
305
|
+
const a = c?.arguments || {};
|
|
306
|
+
const _hasCtx = a['-A'] != null || a['-B'] != null || a['-C'] != null;
|
|
307
|
+
const _isContent = a.output_mode === 'content' || a.output_mode === 'content_with_context' || _hasCtx;
|
|
308
|
+
const _pathsOnly = a.output_mode === 'files_with_matches' || a.output_mode === 'count';
|
|
309
|
+
const _capped = a.head_limit != null;
|
|
310
|
+
if (_isContent && !_pathsOnly && !_capped) {
|
|
311
|
+
for (const p of _pathsOf(a.path)) _next.add(p);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
_lastGrepContentPaths = _next.size ? _next : null;
|
|
315
|
+
}
|
|
316
|
+
// Detector: read fragmentation — 2nd+ single-window read into the
|
|
317
|
+
// SAME file with offsets inside an 800-line span means the model is
|
|
318
|
+
// paging small windows across turns instead of reading one wider
|
|
319
|
+
// span (or batching {path,offset,limit}[] regions in one call).
|
|
320
|
+
{
|
|
321
|
+
for (const c of calls.filter((x) => x?.name === 'read')) {
|
|
322
|
+
const _byPath = new Map();
|
|
323
|
+
for (const w of _readWindows(c)) {
|
|
324
|
+
const k = _normPath(w.path);
|
|
325
|
+
if (!_byPath.has(k)) _byPath.set(k, []);
|
|
326
|
+
_byPath.get(k).push(w);
|
|
327
|
+
}
|
|
328
|
+
for (const [k, ws] of _byPath) {
|
|
329
|
+
if (ws.length !== 1) continue; // batched regions on one path — the good form
|
|
330
|
+
const off = ws[0].offset;
|
|
331
|
+
if (!Number.isFinite(off) || off <= 0) continue;
|
|
332
|
+
let rec = _readWindowsByPath.get(k);
|
|
333
|
+
if (!rec) {
|
|
334
|
+
rec = { offsets: [], fired: false };
|
|
335
|
+
_readWindowsByPath.set(k, rec);
|
|
336
|
+
if (_readWindowsByPath.size > 32) _readWindowsByPath.delete(_readWindowsByPath.keys().next().value);
|
|
337
|
+
}
|
|
338
|
+
rec.offsets.push(off);
|
|
339
|
+
if (!rec.fired && !_hintFiredThisTurn && rec.offsets.length >= 2
|
|
340
|
+
&& (Math.max(...rec.offsets) - Math.min(...rec.offsets)) <= 800) {
|
|
341
|
+
pushSystemReminder(`Windowed reads are fragmenting ${_baseName(k)} — stop paging small windows; read ONE wider span, or batch all needed spans as {path,offset,limit}[] regions in a single call.`);
|
|
342
|
+
rec.fired = true;
|
|
343
|
+
_hintFiredThisTurn = true;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Detector: read-only shell (git status/log/diff --stat, ls/dir/cat/
|
|
349
|
+
// type/Get-ChildItem) inspects state the dedicated tools cover.
|
|
350
|
+
// Nudge toward them; never blocks execution.
|
|
351
|
+
{
|
|
352
|
+
if (!_hintFiredThisTurn && calls.some((c) => c?.name === 'shell' && _isReadOnlyShellCmd(c?.arguments?.command))) {
|
|
353
|
+
pushSystemReminder('Read-only shell (git status/log/diff --stat, ls/dir/cat/type/Get-ChildItem) inspects state the dedicated tools cover — use grep/read/list/find/code_graph; shell is for changing state or running programs.');
|
|
354
|
+
_hintFiredThisTurn = true;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
// Reviewer fix: a zero-tool turn must not bridge ANY cross-turn streak
|
|
359
|
+
// across non-tool turns — that would fire level-2 (or the grep/read
|
|
360
|
+
// detectors) early on a worker that paused to synthesize text mid-run.
|
|
361
|
+
resetAllReadOnlyStreak() {
|
|
362
|
+
_allReadOnlyStreak = 0;
|
|
363
|
+
_serialReadOnlyStreak = 0;
|
|
364
|
+
_sameGrepStreak = 0;
|
|
365
|
+
_sameGrepPrev = null;
|
|
366
|
+
_lastGrepContentPaths = null;
|
|
367
|
+
_identGrepStreak = 0;
|
|
368
|
+
_identGrepToken = null;
|
|
150
369
|
},
|
|
151
|
-
// Reviewer fix: a zero-tool turn must not bridge the all-read-only
|
|
152
|
-
// streak across non-tool turns — that would fire level-2 early on a
|
|
153
|
-
// worker that paused to synthesize text mid-run.
|
|
154
|
-
resetAllReadOnlyStreak() { _allReadOnlyStreak = 0; },
|
|
155
370
|
};
|
|
156
371
|
}
|
|
@@ -87,10 +87,10 @@ function stringToolPermissionAllowList(toolPermission) {
|
|
|
87
87
|
return null;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
// Read-write agent bundle: edit surface
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
90
|
+
// Read-write agent bundle: full edit surface INCLUDING shell/task so
|
|
91
|
+
// write-role agents can run their own verification (build/test) without
|
|
92
|
+
// bouncing it back to Lead. Deploy/ship remains a workflow-level rule,
|
|
93
|
+
// not a tool-surface restriction.
|
|
94
94
|
const AGENT_STRING_PERMISSION_READ_WRITE_ALLOW = Object.freeze([
|
|
95
95
|
'code_graph',
|
|
96
96
|
'find',
|
|
@@ -99,6 +99,8 @@ const AGENT_STRING_PERMISSION_READ_WRITE_ALLOW = Object.freeze([
|
|
|
99
99
|
'grep',
|
|
100
100
|
'read',
|
|
101
101
|
'apply_patch',
|
|
102
|
+
'shell',
|
|
103
|
+
'task',
|
|
102
104
|
'explore',
|
|
103
105
|
'search',
|
|
104
106
|
'web_fetch',
|
|
@@ -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
|
|