acuvo-code 0.2.0
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/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐ WINDOWED READS — THE TRUNCATION THAT DROPS THE MIDDLE, INVERTED ───────
|
|
3
|
+
*
|
|
4
|
+
* `read_file` returns a whole file, and `turn.mjs` then hands it to
|
|
5
|
+
* `clampOutput` with an 8,000-character budget. `clampOutput` is RIGHT for a
|
|
6
|
+
* command's stdout — a test runner prints the assertion near the top and the
|
|
7
|
+
* counts at the bottom, so both ends matter and the middle rarely does. Applied
|
|
8
|
+
* to SOURCE CODE it is exactly backwards: the middle is where the function you
|
|
9
|
+
* are looking for lives.
|
|
10
|
+
*
|
|
11
|
+
* ── WHAT THAT COST, MEASURED ────────────────────────────────────────────────
|
|
12
|
+
* Probe 4 asked for `lib/tools.mjs`. The model — unprompted, without ever being
|
|
13
|
+
* told the argument existed — sent `{path, offset:1560, limit:80}`, then 1380,
|
|
14
|
+
* then 1395, then 1280. Every one came back `ok:true` with a BYTE-IDENTICAL
|
|
15
|
+
* 8,063-character blob, because the offset was ignored and the clamp always cut
|
|
16
|
+
* the same hole: 13,692 characters omitted, with `executeToolCall` at line 290
|
|
17
|
+
* of 458 sitting inside it. The model could see the file was truncated, could
|
|
18
|
+
* not see that its paging was a no-op, and burned four rounds proving it. Probes
|
|
19
|
+
* 1 and 3 hit the same wall from other angles ("The output is being truncated in
|
|
20
|
+
* the middle."). Three of five control runs ended `NOTHING WAS RUN` — all eight
|
|
21
|
+
* rounds spent, ~480k tokens, zero output.
|
|
22
|
+
*
|
|
23
|
+
* ⭐ THE ONE PROBE THAT ADDED offset/limit — about fifteen lines — made every
|
|
24
|
+
* repeated read vanish. That is a control, not a correlation, and it is why this
|
|
25
|
+
* module exists.
|
|
26
|
+
*
|
|
27
|
+
* ── THE RULE THIS FILE ENFORCES ─────────────────────────────────────────────
|
|
28
|
+
* 1. Truncate at the END, on a line boundary, and hand back `nextOffset`.
|
|
29
|
+
* Never the middle. A model that is told where it stopped pages forward;
|
|
30
|
+
* a model handed a hole re-reads the same blob until the budget dies.
|
|
31
|
+
* 2. Always report `totalLines`, so paging is arithmetic instead of a guess.
|
|
32
|
+
*
|
|
33
|
+
* ── ⚠️ AND THE SECOND HALF, WHICH IS A DIFFERENT BUG ENTIRELY ───────────────
|
|
34
|
+
* `search_text` returns `line.trim()`. Probe 3 took a hit at `lib/git.mjs:282`,
|
|
35
|
+
* built an `edit_file` `old_string` from it, GUESSED six leading spaces where
|
|
36
|
+
* the file has two, was refused, ran out of rounds, and shipped a half-migrated
|
|
37
|
+
* refactor that all 124 tests still passed on. Probe 3's own sub-probe lost its
|
|
38
|
+
* run to the identical guess.
|
|
39
|
+
*
|
|
40
|
+
* So `read_around` exists to be the place an `old_string` is COPIED from, and
|
|
41
|
+
* the single most important property of everything below is that the text comes
|
|
42
|
+
* back VERBATIM: original indentation, original trailing whitespace, no
|
|
43
|
+
* line-number gutter, no ellipsis. Every convenience that rewrites a byte has
|
|
44
|
+
* been left out on purpose. The gutter is available behind `numbered:true`, and
|
|
45
|
+
* even then the result carries `exact` beside it — because the moment a model
|
|
46
|
+
* has only the pretty version, it copies the pretty version.
|
|
47
|
+
*
|
|
48
|
+
* ── BYTE-EXACTNESS IS A CONCATENATION PROPERTY, NOT A VIBE ──────────────────
|
|
49
|
+
* A window's `text` is the original substring from the first line's start to the
|
|
50
|
+
* last line's end INCLUDING its terminator. So `\r\n` survives, a file with no
|
|
51
|
+
* final newline survives, and pages read back-to-back concatenate into the
|
|
52
|
+
* original file byte for byte with nothing added between them. The suite asserts
|
|
53
|
+
* that against a 2,000-line fixture rather than trusting the sentence.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import { closeSync, openSync, readSync, statSync } from 'node:fs';
|
|
57
|
+
import { basename } from 'node:path';
|
|
58
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
59
|
+
|
|
60
|
+
import { resolveInWorkspace } from './workspace.mjs';
|
|
61
|
+
import { refusedCommitPath } from './secret-paths.mjs';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* ⚠️ 8 MB, AND IT IS NOT `MAX_READ_BYTES`. `workspace.mjs` caps a read at
|
|
65
|
+
* 200,000 bytes because `read_file` returns the WHOLE file and anything larger
|
|
66
|
+
* would displace the conversation. That reasoning does not transfer: a window is
|
|
67
|
+
* bounded by `maxChars` no matter how big the file is, so the whole-file limit
|
|
68
|
+
* would refuse exactly the files this tool was built for. The cap here is about
|
|
69
|
+
* MEMORY — nothing below ever holds more than one 64 KB chunk plus the requested
|
|
70
|
+
* window, so 8 MB is the largest file we are willing to walk twice.
|
|
71
|
+
*/
|
|
72
|
+
export const MAX_WINDOW_FILE_BYTES = 8 * 1024 * 1024;
|
|
73
|
+
export const DEFAULT_LIMIT = 120;
|
|
74
|
+
export const MAX_LIMIT = 400;
|
|
75
|
+
export const DEFAULT_CONTEXT = 8;
|
|
76
|
+
export const MAX_CONTEXT = 40;
|
|
77
|
+
export const DEFAULT_MAX_BLOCKS = 6;
|
|
78
|
+
export const MAX_BLOCKS = 10;
|
|
79
|
+
/** Matches turn.mjs's MAX_TOOL_RESULT_CHARS on purpose — this module's whole
|
|
80
|
+
* claim is that it is the same budget spent at the END instead of the middle. */
|
|
81
|
+
export const DEFAULT_MAX_CHARS = 8_000;
|
|
82
|
+
export const HARD_MAX_CHARS = 12_000;
|
|
83
|
+
export const MAX_PATTERN_CHARS = 200;
|
|
84
|
+
export const SCAN_BUDGET_MS = 2_000;
|
|
85
|
+
export const BINARY_SNIFF_BYTES = 8 * 1024;
|
|
86
|
+
/** Hits are line NUMBERS, four bytes of thought each, but a pattern like `.`
|
|
87
|
+
* matches every line of an 8 MB file and the array is what would blow up. */
|
|
88
|
+
export const MAX_HITS = 500;
|
|
89
|
+
|
|
90
|
+
const READ_LINES_KEYS = ['path', 'offset', 'limit', 'numbered', 'maxChars'];
|
|
91
|
+
const READ_AROUND_KEYS = ['path', 'pattern', 'context', 'ignoreCase', 'maxBlocks', 'maxChars'];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* ⚠️ THIS BLOCK USED TO SAY "STRICTER THAN `read_file`, DELIBERATELY" — and it
|
|
95
|
+
* was honest at the time. It said `executor.readFile` would happily hand a model
|
|
96
|
+
* `.env.local`, called the divergence intentional, and left narrowing the old
|
|
97
|
+
* door as "a separate, deliberate decision, not something to slip in under a
|
|
98
|
+
* different patch."
|
|
99
|
+
*
|
|
100
|
+
* ⭐ That decision was taken on 2026-08-13 and the divergence is GONE. `read_file`
|
|
101
|
+
* now consults the same list through the tool dispatcher, so the two verbs cannot
|
|
102
|
+
* give different answers about the same file. The old asymmetry was not defence in
|
|
103
|
+
* depth: it meant which of a user's secrets were protected depended on which verb
|
|
104
|
+
* the model happened to reach for.
|
|
105
|
+
*
|
|
106
|
+
* ⚠️ `.env*` INCLUDES `.env.example`, which is usually harmless. Refusing it is
|
|
107
|
+
* the cost of a rule with no exceptions to argue about, and the alternative —
|
|
108
|
+
* "harmless-looking .env variants are fine" — is how the rule dies.
|
|
109
|
+
*/
|
|
110
|
+
/**
|
|
111
|
+
* ── ⚠️⚠️ THERE USED TO BE A SECOND LIST HERE, AND IT DISAGREED ──────────────
|
|
112
|
+
*
|
|
113
|
+
* `CREDENTIAL_BASENAME` lived in this file and guarded `read_lines` and
|
|
114
|
+
* `read_around`, while `NEVER_COMMIT` in `git.mjs` guarded the pre-load,
|
|
115
|
+
* `search_text`, `repo-map` and `session`. Written separately, they diverged.
|
|
116
|
+
* Measured 2026-08-13 through the real dispatcher:
|
|
117
|
+
*
|
|
118
|
+
* read_lines LEAKED : vault.pfx · keys.jks · secrets.json · credentials.yml
|
|
119
|
+
* · service-account.json (absent from this list)
|
|
120
|
+
* read_file LEAKED : .git-credentials (absent from that one)
|
|
121
|
+
*
|
|
122
|
+
* ⭐ So which of a user's secrets were protected depended on WHICH VERB the
|
|
123
|
+
* model happened to pick. Two guards for one rule is not defence in depth, it is
|
|
124
|
+
* two half-answers — and each per-tool test passed, about its own list.
|
|
125
|
+
*
|
|
126
|
+
* `refusedCommitPath` is now the only list; `.git-credentials`, which only this
|
|
127
|
+
* file had, was folded into it. ⚠️ Do not reintroduce a local list here: add the
|
|
128
|
+
* pattern in `git.mjs` and every consumer gains it at once.
|
|
129
|
+
*/
|
|
130
|
+
function credentialRefusal(base) {
|
|
131
|
+
if (refusedCommitPath(base) === null) return null;
|
|
132
|
+
return `this tool does not return credential files, and "${base}" is one — read the code that consumes the variable instead, or use search_text to confirm the NAME exists.`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* ⚠️ THE UNKNOWN-KEY REFUSAL, WHICH IS THE SECOND-ORDER LESSON OF PROBE 2.
|
|
137
|
+
*
|
|
138
|
+
* `read_file` accepted `{path, offset, limit}` and silently ignored two of the
|
|
139
|
+
* three. The model was not told it had been ignored, so it did the reasonable
|
|
140
|
+
* thing: concluded the FILE was broken and spent four rounds working around a
|
|
141
|
+
* problem that did not exist. A silent success on an argument you do not
|
|
142
|
+
* implement is worse than any refusal, because the model cannot see it.
|
|
143
|
+
*
|
|
144
|
+
* So: an argument this module does not understand is a hard stop, naming the
|
|
145
|
+
* ones it does. Pure.
|
|
146
|
+
*/
|
|
147
|
+
function unknownKeyRefusal(tool, args, allowed) {
|
|
148
|
+
for (const key of Object.keys(args)) {
|
|
149
|
+
if (!allowed.includes(key)) {
|
|
150
|
+
return `${tool} accepts only ${allowed.join(', ')} — it does not accept "${key}".`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* ── ⚠️⚠️ THE ONLY THING THAT ACTUALLY STOPS A HANG, AND IT WAS MEASURED ─────
|
|
158
|
+
*
|
|
159
|
+
* The first version of this file relied on a wall-clock check between lines. It
|
|
160
|
+
* was WRONG, and a real run proved it inside a minute: `(z+z+)+Q` against a
|
|
161
|
+
* 7.5 MB fixture ran for over FIVE MINUTES and had to be killed. A JavaScript
|
|
162
|
+
* regex is uninterruptible once `test()` is entered, so a clock check between
|
|
163
|
+
* lines cannot help when a SINGLE line is the thing that never returns — and one
|
|
164
|
+
* 60-character run of `z` is enough for that pattern to go exponential.
|
|
165
|
+
*
|
|
166
|
+
* ⭐ So the budget is enforced BEFORE the scan, statically, by refusing the
|
|
167
|
+
* shape: a quantified group whose body also contains a quantifier — `(a+)+`,
|
|
168
|
+
* `(a*)*`, `(a?)+`, `(a{2,})+`. That is the catastrophic-backtracking family,
|
|
169
|
+
* and it is the one class a timeout provably cannot rescue us from.
|
|
170
|
+
*
|
|
171
|
+
* ⚠️ IT IS A HEURISTIC AND IT REFUSES SOME HARMLESS PATTERNS. That trade is the
|
|
172
|
+
* right way round: a refused pattern costs one round and says exactly what to
|
|
173
|
+
* write instead, while the alternative was a terminal that sat there for five
|
|
174
|
+
* minutes looking like the tool was broken. `search.mjs` has the same exposure
|
|
175
|
+
* and does not check — worth flagging separately, not worth widening this patch.
|
|
176
|
+
*
|
|
177
|
+
* Pure. Escapes and character classes are honoured so `\(` and `[+*]` cannot be
|
|
178
|
+
* mistaken for structure.
|
|
179
|
+
*/
|
|
180
|
+
export function nestedQuantifier(source) {
|
|
181
|
+
const stack = [];
|
|
182
|
+
const QUANT_BRACE = /^\{\d+(,\d*)?\}/;
|
|
183
|
+
let inClass = false;
|
|
184
|
+
for (let i = 0; i < source.length; i++) {
|
|
185
|
+
const c = source[i];
|
|
186
|
+
if (c === '\\') { i += 1; continue; }
|
|
187
|
+
if (inClass) { if (c === ']') inClass = false; continue; }
|
|
188
|
+
if (c === '[') { inClass = true; continue; }
|
|
189
|
+
|
|
190
|
+
if (c === '(') {
|
|
191
|
+
stack.push({ quantifierInside: false });
|
|
192
|
+
// `(?:`, `(?=`, `(?!`, `(?<=`, `(?<!`, `(?<name>` — the `?` is a group
|
|
193
|
+
// modifier here, and counting it as a quantifier would refuse every
|
|
194
|
+
// non-capturing group in the world.
|
|
195
|
+
if (source[i + 1] === '?') {
|
|
196
|
+
i += 1;
|
|
197
|
+
const n = source[i + 1];
|
|
198
|
+
if (n === '<') {
|
|
199
|
+
const close = source.indexOf('>', i + 1);
|
|
200
|
+
if (close !== -1 && /^[A-Za-z_$]/.test(source[i + 2] ?? '')) i = close;
|
|
201
|
+
else i += 1;
|
|
202
|
+
} else if (n === ':' || n === '=' || n === '!') i += 1;
|
|
203
|
+
}
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (c === ')') {
|
|
207
|
+
const group = stack.pop();
|
|
208
|
+
if (!group) continue; // unbalanced — the RegExp constructor reports it
|
|
209
|
+
const next = source[i + 1];
|
|
210
|
+
const outerQuantified = next === '*' || next === '+' || (next === '{' && QUANT_BRACE.test(source.slice(i + 1)));
|
|
211
|
+
if (outerQuantified && group.quantifierInside) return true;
|
|
212
|
+
const parent = stack[stack.length - 1];
|
|
213
|
+
// A quantifier anywhere in a child counts for the parent: `((a+))+ ` is
|
|
214
|
+
// the same bomb with one more layer of parentheses around it.
|
|
215
|
+
if (parent && (group.quantifierInside || outerQuantified)) parent.quantifierInside = true;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (c === '*' || c === '+' || c === '?' || (c === '{' && QUANT_BRACE.test(source.slice(i)))) {
|
|
219
|
+
const top = stack[stack.length - 1];
|
|
220
|
+
if (top) top.quantifierInside = true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** An integer, or null. Rejects 12.5 and "12" rather than coercing — a coerced
|
|
227
|
+
* argument is the silent-acceptance failure above wearing a different hat. */
|
|
228
|
+
function asInteger(v) {
|
|
229
|
+
return typeof v === 'number' && Number.isInteger(v) ? v : null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function clampMaxChars(raw) {
|
|
233
|
+
if (raw === undefined) return { ok: true, value: DEFAULT_MAX_CHARS };
|
|
234
|
+
const n = asInteger(raw);
|
|
235
|
+
if (n === null || n < 200 || n > HARD_MAX_CHARS) {
|
|
236
|
+
return { ok: false, error: `maxChars must be a whole number between 200 and ${HARD_MAX_CHARS} — omit it for the ${DEFAULT_MAX_CHARS} default and page with offset instead.` };
|
|
237
|
+
}
|
|
238
|
+
return { ok: true, value: n };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** A NUL in the first block means binary, whatever the extension says. Same
|
|
242
|
+
* heuristic `readFile` and `searchText` use, so all three agree on what text is. */
|
|
243
|
+
function looksBinary(absolute, size) {
|
|
244
|
+
if (size === 0) return false;
|
|
245
|
+
const want = Math.min(size, BINARY_SNIFF_BYTES);
|
|
246
|
+
const buf = Buffer.allocUnsafe(want);
|
|
247
|
+
let fd;
|
|
248
|
+
try {
|
|
249
|
+
fd = openSync(absolute, 'r');
|
|
250
|
+
const got = readSync(fd, buf, 0, want, 0);
|
|
251
|
+
return buf.subarray(0, got).includes(0);
|
|
252
|
+
} catch {
|
|
253
|
+
return false; // unreadable is a different failure, reported by the caller
|
|
254
|
+
} finally {
|
|
255
|
+
if (fd !== undefined) closeSync(fd);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Everything both tools must agree on before a byte is read: inside the
|
|
261
|
+
* workspace, not a credential, not a directory, not enormous, not binary.
|
|
262
|
+
*
|
|
263
|
+
* Returns data, never throws.
|
|
264
|
+
*/
|
|
265
|
+
function prepare(root, rawPath) {
|
|
266
|
+
const r = resolveInWorkspace(root, rawPath, 'read');
|
|
267
|
+
if (!r.ok) return { ok: false, error: r.reason };
|
|
268
|
+
|
|
269
|
+
const base = basename(r.relative);
|
|
270
|
+
const cred = credentialRefusal(base);
|
|
271
|
+
if (cred) return { ok: false, error: cred };
|
|
272
|
+
|
|
273
|
+
let stat;
|
|
274
|
+
try {
|
|
275
|
+
stat = statSync(r.absolute);
|
|
276
|
+
} catch {
|
|
277
|
+
return { ok: false, error: `no such file: ${r.relative} — use find_files to locate it before reading it.` };
|
|
278
|
+
}
|
|
279
|
+
if (stat.isDirectory()) return { ok: false, error: `${r.relative} is a directory — use list_dir.` };
|
|
280
|
+
if (stat.size > MAX_WINDOW_FILE_BYTES) {
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
error: `${r.relative} is ${stat.size} bytes, over the ${MAX_WINDOW_FILE_BYTES}-byte (8 MB) window limit — no tool here opens a file that large; if it is generated output, read the generator instead.`,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
if (looksBinary(r.absolute, stat.size)) {
|
|
287
|
+
return { ok: false, error: `${r.relative} has a NUL byte in its first 8 KB, so it is not text — this tool returns text only.` };
|
|
288
|
+
}
|
|
289
|
+
return { ok: true, relative: r.relative, absolute: r.absolute, totalBytes: stat.size };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Walk a file line by line, holding one 64 KB chunk plus the current line.
|
|
294
|
+
*
|
|
295
|
+
* ⚠️ EACH `raw` KEEPS ITS TERMINATOR. That is the whole byte-exactness story:
|
|
296
|
+
* `\r\n` is preserved rather than normalised, a file with no final newline
|
|
297
|
+
* yields a last line with none, and concatenating consecutive windows needs no
|
|
298
|
+
* separator inserted — which is the only version of "byte-for-byte" that can
|
|
299
|
+
* actually be asserted in a test.
|
|
300
|
+
*
|
|
301
|
+
* ⚠️ AND IT IS A GENERATOR, not `readFileSync().split('\n')`, because the split
|
|
302
|
+
* version holds the file AND an array of every line — roughly 3× the file — at
|
|
303
|
+
* the exact moment we are trying to prove we do not.
|
|
304
|
+
*/
|
|
305
|
+
function* iterateLines(absolute, size) {
|
|
306
|
+
if (size === 0) return;
|
|
307
|
+
const CHUNK = 64 * 1024;
|
|
308
|
+
const buf = Buffer.allocUnsafe(Math.min(CHUNK, size));
|
|
309
|
+
const decoder = new StringDecoder('utf8');
|
|
310
|
+
let fd;
|
|
311
|
+
try {
|
|
312
|
+
fd = openSync(absolute, 'r');
|
|
313
|
+
let pos = 0;
|
|
314
|
+
let pending = '';
|
|
315
|
+
let lineNo = 0;
|
|
316
|
+
while (pos < size) {
|
|
317
|
+
const got = readSync(fd, buf, 0, Math.min(buf.length, size - pos), pos);
|
|
318
|
+
if (got <= 0) break;
|
|
319
|
+
pos += got;
|
|
320
|
+
pending += decoder.write(buf.subarray(0, got));
|
|
321
|
+
/**
|
|
322
|
+
* ⚠️ A CURSOR, NOT `pending = pending.slice(...)` PER LINE. The obvious
|
|
323
|
+
* version re-allocates the remaining buffer once per line — measured on a
|
|
324
|
+
* 7.5 MB / 100k-line fixture it churned 14.8 MB of garbage for a five-line
|
|
325
|
+
* window. Compacting ONCE per 64 KB chunk instead keeps the retained set
|
|
326
|
+
* at one chunk and drops the churn to the lines actually yielded.
|
|
327
|
+
*/
|
|
328
|
+
let cursor = 0;
|
|
329
|
+
let idx = pending.indexOf('\n', cursor);
|
|
330
|
+
while (idx !== -1) {
|
|
331
|
+
lineNo += 1;
|
|
332
|
+
yield { number: lineNo, raw: pending.slice(cursor, idx + 1) };
|
|
333
|
+
cursor = idx + 1;
|
|
334
|
+
idx = pending.indexOf('\n', cursor);
|
|
335
|
+
}
|
|
336
|
+
if (cursor > 0) pending = pending.slice(cursor);
|
|
337
|
+
}
|
|
338
|
+
pending += decoder.end();
|
|
339
|
+
if (pending.length > 0) yield { number: lineNo + 1, raw: pending };
|
|
340
|
+
} finally {
|
|
341
|
+
if (fd !== undefined) closeSync(fd);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The terminator is content for concatenation and noise for a regex test —
|
|
346
|
+
* `$` must anchor at the end of the LINE, not after an invisible `\r`. */
|
|
347
|
+
function withoutTerminator(raw) {
|
|
348
|
+
return raw.endsWith('\r\n') ? raw.slice(0, -2) : raw.endsWith('\n') ? raw.slice(0, -1) : raw;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** `NNN| ` gutter, right-aligned to the widest number in the window. Only ever
|
|
352
|
+
* applied beside an untouched `exact` copy. */
|
|
353
|
+
function addGutter(lines, startLine) {
|
|
354
|
+
const width = String(startLine + lines.length - 1).length;
|
|
355
|
+
return lines.map((raw, i) => {
|
|
356
|
+
const n = String(startLine + i).padStart(width);
|
|
357
|
+
const term = raw.endsWith('\r\n') ? '\r\n' : raw.endsWith('\n') ? '\n' : '';
|
|
358
|
+
return `${n}| ${withoutTerminator(raw)}${term}`;
|
|
359
|
+
}).join('');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* ── TOOL: read_lines ────────────────────────────────────────────────────────
|
|
364
|
+
* `offset` is a 1-INDEXED LINE NUMBER. Not a byte offset, not 0-indexed —
|
|
365
|
+
* because that is what the model already sends unprompted, and a primitive that
|
|
366
|
+
* disagrees with the caller's existing habit is a primitive nobody uses
|
|
367
|
+
* correctly.
|
|
368
|
+
*/
|
|
369
|
+
function readLines(root, args) {
|
|
370
|
+
const bad = unknownKeyRefusal('read_lines', args, READ_LINES_KEYS);
|
|
371
|
+
if (bad) return { ok: false, error: bad };
|
|
372
|
+
|
|
373
|
+
const offset = args.offset === undefined ? 1 : asInteger(args.offset);
|
|
374
|
+
if (offset === null || offset < 1) {
|
|
375
|
+
return { ok: false, error: `offset is a 1-indexed LINE NUMBER, so the first line is offset 1 — you sent ${JSON.stringify(args.offset)}.` };
|
|
376
|
+
}
|
|
377
|
+
const limit = args.limit === undefined ? DEFAULT_LIMIT : asInteger(args.limit);
|
|
378
|
+
if (limit === null || limit < 1 || limit > MAX_LIMIT) {
|
|
379
|
+
return { ok: false, error: `limit must be a whole number of lines between 1 and ${MAX_LIMIT} — you sent ${JSON.stringify(args.limit)}; page through a bigger span with repeated offsets.` };
|
|
380
|
+
}
|
|
381
|
+
if (args.numbered !== undefined && typeof args.numbered !== 'boolean') {
|
|
382
|
+
return { ok: false, error: 'numbered must be true or false — leave it out to get the file exactly as written, which is what you want if you are about to copy an edit_file old_string.' };
|
|
383
|
+
}
|
|
384
|
+
const budget = clampMaxChars(args.maxChars);
|
|
385
|
+
if (!budget.ok) return budget;
|
|
386
|
+
|
|
387
|
+
const pre = prepare(root, args.path);
|
|
388
|
+
if (!pre.ok) return pre;
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* ⚠️ THE SCAN RUNS TO EOF EVEN AFTER THE WINDOW IS FULL, and that is not
|
|
392
|
+
* waste. `totalLines` is the number that turns paging from a guess into
|
|
393
|
+
* arithmetic — without it the model cannot tell "the window ended" from "the
|
|
394
|
+
* file ended", which is the ambiguity that made it re-read the same blob.
|
|
395
|
+
*/
|
|
396
|
+
const wantFrom = offset;
|
|
397
|
+
const wantTo = offset + limit - 1;
|
|
398
|
+
const collected = [];
|
|
399
|
+
let totalLines = 0;
|
|
400
|
+
for (const line of iterateLines(pre.absolute, pre.totalBytes)) {
|
|
401
|
+
totalLines = line.number;
|
|
402
|
+
if (line.number >= wantFrom && line.number <= wantTo) collected.push(line.raw);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// An empty file is a fact, not a failure — refusing it would send the model
|
|
406
|
+
// hunting for a file that is right there and simply has nothing in it.
|
|
407
|
+
if (totalLines === 0) {
|
|
408
|
+
return {
|
|
409
|
+
ok: true, tool: 'read_lines', path: pre.relative,
|
|
410
|
+
startLine: 0, endLine: 0, totalLines: 0, totalBytes: pre.totalBytes,
|
|
411
|
+
text: '', bytes: 0, nextOffset: null, truncated: false, partialLine: null,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
if (offset > totalLines) {
|
|
415
|
+
return { ok: false, error: `offset ${offset} is past the end of ${pre.relative} — it has ${totalLines} lines, so the last readable offset is ${totalLines}.` };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── THE TRUNCATION, AND THE ONLY DIRECTION IT IS ALLOWED TO CUT ───────────
|
|
419
|
+
const kept = [];
|
|
420
|
+
let chars = 0;
|
|
421
|
+
let partialLine = null;
|
|
422
|
+
let truncated = false;
|
|
423
|
+
for (let i = 0; i < collected.length; i++) {
|
|
424
|
+
const raw = collected[i];
|
|
425
|
+
if (chars + raw.length <= budget.value) {
|
|
426
|
+
kept.push(raw);
|
|
427
|
+
chars += raw.length;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* ⚠️ THE ONE CASE WHERE A PARTIAL LINE IS THE HONEST ANSWER. "Whole lines
|
|
432
|
+
* only" has an obvious hole: a minified bundle is one line of 400 KB, and
|
|
433
|
+
* the whole-line rule would return NOTHING for it. Returning nothing to a
|
|
434
|
+
* model reads as "the file is empty", which is a lie; returning the head
|
|
435
|
+
* with `partialLine` set is true and usable. Note it still cuts at the END.
|
|
436
|
+
*/
|
|
437
|
+
if (kept.length === 0) {
|
|
438
|
+
kept.push(raw.slice(0, budget.value));
|
|
439
|
+
chars = budget.value;
|
|
440
|
+
partialLine = offset;
|
|
441
|
+
}
|
|
442
|
+
truncated = true;
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const endLine = partialLine !== null ? offset : offset + kept.length - 1;
|
|
447
|
+
const exact = kept.join('');
|
|
448
|
+
const text = args.numbered === true && partialLine === null ? addGutter(kept, offset) : exact;
|
|
449
|
+
|
|
450
|
+
const result = {
|
|
451
|
+
ok: true,
|
|
452
|
+
tool: 'read_lines',
|
|
453
|
+
path: pre.relative,
|
|
454
|
+
startLine: offset,
|
|
455
|
+
endLine,
|
|
456
|
+
totalLines,
|
|
457
|
+
totalBytes: pre.totalBytes,
|
|
458
|
+
text,
|
|
459
|
+
bytes: Buffer.byteLength(exact, 'utf8'),
|
|
460
|
+
// The first line NOT returned. Null only when the file genuinely ended.
|
|
461
|
+
nextOffset: endLine < totalLines ? endLine + 1 : null,
|
|
462
|
+
truncated,
|
|
463
|
+
partialLine,
|
|
464
|
+
};
|
|
465
|
+
// ⚠️ `exact` rides along whenever the gutter is on: a model handed only the
|
|
466
|
+
// decorated version will paste the decorated version into an edit and be
|
|
467
|
+
// refused, which is the exact failure this module was built to end.
|
|
468
|
+
if (args.numbered === true) result.exact = exact;
|
|
469
|
+
return result;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* ── TOOL: read_around ───────────────────────────────────────────────────────
|
|
474
|
+
* The tool an `edit_file` `old_string` is copied from. `search_text` tells you
|
|
475
|
+
* WHERE; this tells you what is actually there, to the byte.
|
|
476
|
+
*/
|
|
477
|
+
function readAround(root, args) {
|
|
478
|
+
const bad = unknownKeyRefusal('read_around', args, READ_AROUND_KEYS);
|
|
479
|
+
if (bad) return { ok: false, error: bad };
|
|
480
|
+
|
|
481
|
+
if (typeof args.pattern !== 'string' || args.pattern === '') {
|
|
482
|
+
return { ok: false, error: 'pattern must be a non-empty regular-expression string — pass the distinctive part of the line you are looking for.' };
|
|
483
|
+
}
|
|
484
|
+
if (args.pattern.length > MAX_PATTERN_CHARS) {
|
|
485
|
+
return { ok: false, error: `pattern is ${args.pattern.length} characters, over the ${MAX_PATTERN_CHARS}-character limit — match a short distinctive substring and read the surrounding lines instead of matching the whole thing.` };
|
|
486
|
+
}
|
|
487
|
+
const context = args.context === undefined ? DEFAULT_CONTEXT : asInteger(args.context);
|
|
488
|
+
if (context === null || context < 0 || context > MAX_CONTEXT) {
|
|
489
|
+
return { ok: false, error: `context must be a whole number of lines between 0 and ${MAX_CONTEXT} — you sent ${JSON.stringify(args.context)}; for a bigger span use read_lines with an offset.` };
|
|
490
|
+
}
|
|
491
|
+
const maxBlocks = args.maxBlocks === undefined ? DEFAULT_MAX_BLOCKS : asInteger(args.maxBlocks);
|
|
492
|
+
if (maxBlocks === null || maxBlocks < 1 || maxBlocks > MAX_BLOCKS) {
|
|
493
|
+
return { ok: false, error: `maxBlocks must be a whole number between 1 and ${MAX_BLOCKS} — you sent ${JSON.stringify(args.maxBlocks)}; narrow the pattern rather than asking for more blocks.` };
|
|
494
|
+
}
|
|
495
|
+
if (args.ignoreCase !== undefined && typeof args.ignoreCase !== 'boolean') {
|
|
496
|
+
return { ok: false, error: 'ignoreCase must be true or false — it defaults to true, matching search_text.' };
|
|
497
|
+
}
|
|
498
|
+
const budget = clampMaxChars(args.maxChars);
|
|
499
|
+
if (!budget.ok) return budget;
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* ⚠️ CASE-INSENSITIVE BY DEFAULT, TO AGREE WITH `search_text`. That tool
|
|
503
|
+
* hardcodes the `i` flag. If this one defaulted to exact matching, a hit the
|
|
504
|
+
* model had just found with search_text could come back "0 matches" here —
|
|
505
|
+
* and it would conclude the file had changed under it. Two tools in one loop
|
|
506
|
+
* must not disagree about what a match is.
|
|
507
|
+
*/
|
|
508
|
+
let rx;
|
|
509
|
+
try {
|
|
510
|
+
rx = new RegExp(args.pattern, args.ignoreCase === false ? '' : 'i');
|
|
511
|
+
} catch (err) {
|
|
512
|
+
return { ok: false, error: `not a valid regular expression: ${String(err?.message || err)} — escape the special characters if you meant a literal string.` };
|
|
513
|
+
}
|
|
514
|
+
// ⚠️ BEFORE A BYTE IS READ. See nestedQuantifier: this class of pattern cannot
|
|
515
|
+
// be stopped by a timeout once it starts, so it has to be stopped by not
|
|
516
|
+
// starting. Measured: the version without this check ran 5+ minutes.
|
|
517
|
+
if (nestedQuantifier(args.pattern)) {
|
|
518
|
+
return {
|
|
519
|
+
ok: false,
|
|
520
|
+
error: `"${args.pattern}" nests a quantifier inside a quantified group (like "(a+)+"), which can take exponential time on one long line and cannot be interrupted — rewrite it without the inner repetition, e.g. match a literal prefix and widen "context" instead.`,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const pre = prepare(root, args.path);
|
|
525
|
+
if (!pre.ok) return pre;
|
|
526
|
+
|
|
527
|
+
// ── PASS 1: line NUMBERS only ─────────────────────────────────────────────
|
|
528
|
+
// Two passes over the file rather than one pass holding every line, because
|
|
529
|
+
// "never more than 8 MB in memory" has to survive an 8 MB file.
|
|
530
|
+
const hits = [];
|
|
531
|
+
let totalLines = 0;
|
|
532
|
+
let hitsTruncated = false;
|
|
533
|
+
const started = Date.now();
|
|
534
|
+
for (const line of iterateLines(pre.absolute, pre.totalBytes)) {
|
|
535
|
+
totalLines = line.number;
|
|
536
|
+
/**
|
|
537
|
+
* The clock is the SECOND line of defence, not the first — `nestedQuantifier`
|
|
538
|
+
* above already refused the patterns a clock cannot save us from. What this
|
|
539
|
+
* catches is the merely-slow case: a linear pattern over a very large file,
|
|
540
|
+
* which would otherwise sit there looking like a hung terminal.
|
|
541
|
+
*
|
|
542
|
+
* Every 64 lines rather than every line: 1,500 `Date.now()` calls on a
|
|
543
|
+
* 100k-line file is free, and per-line would be measurable.
|
|
544
|
+
*/
|
|
545
|
+
if ((line.number & 0x3f) === 0 && Date.now() - started > SCAN_BUDGET_MS) {
|
|
546
|
+
return {
|
|
547
|
+
ok: false,
|
|
548
|
+
error: `the pattern was still scanning ${pre.relative} after the ${SCAN_BUDGET_MS}ms budget — anchor it (drop a leading ".*", add a literal prefix) and run it again, or use read_lines if you already know roughly where to look.`,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
if (hits.length >= MAX_HITS) { hitsTruncated = true; continue; }
|
|
552
|
+
if (rx.test(withoutTerminator(line.raw))) hits.push(line.number);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (hits.length === 0) {
|
|
556
|
+
return {
|
|
557
|
+
ok: true, tool: 'read_around', path: pre.relative, pattern: args.pattern,
|
|
558
|
+
totalLines, totalBytes: pre.totalBytes, matchCount: 0, blocks: [],
|
|
559
|
+
truncated: false, blocksOmitted: 0, nextOffset: null,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Merge into ranges. Adjacent counts as overlapping: a one-line gap costs more
|
|
564
|
+
// as a "··· 1 line omitted ···" marker than as the line itself.
|
|
565
|
+
const ranges = [];
|
|
566
|
+
for (const h of hits) {
|
|
567
|
+
const start = Math.max(1, h - context);
|
|
568
|
+
const end = Math.min(totalLines, h + context);
|
|
569
|
+
const last = ranges[ranges.length - 1];
|
|
570
|
+
if (last && start <= last.end + 1) last.end = Math.max(last.end, end);
|
|
571
|
+
else ranges.push({ start, end });
|
|
572
|
+
}
|
|
573
|
+
const blocksOmitted = Math.max(0, ranges.length - maxBlocks);
|
|
574
|
+
const wanted = ranges.slice(0, maxBlocks);
|
|
575
|
+
|
|
576
|
+
// ── PASS 2: materialise only the wanted lines ─────────────────────────────
|
|
577
|
+
const byRange = wanted.map(() => []);
|
|
578
|
+
for (const line of iterateLines(pre.absolute, pre.totalBytes)) {
|
|
579
|
+
for (let i = 0; i < wanted.length; i++) {
|
|
580
|
+
if (line.number >= wanted[i].start && line.number <= wanted[i].end) byRange[i].push(line.raw);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const blocks = [];
|
|
585
|
+
let chars = 0;
|
|
586
|
+
let truncated = blocksOmitted > 0;
|
|
587
|
+
let nextOffset = blocksOmitted > 0 ? ranges[maxBlocks].start : null;
|
|
588
|
+
for (let i = 0; i < wanted.length; i++) {
|
|
589
|
+
const kept = [];
|
|
590
|
+
let cut = false;
|
|
591
|
+
for (const raw of byRange[i]) {
|
|
592
|
+
if (chars + raw.length > budget.value) { cut = true; break; }
|
|
593
|
+
kept.push(raw);
|
|
594
|
+
chars += raw.length;
|
|
595
|
+
}
|
|
596
|
+
if (kept.length > 0) {
|
|
597
|
+
blocks.push({ startLine: wanted[i].start, endLine: wanted[i].start + kept.length - 1, text: kept.join('') });
|
|
598
|
+
}
|
|
599
|
+
if (cut) {
|
|
600
|
+
truncated = true;
|
|
601
|
+
nextOffset = wanted[i].start + kept.length;
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
ok: true,
|
|
608
|
+
tool: 'read_around',
|
|
609
|
+
path: pre.relative,
|
|
610
|
+
pattern: args.pattern,
|
|
611
|
+
totalLines,
|
|
612
|
+
totalBytes: pre.totalBytes,
|
|
613
|
+
matchCount: hits.length,
|
|
614
|
+
matchesTruncated: hitsTruncated,
|
|
615
|
+
blocks,
|
|
616
|
+
truncated,
|
|
617
|
+
blocksOmitted,
|
|
618
|
+
nextOffset,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* The one entry point. `tool` is passed explicitly by the dispatcher; the
|
|
624
|
+
* inference fallback exists only so a direct caller (or a test) can hand over an
|
|
625
|
+
* argument object and get the obvious thing.
|
|
626
|
+
*
|
|
627
|
+
* ⚠️ INFERENCE IS BY `pattern`, AND IT IS NOT A LOOSE MATCH — once inferred, the
|
|
628
|
+
* unknown-key rule applies to THAT tool's keys, so `{path, pattern, offset}`
|
|
629
|
+
* lands as a read_around with an illegal `offset` and is refused. Guessing which
|
|
630
|
+
* tool the caller meant is acceptable; guessing which arguments they meant is
|
|
631
|
+
* exactly the silent acceptance this module exists to stop.
|
|
632
|
+
*
|
|
633
|
+
* @param {string} root workspace root
|
|
634
|
+
* @param {Record<string, unknown>} args
|
|
635
|
+
* @param {'read_lines'|'read_around'} [tool]
|
|
636
|
+
*/
|
|
637
|
+
export function readWindow(root, args, tool = undefined) {
|
|
638
|
+
if (args === null || typeof args !== 'object' || Array.isArray(args)) {
|
|
639
|
+
return { ok: false, error: 'arguments must be a JSON object with at least a "path".' };
|
|
640
|
+
}
|
|
641
|
+
const chosen = tool ?? ('pattern' in args ? 'read_around' : 'read_lines');
|
|
642
|
+
if (chosen === 'read_around') return readAround(root, args);
|
|
643
|
+
if (chosen === 'read_lines') return readLines(root, args);
|
|
644
|
+
return { ok: false, error: `unknown window tool "${chosen}" — this module implements read_lines and read_around.` };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* ⚠️ THE DESCRIPTIONS DO THE TEACHING, because a tool the model uses wrongly is
|
|
649
|
+
* a tool that does not exist. Two facts have to survive into the payload: offset
|
|
650
|
+
* is a LINE NUMBER, and the text comes back byte-exact so it can be pasted
|
|
651
|
+
* straight into `edit_file`. Both are stated, in the tool the model reads.
|
|
652
|
+
*/
|
|
653
|
+
export function readWindowToolSchemas() {
|
|
654
|
+
return [
|
|
655
|
+
{
|
|
656
|
+
type: 'function',
|
|
657
|
+
function: {
|
|
658
|
+
name: 'read_lines',
|
|
659
|
+
description: [
|
|
660
|
+
'Read a WINDOW of a text file: whole lines, exactly as written, no line-number gutter and no ellipsis.',
|
|
661
|
+
'offset is a 1-indexed LINE NUMBER (offset 1 is the first line); limit is how many lines.',
|
|
662
|
+
'Use this instead of read_file for anything over ~200 lines: read_file truncates the MIDDLE, this truncates the END and tells you totalLines and the nextOffset to continue from.',
|
|
663
|
+
'Reads files up to 8 MB. Never returns credential files.',
|
|
664
|
+
].join(' '),
|
|
665
|
+
parameters: {
|
|
666
|
+
type: 'object',
|
|
667
|
+
properties: {
|
|
668
|
+
path: { type: 'string', description: 'Workspace-relative path, e.g. "lib/tools.mjs".' },
|
|
669
|
+
offset: { type: 'integer', description: `1-indexed first line to return. Default 1.` },
|
|
670
|
+
limit: { type: 'integer', description: `How many lines. Default ${DEFAULT_LIMIT}, maximum ${MAX_LIMIT}.` },
|
|
671
|
+
numbered: { type: 'boolean', description: 'Prefix each line with "NNN| ". Default false — leave it off when you are about to copy text into edit_file.' },
|
|
672
|
+
},
|
|
673
|
+
required: ['path'],
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
type: 'function',
|
|
679
|
+
function: {
|
|
680
|
+
name: 'read_around',
|
|
681
|
+
description: [
|
|
682
|
+
'Show the exact lines around every match of a pattern in one file, with context either side.',
|
|
683
|
+
'This is where you copy an edit_file old_string from: the text is byte-exact, so the indentation is the real indentation.',
|
|
684
|
+
'search_text returns each hit byte-exact, including its leading indentation, so its text can be '
|
|
685
|
+
+ 'used directly as an edit_file old_string. Use read_around when you need the SURROUNDING lines, '
|
|
686
|
+
+ 'not to recover the line itself.',
|
|
687
|
+
'Overlapping windows merge; skipped spans are marked. Case-insensitive by default, like search_text.',
|
|
688
|
+
].join(' '),
|
|
689
|
+
parameters: {
|
|
690
|
+
type: 'object',
|
|
691
|
+
properties: {
|
|
692
|
+
path: { type: 'string', description: 'Workspace-relative path, e.g. "lib/git.mjs".' },
|
|
693
|
+
pattern: { type: 'string', description: `A JavaScript regular expression tested per line, at most ${MAX_PATTERN_CHARS} characters.` },
|
|
694
|
+
context: { type: 'integer', description: `Lines of context either side. Default ${DEFAULT_CONTEXT}, maximum ${MAX_CONTEXT}.` },
|
|
695
|
+
ignoreCase: { type: 'boolean', description: 'Default true. Set false for an exact-case match.' },
|
|
696
|
+
maxBlocks: { type: 'integer', description: `How many separate blocks to return. Default ${DEFAULT_MAX_BLOCKS}, maximum ${MAX_BLOCKS}.` },
|
|
697
|
+
},
|
|
698
|
+
required: ['path', 'pattern'],
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
},
|
|
702
|
+
];
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* What the MODEL sees. Exactly one header line, then the verbatim text.
|
|
707
|
+
*
|
|
708
|
+
* ⚠️ THE CONTINUATION INSTRUCTION LIVES IN THE HEADER, NOT IN A TRAILER. A
|
|
709
|
+
* trailer after the code is a line the model may copy into an edit; a header is
|
|
710
|
+
* unambiguously commentary. And it says the literal next call to make, because
|
|
711
|
+
* "truncated" alone is what a model reads as "this file is unreadable".
|
|
712
|
+
*/
|
|
713
|
+
export function formatWindowForModel(result) {
|
|
714
|
+
if (!result || result.ok !== true) return `read failed: ${result?.error ?? 'unknown error'}`;
|
|
715
|
+
|
|
716
|
+
if (result.tool === 'read_around') {
|
|
717
|
+
const head = result.matchCount === 0
|
|
718
|
+
? `${result.path} — no line matches /${result.pattern}/ in ${result.totalLines} lines; try a shorter fragment or search_text across the repo`
|
|
719
|
+
: `${result.path} — ${result.matchCount}${result.matchesTruncated ? '+' : ''} match${result.matchCount === 1 ? '' : 'es'} for /${result.pattern}/, ${result.totalLines} lines total${result.truncated ? `; stopped early, continue with read_lines offset ${result.nextOffset}` : ''}`;
|
|
720
|
+
const body = [];
|
|
721
|
+
let previousEnd = null;
|
|
722
|
+
for (const b of result.blocks) {
|
|
723
|
+
if (previousEnd !== null && b.startLine > previousEnd + 1) {
|
|
724
|
+
body.push(`··· lines ${previousEnd + 1}-${b.startLine - 1} omitted ···`);
|
|
725
|
+
}
|
|
726
|
+
body.push(`lines ${b.startLine}-${b.endLine}:`);
|
|
727
|
+
// ⚠️ trailing terminator stripped from the BLOCK, never from a line inside
|
|
728
|
+
// it — otherwise the marker below would sit on the last line of code.
|
|
729
|
+
body.push(b.text.replace(/\r?\n$/, ''));
|
|
730
|
+
previousEnd = b.endLine;
|
|
731
|
+
}
|
|
732
|
+
return [head, ...body].join('\n');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (result.totalLines === 0) return `${result.path} is empty (0 lines)`;
|
|
736
|
+
const tail = result.partialLine !== null
|
|
737
|
+
? ` — line ${result.partialLine} is longer than the whole budget, so only its first ${result.bytes} bytes are shown and the REST OF THAT LINE is not retrievable through this tool${result.nextOffset === null ? '' : `; read_lines offset ${result.nextOffset} resumes at the next line`}`
|
|
738
|
+
: result.truncated || result.nextOffset !== null
|
|
739
|
+
? ` — continue with read_lines offset ${result.nextOffset}`
|
|
740
|
+
: '';
|
|
741
|
+
const head = `${result.path} lines ${result.startLine}-${result.endLine} of ${result.totalLines} (${result.bytes} bytes)${tail}`;
|
|
742
|
+
return `${head}\n${result.text.replace(/\r?\n$/, '')}`;
|
|
743
|
+
}
|