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.
Files changed (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. 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
+ }