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,952 @@
1
+ /**
2
+ * ── ⭐ FETCH A DOC AND READ IT — THE PRIMITIVE WHOSE ABSENCE BURNED FOUR RUNS ─
3
+ *
4
+ * Measured on this package's own probes, 2026-08-10:
5
+ *
6
+ * · Probe D was told to read https://nodejs.org/api/test.html. Round 1 refused
7
+ * (a `"` is not allowed in a command string). Rounds 2-6 hand-rolled a fetch
8
+ * plus HTML scraping inside `evaluate`: "I fetched the page successfully",
9
+ * then "The output was truncated", then "The output is being truncated in
10
+ * the middle". NOTES.md was never written — `ls -la wsD` shows only `.env` —
11
+ * and the run still printed ✔ VERIFIED.
12
+ * · Probe G repeated the shape, gave up on the URL, guessed empirically, and
13
+ * never wrote API.md.
14
+ * · Probe A died on `mock.timers.enable({ apis: ['setTimeout', …] })`, an enum
15
+ * member that does not exist. ERR_INVALID_ARG_VALUE ate the whole budget.
16
+ *
17
+ * ⭐ AND THE NUMBER THAT MAKES THE CASE. Across those runs: **0 invented
18
+ * function names in 12 uses of Node built-ins, 3 wrong facts in ~19 uses of a
19
+ * library API (~16%)** — an invented enum member, a wrong async shape (`glob`
20
+ * from fs/promises spread as an array → "files is not iterable"), and a
21
+ * wrong-version idiom (`z.string().email()` under an explicit "use the v4 APIs"
22
+ * instruction, asserted as "The code itself is correct" in the one probe where
23
+ * nothing could run to contradict it). Every single one was a doc lookup away.
24
+ *
25
+ * ── ⚠️ THIS ADDS ERGONOMICS AND RAILS, NOT REACH ────────────────────────────
26
+ * The network was ALREADY reachable from inside `evaluate` — the model proved
27
+ * that by fetching the page. What it did not have was: a converter that turns
28
+ * HTML into something readable, a cache so window two is free, and a boundary
29
+ * that stops "fetch a URL" from meaning "read the cloud metadata endpoint". So
30
+ * do not sell this as new capability. It is the difference between having a
31
+ * network and being able to look something up.
32
+ *
33
+ * ── ⚠️ THE PART THAT IS SECURITY, NOT CONVENIENCE ───────────────────────────
34
+ * This runs on other people's machines, and the URL is chosen by a language
35
+ * model that will happily follow a link out of a page it just read. A tool that
36
+ * takes a model-supplied URL and performs a GET is an SSRF primitive unless
37
+ * something stops it: `http://169.254.169.254/latest/meta-data/iam/…` hands out
38
+ * cloud credentials, and `http://127.0.0.1:*` is every unauthenticated admin
39
+ * panel a developer has ever run on a laptop.
40
+ *
41
+ * ⭐ So the guard is not a blocklist of strings, it is a check on the RESOLVED
42
+ * ADDRESSES, re-run on every redirect hop, and the socket is then pinned to the
43
+ * exact addresses that were checked — otherwise a hostname that resolves public
44
+ * once and private a millisecond later (DNS rebinding) walks straight through a
45
+ * check that already passed.
46
+ *
47
+ * ⚠️ THE CONSEQUENCE IS STATED IN THE TOOL DESCRIPTION, NOT HIDDEN: this tool
48
+ * CANNOT talk to a server the agent just started on localhost. That is a real
49
+ * loss and it is deliberate — "read my dev server" is a different primitive with
50
+ * a different threat model, and merging the two is how the guard gets a bypass
51
+ * flag that eventually defaults to on.
52
+ *
53
+ * ── WHAT IS DELIBERATELY NOT HERE ───────────────────────────────────────────
54
+ * No POST. No model-supplied headers, cookies, auth or body. The schema exposes
55
+ * `url`, `offset`, `limit` and refuses everything else BY NAME — a tool that can
56
+ * be talked into sending an Authorization header is a credential exfiltration
57
+ * path with a docs-lookup costume on.
58
+ */
59
+
60
+ import { createHash } from 'node:crypto';
61
+ import dns from 'node:dns/promises';
62
+ import http from 'node:http';
63
+ import https from 'node:https';
64
+ import { createGunzip, createInflate, createBrotliDecompress } from 'node:zlib';
65
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
66
+ import { dirname } from 'node:path';
67
+
68
+ import { resolveInWorkspace } from './workspace.mjs';
69
+
70
+ /**
71
+ * ⚠️ THE SHAPES ARE DECLARED, NOT INFERRED — same reason as workspace.mjs and
72
+ * git.mjs. Inference widens `ok: false` to `ok: boolean` and the discriminated
73
+ * union dies at the call site, where the console's TypeScript client reads it.
74
+ *
75
+ * @typedef {{ ok: false, error: string }} FetchRefused
76
+ * @typedef {{
77
+ * ok: true, url: string, finalUrl: string, status: number, contentType: string,
78
+ * totalChars: number, cachePath: string | null, fromCache: boolean,
79
+ * text: string, nextOffset: number | null, truncated?: boolean
80
+ * }} FetchOk
81
+ * @typedef {{ address: string, family: number }} ResolvedAddress
82
+ */
83
+
84
+ /** A doc lookup is fast or it is not happening. Whole-request budget, all hops. */
85
+ export const FETCH_TIMEOUT_MS = 20_000;
86
+ /** Three hops covers http→https→www→canonical. A fourth is a loop or a trap. */
87
+ export const MAX_REDIRECTS = 3;
88
+ /** Hard ceiling on what comes off the wire, decompressed. */
89
+ export const MAX_BODY_BYTES = 5 * 1024 * 1024;
90
+ /** One window. Big enough to hold an API section, small enough to not eat a turn. */
91
+ export const DEFAULT_WINDOW = 6_000;
92
+ export const MAX_WINDOW = 12_000;
93
+ /**
94
+ * ⚠️ PER-PROCESS BUDGETS, and they exist because of the loop this file is fixing.
95
+ * An agent that can fetch can crawl, and a crawl inside an agent turn is a
96
+ * budget fire nobody watches. Ten lookups settles any question a coding task
97
+ * has; three per host stops one site becoming the whole session.
98
+ */
99
+ export const MAX_FETCHES_PER_PROCESS = 10;
100
+ export const MAX_FETCHES_PER_HOST = 3;
101
+
102
+ /** Same `.acuvo/` the audit log, policy and mcp config already live in. */
103
+ export const CACHE_DIR = '.acuvo/fetch';
104
+
105
+ /** Content types worth converting to text. Everything else is refused BY NAME. */
106
+ const TEXTUAL = /^(text\/|application\/(json|xml|xhtml\+xml)$|application\/[a-z0-9.+-]*\+json$|application\/[a-z0-9.+-]*\+xml$)/i;
107
+
108
+ /* ────────────────────────────────────────────────────────────────────────────
109
+ * PER-PROCESS STATE
110
+ *
111
+ * ⚠️ PER-PROCESS AND NOT PERSISTED, exactly like breaker.mjs and for the same
112
+ * reason: a CLI run is minutes long, so there is no cache-expiry policy worth
113
+ * modelling and the next invocation gets fresh docs for free. The disk file is
114
+ * for PAGING within a session, not an HTTP cache — a stale answer that survives
115
+ * a restart is how a model ends up quoting last month's API.
116
+ * ──────────────────────────────────────────────────────────────────────────── */
117
+
118
+ /** url → { cachePath, text|null, finalUrl, status, contentType, totalChars, truncated } */
119
+ const session = new Map();
120
+ /** host → number of requests made this process. */
121
+ const perHost = new Map();
122
+ let fetchesMade = 0;
123
+
124
+ /** Reset between tests. Never called in normal operation — a run is short. */
125
+ export function resetFetchState() {
126
+ session.clear();
127
+ perHost.clear();
128
+ fetchesMade = 0;
129
+ }
130
+
131
+ /* ────────────────────────────────────────────────────────────────────────────
132
+ * THE ADDRESS GUARD
133
+ * ──────────────────────────────────────────────────────────────────────────── */
134
+
135
+ function parseIPv4(s) {
136
+ const parts = s.split('.');
137
+ if (parts.length !== 4) return null;
138
+ const out = [];
139
+ for (const p of parts) {
140
+ // ⚠️ No leading zeros, no `0x`, no shorthand. `0177.0.0.1` is 127.0.0.1 to
141
+ // inet_aton and something else to a naive parser, and that gap has been a
142
+ // real SSRF bypass more than once. Anything unusual is refused, not parsed.
143
+ if (!/^\d{1,3}$/.test(p)) return null;
144
+ const n = Number(p);
145
+ if (n > 255) return null;
146
+ out.push(n);
147
+ }
148
+ return out;
149
+ }
150
+
151
+ /** Returns 16 bytes, or null. Handles `::` compression and a trailing IPv4. */
152
+ function parseIPv6(s) {
153
+ let text = s;
154
+ if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
155
+ const zone = text.indexOf('%');
156
+ if (zone !== -1) text = text.slice(0, zone);
157
+ if (!text.includes(':')) return null;
158
+
159
+ let tail4 = null;
160
+ const lastColon = text.lastIndexOf(':');
161
+ const after = text.slice(lastColon + 1);
162
+ if (after.includes('.')) {
163
+ tail4 = parseIPv4(after);
164
+ if (!tail4) return null;
165
+ text = text.slice(0, lastColon + 1) + '0:0';
166
+ }
167
+
168
+ const halves = text.split('::');
169
+ if (halves.length > 2) return null;
170
+ const toGroups = (part) => (part === '' ? [] : part.split(':').map((g) => {
171
+ if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return NaN;
172
+ return parseInt(g, 16);
173
+ }));
174
+ const head = toGroups(halves[0]);
175
+ const tail = halves.length === 2 ? toGroups(halves[1]) : [];
176
+ if ([...head, ...tail].some((n) => Number.isNaN(n))) return null;
177
+
178
+ let groups;
179
+ if (halves.length === 2) {
180
+ const fill = 8 - head.length - tail.length;
181
+ if (fill < 0) return null;
182
+ groups = [...head, ...new Array(fill).fill(0), ...tail];
183
+ } else {
184
+ groups = head;
185
+ }
186
+ if (groups.length !== 8) return null;
187
+
188
+ const bytes = [];
189
+ for (const g of groups) bytes.push((g >> 8) & 0xff, g & 0xff);
190
+ if (tail4) { bytes[12] = tail4[0]; bytes[13] = tail4[1]; bytes[14] = tail4[2]; bytes[15] = tail4[3]; }
191
+ return bytes;
192
+ }
193
+
194
+ function blockedV4([a, b]) {
195
+ if (a === 0) return true; // 0.0.0.0/8 — "this network", and 0.0.0.0 IS localhost on Linux
196
+ if (a === 10) return true; // RFC1918
197
+ if (a === 127) return true; // loopback
198
+ if (a === 169 && b === 254) return true; // link-local — INCLUDING 169.254.169.254, the cloud metadata endpoint
199
+ if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
200
+ if (a === 192 && b === 168) return true; // RFC1918
201
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT — a real private range on cloud hosts
202
+ if (a >= 224) return true; // multicast, reserved, and 255.255.255.255
203
+ return false;
204
+ }
205
+
206
+ /**
207
+ * Is this resolved address one the tool must never open a socket to?
208
+ *
209
+ * ⚠️ FAILS CLOSED. An address string this cannot parse is refused, because the
210
+ * alternative — "I did not understand it, so it is probably fine" — is the exact
211
+ * reasoning that turns a parser gap into a bypass.
212
+ *
213
+ * @param {unknown} ip
214
+ * @returns {boolean}
215
+ */
216
+ export function isBlockedAddress(ip) {
217
+ if (typeof ip !== 'string' || !ip.trim()) return true;
218
+ const raw = ip.trim();
219
+
220
+ const v4 = parseIPv4(raw);
221
+ if (v4) return blockedV4(v4);
222
+
223
+ const v6 = parseIPv6(raw);
224
+ if (!v6) return true;
225
+
226
+ // IPv4-mapped (::ffff:127.0.0.1) and IPv4-compatible (::127.0.0.1) both reach
227
+ // v4 destinations, so they are checked as v4 rather than as "some IPv6 address
228
+ // that is not on the list".
229
+ const firstTenZero = v6.slice(0, 10).every((b) => b === 0);
230
+ if (firstTenZero && v6[10] === 0xff && v6[11] === 0xff) return blockedV4(v6.slice(12));
231
+ if (firstTenZero && v6[10] === 0 && v6[11] === 0) {
232
+ const low = v6.slice(12);
233
+ if (low.every((b) => b === 0)) return true; // ::
234
+ if (low[0] === 0 && low[1] === 0 && low[2] === 0 && low[3] === 1) return true; // ::1
235
+ return blockedV4(low); // ::a.b.c.d
236
+ }
237
+ if ((v6[0] & 0xfe) === 0xfc) return true; // fc00::/7 unique-local
238
+ if (v6[0] === 0xfe && (v6[1] & 0xc0) === 0x80) return true; // fe80::/10 link-local
239
+ if (v6[0] === 0xff) return true; // ff00::/8 multicast
240
+ return false;
241
+ }
242
+
243
+ const BLOCKED_MESSAGE = 'this tool cannot reach private or local addresses';
244
+
245
+ /**
246
+ * Is this hostname an address rather than a name?
247
+ *
248
+ * ⚠️ DELIBERATELY GENEROUS. Anything made only of digits and dots, or holding a
249
+ * colon, is treated as a literal and handed to `isBlockedAddress` — which fails
250
+ * closed. So `0177.0.0.1` and `2130706433` are refused as unparseable addresses
251
+ * rather than handed to a resolver that might make something of them. A real
252
+ * hostname cannot be all digits and dots, so nothing legitimate is lost.
253
+ */
254
+ function looksLikeIpLiteral(host) {
255
+ return /^[0-9.]+$/.test(host) || host.includes(':');
256
+ }
257
+
258
+ /**
259
+ * Validate one hop: scheme, credentials, and every address the hostname resolves
260
+ * to. Returns the resolved addresses so the socket can be PINNED to them.
261
+ *
262
+ * ⚠️ Re-run on every redirect. A first hop that is public says nothing about the
263
+ * second — "public host 302s to 127.0.0.1" is the standard SSRF filter bypass,
264
+ * and it costs one function call to close.
265
+ */
266
+ async function validateHop(urlString, lookupImpl) {
267
+ let u;
268
+ try {
269
+ u = new URL(urlString);
270
+ } catch {
271
+ return {
272
+ ok: false,
273
+ error: `"${String(urlString).slice(0, 120)}" is not a full URL. Include the scheme, e.g. https://nodejs.org/api/test.html`,
274
+ };
275
+ }
276
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
277
+ // Named, not lumped into "invalid URL": the model must learn the boundary,
278
+ // and for the common cases there is a better tool to point it at.
279
+ const alt = u.protocol === 'file:'
280
+ ? ' Use read_file for something on this machine.'
281
+ : '';
282
+ return { ok: false, error: `fetch_url speaks http and https only — "${u.protocol}" is refused.${alt}` };
283
+ }
284
+ if (u.username || u.password) {
285
+ return {
286
+ ok: false,
287
+ error: 'this URL embeds credentials (user:pass@host) and is refused. fetch_url never sends authentication; fetch a public URL instead.',
288
+ };
289
+ }
290
+ const host = u.hostname.replace(/^\[|\]$/g, '');
291
+ if (!host) return { ok: false, error: 'that URL has no hostname' };
292
+
293
+ /**
294
+ * ⚠️⚠️ AN IP LITERAL IS CHECKED DIRECTLY AND NEVER SENT TO THE RESOLVER, and
295
+ * this was a real hole found by its own test. `http://127.0.0.1:8080/admin`
296
+ * has nothing to resolve — but routing it through `lookupImpl` anyway makes
297
+ * the verdict depend on what the resolver says about the string "127.0.0.1",
298
+ * and a resolver is a thing outside this process. The test that caught it
299
+ * stubbed the resolver, the stub answered "public", and the guard cheerfully
300
+ * approved loopback. In production the answer would have been right by luck;
301
+ * here it is right by construction.
302
+ */
303
+ const literal = looksLikeIpLiteral(host);
304
+ if (literal) {
305
+ if (isBlockedAddress(host)) return { ok: false, error: `${BLOCKED_MESSAGE} — "${host}".` };
306
+ return { ok: true, url: u, host: host.toLowerCase(), addresses: [{ address: host, family: host.includes(':') ? 6 : 4 }] };
307
+ }
308
+
309
+ /** @type {ResolvedAddress[]} */
310
+ let addresses;
311
+ try {
312
+ addresses = await lookupImpl(host);
313
+ } catch (err) {
314
+ const code = err?.code || err?.errno || 'lookup failed';
315
+ return {
316
+ ok: false,
317
+ error: `could not resolve "${host}" (${code}). Check the host name — fetch_url takes an exact URL and does not search.`,
318
+ };
319
+ }
320
+ if (!Array.isArray(addresses) || addresses.length === 0) {
321
+ return { ok: false, error: `"${host}" resolved to no addresses` };
322
+ }
323
+ // ⚠️ ANY blocked address refuses the whole hop, not "the first one is fine".
324
+ // A host with one public and one loopback record would otherwise be a coin
325
+ // flip decided by resolver ordering.
326
+ const bad = addresses.find((a) => isBlockedAddress(a?.address));
327
+ if (bad) {
328
+ return { ok: false, error: `${BLOCKED_MESSAGE} — "${host}" resolves to ${bad.address}.` };
329
+ }
330
+ return { ok: true, url: u, host: host.toLowerCase(), addresses };
331
+ }
332
+
333
+ /* ────────────────────────────────────────────────────────────────────────────
334
+ * HTML → TEXT
335
+ * ──────────────────────────────────────────────────────────────────────────── */
336
+
337
+ const NAMED_ENTITIES = {
338
+ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ',
339
+ // The four that actually show up in docs prose and would otherwise read as
340
+ // literal `&mdash;` noise in the model's context.
341
+ mdash: '—', ndash: '–', hellip: '…', copy: '©',
342
+ };
343
+
344
+ function decodeEntities(s) {
345
+ return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]{1,31});/g, (whole, body) => {
346
+ if (body[0] === '#') {
347
+ const code = body[1] === 'x' || body[1] === 'X'
348
+ ? parseInt(body.slice(2), 16)
349
+ : parseInt(body.slice(1), 10);
350
+ if (!Number.isFinite(code) || code < 0 || code > 0x10ffff) return whole;
351
+ try { return String.fromCodePoint(code); } catch { return whole; }
352
+ }
353
+ const hit = NAMED_ENTITIES[body.toLowerCase()];
354
+ return hit === undefined ? whole : hit;
355
+ });
356
+ }
357
+
358
+ /**
359
+ * Turn a documentation page into something a model can read.
360
+ *
361
+ * Pure and separately tested — which is the point. The transport can only be
362
+ * exercised with a socket or a stub; THIS is where the actual quality of a doc
363
+ * lookup lives, and it can be pinned down with a fixture.
364
+ *
365
+ * ⚠️ `<pre>` AND `<code>` ARE EXTRACTED FIRST AND PUT BACK LAST. Every other
366
+ * step here collapses whitespace, and collapsing whitespace inside a code sample
367
+ * turns the one part of the page the model came for into an unindented smear.
368
+ * That is not a formatting nicety: `mock.timers.enable({ apis: [...] })` is
369
+ * copied from a doc code block, and a mangled block is how you get a plausible
370
+ * invented call instead.
371
+ *
372
+ * @param {string} html
373
+ * @returns {string}
374
+ */
375
+ export function htmlToText(html) {
376
+ if (typeof html !== 'string') return '';
377
+ let s = html;
378
+
379
+ // 1. Contents that are not prose. Dropped WITH their tags — `<script>` text
380
+ // is code the page runs, and handing it to the model is pure token burn.
381
+ s = s.replace(/<!--[\s\S]*?-->/g, '');
382
+ /**
383
+ * ⚠️⚠️ THE HEAD IS FOUND BY WHERE THE BODY STARTS, NOT BY MATCHING A TAG PAIR
384
+ * — and it took the real page twice to get this right.
385
+ *
386
+ * Attempt 1 was `/<head\b[^>]*>[\s\S]*?<\/head>/`, which is the obvious rule
387
+ * and does nothing at all on https://nodejs.org/api/test.html, the exact page
388
+ * this module was written for. Measured on the live document (943,859 chars,
389
+ * 2026-08-10): **it contains neither `<head>` nor `</head>`.** Both tags are
390
+ * optional in HTML and the minifier drops them. My grep for `<head` matched
391
+ * at 5750 and my grep for `</head` matched at 27418, which looked like proof
392
+ * the pair existed — they were `<header class=header>` and `</header>`. Being
393
+ * fooled by `<header>` is why attempt 2, cutting at `</head>`, also shipped
394
+ * nothing.
395
+ *
396
+ * ⭐ THE FIXTURE HAD BOTH TAGS, SO THE TEST WAS GREEN ABOUT A RULE THAT DID
397
+ * NOT WORK. A fixture written from what HTML is supposed to look like proves
398
+ * nothing about what servers send; only the render caught it, which is the
399
+ * same lesson `see_page` exists for one layer up.
400
+ *
401
+ * So the rule is now the browser's own: whatever precedes `<body>` is
402
+ * preamble. The `<pre>/<code>` guard keeps a page that merely QUOTES a body
403
+ * tag inside a code sample from being decapitated.
404
+ */
405
+ const bodyOpen = /<body\b[^>]*>/i.exec(s);
406
+ const verbatimAt = s.search(/<(pre|code)\b/i);
407
+ if (bodyOpen && (verbatimAt === -1 || bodyOpen.index < verbatimAt)) {
408
+ s = s.slice(bodyOpen.index + bodyOpen[0].length);
409
+ } else {
410
+ const headEnd = /<\/head\s*>/i.exec(s);
411
+ if (headEnd && (verbatimAt === -1 || headEnd.index < verbatimAt)) s = s.slice(headEnd.index + headEnd[0].length);
412
+ else s = s.replace(/<head\b[^>]*>[\s\S]*?<\/head>/gi, '');
413
+ }
414
+ s = s.replace(/<(script|style|svg|noscript|template|iframe)\b[^>]*>[\s\S]*?<\/\1>/gi, '');
415
+ // An unclosed <script> at the end of a truncated document would otherwise
416
+ // survive the pass above and leak its whole body.
417
+ s = s.replace(/<(script|style|svg|noscript)\b[^>]*>[\s\S]*$/gi, '');
418
+
419
+ // 2. Park the verbatim blocks behind sentinels. NUL is used because it cannot
420
+ // survive step 6 by accident and cannot appear in the source text — the
421
+ // transport refuses binary content types before we ever get here.
422
+ /** @type {string[]} */
423
+ const verbatim = [];
424
+ const park = (_all, inner) => {
425
+ const cleaned = decodeEntities(inner.replace(/<[^>]*>/g, '')).replace(/\r\n?/g, '\n');
426
+ verbatim.push(cleaned);
427
+ return `\u0000V${verbatim.length - 1}\u0000`;
428
+ };
429
+ s = s.replace(/<pre\b[^>]*>([\s\S]*?)<\/pre>/gi, park);
430
+ s = s.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, park);
431
+
432
+ // 3. The tags whose whole job is a line break.
433
+ s = s.replace(/<br\s*\/?>/gi, '\n');
434
+ s = s.replace(/<\/(p|div|section|article|blockquote|ul|ol|table)>/gi, '\n\n');
435
+ s = s.replace(/<\/(li|tr|dt|dd)>/gi, '\n');
436
+ // A cell boundary that becomes nothing merges two columns into one word.
437
+ s = s.replace(/<\/(td|th)>/gi, '\t');
438
+ s = s.replace(/<h[1-6]\b[^>]*>/gi, '\n\n');
439
+ s = s.replace(/<\/h[1-6]>/gi, '\n\n');
440
+ s = s.replace(/<li\b[^>]*>/gi, '\n- ');
441
+
442
+ // 4. Everything else that is markup is not content.
443
+ s = s.replace(/<[^>]*>/g, '');
444
+
445
+ // 5. Entities AFTER tag stripping, never before: decoding first would turn a
446
+ // literal `&lt;script&gt;` in the prose into a tag the next pass eats.
447
+ s = decodeEntities(s);
448
+
449
+ // 6. Whitespace. Horizontal runs collapse, trailing spaces go, 3+ blank lines
450
+ // become 2 — a doc page is mostly layout whitespace and the model pays for
451
+ // every space of it.
452
+ s = s.replace(/\r\n?/g, '\n');
453
+ s = s.replace(/[^\S\n]+/g, ' ');
454
+ s = s.split('\n').map((line) => line.trim()).join('\n');
455
+ s = s.replace(/\n{3,}/g, '\n\n');
456
+
457
+ // 7. Put the code back, indentation intact.
458
+ s = s.replace(/\u0000V(\d+)\u0000/g, (_m, i) => verbatim[Number(i)] ?? '');
459
+ // Any sentinel that survived a mangled document must not reach the model.
460
+ s = s.replace(/\u0000/g, '');
461
+
462
+ return s.trim();
463
+ }
464
+
465
+ /* ────────────────────────────────────────────────────────────────────────────
466
+ * TRANSPORT
467
+ * ──────────────────────────────────────────────────────────────────────────── */
468
+
469
+ let cachedVersion = null;
470
+ function userAgent() {
471
+ if (cachedVersion === null) {
472
+ try {
473
+ cachedVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '0.0.0';
474
+ } catch {
475
+ // A CLI that cannot read its own manifest still has to be able to fetch.
476
+ cachedVersion = '0.0.0';
477
+ }
478
+ }
479
+ return `acuvo-code/${cachedVersion}`;
480
+ }
481
+
482
+ /**
483
+ * ⚠️⚠️ DELIBERATELY DUMB, AND THAT IS THE CONTRACT. This opens a socket to
484
+ * whatever it is given and validates NOTHING — the address guard lives in
485
+ * `fetchText`, above it, so there is exactly one place that decides what is
486
+ * reachable. Never call this with a model-supplied URL. It is exported so the
487
+ * tests can drive a real `node:http` server on 127.0.0.1 THROUGH the injection
488
+ * seam, which is how the SSRF guard stays under test instead of switched off
489
+ * for the test suite (a guard with a test-only bypass is a guard with a bypass).
490
+ *
491
+ * Resolves to `{ status, headers, body, truncated }`. Rejects only on a real
492
+ * transport failure, and the error carries `.code` so the caller can tell a
493
+ * socket reset apart from a timeout.
494
+ */
495
+ export async function rawHttpRequest({ url, headers = {}, timeoutMs = FETCH_TIMEOUT_MS, addresses = null, maxBytes = MAX_BODY_BYTES }) {
496
+ const u = new URL(url);
497
+ const mod = u.protocol === 'https:' ? https : http;
498
+
499
+ /**
500
+ * ⚠️ THE SOCKET IS PINNED TO THE ADDRESSES THAT WERE CHECKED. Without this,
501
+ * the guard resolves a hostname, approves it, and then Node resolves it AGAIN
502
+ * for the connection — a second lookup that can return a different answer.
503
+ * That is DNS rebinding, and it is the difference between a check and a
504
+ * guarantee. SNI and the Host header still come from the URL, so pinning does
505
+ * not break TLS or virtual hosting.
506
+ */
507
+ const lookup = addresses
508
+ ? (hostname, options, cb) => {
509
+ const callback = typeof options === 'function' ? options : cb;
510
+ const list = addresses.map((a) => ({ address: a.address, family: a.family || (a.address.includes(':') ? 6 : 4) }));
511
+ if (options && typeof options === 'object' && options.all) return callback(null, list);
512
+ return callback(null, list[0].address, list[0].family);
513
+ }
514
+ : undefined;
515
+
516
+ return new Promise((resolve, reject) => {
517
+ const req = mod.request(u, { method: 'GET', headers, lookup, timeout: timeoutMs }, (res) => {
518
+ const encoding = String(res.headers['content-encoding'] || '').toLowerCase();
519
+ let stream = res;
520
+ if (encoding === 'gzip' || encoding === 'x-gzip') stream = res.pipe(createGunzip());
521
+ else if (encoding === 'deflate') stream = res.pipe(createInflate());
522
+ else if (encoding === 'br') stream = res.pipe(createBrotliDecompress());
523
+
524
+ /** @type {Buffer[]} */
525
+ const chunks = [];
526
+ let total = 0;
527
+ let truncated = false;
528
+ let settled = false;
529
+
530
+ const finish = () => {
531
+ if (settled) return;
532
+ settled = true;
533
+ resolve({
534
+ status: res.statusCode ?? 0,
535
+ headers: res.headers,
536
+ body: Buffer.concat(chunks),
537
+ truncated,
538
+ });
539
+ };
540
+
541
+ stream.on('data', (chunk) => {
542
+ total += chunk.length;
543
+ if (total > maxBytes) {
544
+ // ⚠️ THE SOCKET IS DESTROYED, not just ignored. Reading a 4GB file to
545
+ // completion and then throwing it away is a denial of service the
546
+ // agent performs on its own user's connection.
547
+ truncated = true;
548
+ chunks.push(chunk.subarray(0, Math.max(0, chunk.length - (total - maxBytes))));
549
+ req.destroy();
550
+ res.destroy();
551
+ finish();
552
+ return;
553
+ }
554
+ chunks.push(chunk);
555
+ });
556
+ stream.on('end', finish);
557
+ stream.on('error', (err) => {
558
+ // A decompression failure on a body we deliberately cut short is not an
559
+ // error, it is the expected consequence of cutting it short.
560
+ if (truncated) return finish();
561
+ if (!settled) { settled = true; reject(err); }
562
+ });
563
+ });
564
+
565
+ req.on('timeout', () => {
566
+ const err = new Error('timed out');
567
+ err.code = 'ETIMEDOUT';
568
+ req.destroy(err);
569
+ });
570
+ req.on('error', reject);
571
+ req.end();
572
+ });
573
+ }
574
+
575
+ /** Decode a body buffer, honouring the one legacy charset still worth honouring. */
576
+ function decodeBody(buf, contentType) {
577
+ const m = /charset\s*=\s*"?([\w-]+)"?/i.exec(contentType || '');
578
+ const charset = (m?.[1] || 'utf-8').toLowerCase();
579
+ if (charset === 'iso-8859-1' || charset === 'latin1' || charset === 'windows-1252') return buf.toString('latin1');
580
+ return buf.toString('utf8');
581
+ }
582
+
583
+ /** The default one-hop transport, in the shape `fetchText` injects. */
584
+ async function defaultFetchImpl({ url, headers, timeoutMs, addresses }) {
585
+ return rawHttpRequest({ url, headers, timeoutMs, addresses });
586
+ }
587
+
588
+ /* ────────────────────────────────────────────────────────────────────────────
589
+ * THE TOOL
590
+ * ──────────────────────────────────────────────────────────────────────────── */
591
+
592
+ const KNOWN_ARGS = new Set(['root', 'url', 'offset', 'limit', 'fetchImpl', 'lookupImpl']);
593
+
594
+ function cachePathFor(url) {
595
+ return `${CACHE_DIR}/${createHash('sha1').update(url).digest('hex')}.txt`;
596
+ }
597
+
598
+ /** The window, plus the honest answer to "is there more". */
599
+ function windowOf(text, offset, limit) {
600
+ const slice = text.slice(offset, offset + limit);
601
+ const end = offset + slice.length;
602
+ return { text: slice, nextOffset: end < text.length ? end : null };
603
+ }
604
+
605
+ /**
606
+ * Fetch a URL and return a readable window of it.
607
+ *
608
+ * @param {{
609
+ * root?: string, url?: unknown, offset?: unknown, limit?: unknown,
610
+ * fetchImpl?: Function, lookupImpl?: Function
611
+ * }} params
612
+ * @returns {Promise<FetchOk | FetchRefused>}
613
+ */
614
+ export async function fetchText(params = {}) {
615
+ /**
616
+ * ⚠️ UNKNOWN KEYS ARE REFUSED BY NAME, HERE RATHER THAN IN THE DISPATCHER.
617
+ * The registration snippet spreads the model's arguments straight in, so this
618
+ * is the only place that sees them. A model that tried `headers` or `method`
619
+ * must be TOLD that this tool has no such control — silently dropping the key
620
+ * teaches it that the header was sent, which is the worse of the two failures.
621
+ */
622
+ const unknown = Object.keys(params).filter((k) => !KNOWN_ARGS.has(k));
623
+ if (unknown.length) {
624
+ return {
625
+ ok: false,
626
+ error: `fetch_url does not accept "${unknown[0]}". It sends a fixed GET — no headers, cookies, auth, method or body are configurable — and takes only url, offset and limit.`,
627
+ };
628
+ }
629
+ // The two injection seams are for tests and for the console's executor. A
630
+ // model can only ever supply JSON, so a non-function here is a model reaching
631
+ // for a control it does not have.
632
+ for (const seam of ['fetchImpl', 'lookupImpl']) {
633
+ if (params[seam] !== undefined && typeof params[seam] !== 'function') {
634
+ return { ok: false, error: `fetch_url does not accept "${seam}". It takes only url, offset and limit.` };
635
+ }
636
+ }
637
+
638
+ const { root, fetchImpl = defaultFetchImpl } = params;
639
+ const lookupImpl = params.lookupImpl
640
+ || ((host) => dns.lookup(host, { all: true, verbatim: true }));
641
+
642
+ if (typeof params.url !== 'string' || !params.url.trim()) {
643
+ return { ok: false, error: 'fetch_url needs a url, e.g. "https://nodejs.org/api/test.html"' };
644
+ }
645
+ const url = params.url.trim();
646
+
647
+ const offset = params.offset === undefined ? 0 : Number(params.offset);
648
+ if (!Number.isInteger(offset) || offset < 0) {
649
+ return { ok: false, error: `offset must be a whole number of characters from the start of the document, not ${JSON.stringify(params.offset)}` };
650
+ }
651
+ let limit = params.limit === undefined ? DEFAULT_WINDOW : Number(params.limit);
652
+ if (!Number.isInteger(limit) || limit <= 0) {
653
+ return { ok: false, error: `limit must be a positive whole number of characters, not ${JSON.stringify(params.limit)}` };
654
+ }
655
+ // Clamped rather than refused: a model asking for 100k characters wants the
656
+ // document, and giving it 12k plus a nextOffset answers that. Refusing would
657
+ // cost a round to learn a number it could have been handed.
658
+ if (limit > MAX_WINDOW) limit = MAX_WINDOW;
659
+
660
+ /* ── ALREADY FETCHED THIS SESSION → NO REQUEST AT ALL ───────────────────── */
661
+ const previous = session.get(url);
662
+ if (previous) {
663
+ const full = readCached(previous);
664
+ if (full === null) {
665
+ return { ok: false, error: `the cached copy of ${url} is gone from ${previous.cachePath}. Fetch a different URL, or re-read it with read_file if you still have the path.` };
666
+ }
667
+ return finishWindow({ ...previous, text: full, url, offset, limit, fromCache: true });
668
+ }
669
+
670
+ /* ── BUDGETS ───────────────────────────────────────────────────────────── */
671
+ if (fetchesMade >= MAX_FETCHES_PER_PROCESS) {
672
+ return {
673
+ ok: false,
674
+ error: `fetch_url has already made ${MAX_FETCHES_PER_PROCESS} requests this run, which is the limit. Re-read anything you already fetched (that is free and does not count), and work from what you have.`,
675
+ };
676
+ }
677
+
678
+ /* ── HOP LOOP ──────────────────────────────────────────────────────────── */
679
+ const deadline = Date.now() + FETCH_TIMEOUT_MS;
680
+ let current = url;
681
+ let hops = 0;
682
+ let response = null;
683
+ let finalUrl = url;
684
+
685
+ for (;;) {
686
+ const hop = await validateHop(current, lookupImpl);
687
+ if (!hop.ok) return hop;
688
+
689
+ /**
690
+ * ⚠️ THE BUDGET IS CHARGED ONCE PER LOOKUP, NOT ONCE PER HOP — and getting
691
+ * that wrong made its own test fail in an instructive way. Billing each
692
+ * redirect against the host meant `http → https → www → canonical`, which is
693
+ * ONE document and a completely normal chain, spent the entire per-host
694
+ * allowance before it arrived. The budgets exist to stop a CRAWL; a redirect
695
+ * chain is not a crawl and is already bounded by MAX_REDIRECTS.
696
+ */
697
+ if (hops === 0) {
698
+ const hostCount = perHost.get(hop.host) ?? 0;
699
+ if (hostCount >= MAX_FETCHES_PER_HOST) {
700
+ return {
701
+ ok: false,
702
+ error: `fetch_url has already requested ${MAX_FETCHES_PER_HOST} pages from ${hop.host} this run, which is the limit. Use what you fetched from it rather than crawling further.`,
703
+ };
704
+ }
705
+ perHost.set(hop.host, hostCount + 1);
706
+ fetchesMade += 1;
707
+ }
708
+
709
+ const remaining = deadline - Date.now();
710
+ if (remaining <= 0) {
711
+ return { ok: false, error: `${url} did not finish within ${FETCH_TIMEOUT_MS / 1000}s. Try a more specific page — this tool is for one document, not a slow endpoint.` };
712
+ }
713
+
714
+ const request = () => fetchImpl({
715
+ url: current,
716
+ headers: {
717
+ // ⚠️ FIXED, AND THE MODEL CANNOT ADD TO THEM. No cookie, no auth, no
718
+ // referer — this request carries nothing about the machine it runs on.
719
+ 'user-agent': userAgent(),
720
+ accept: 'text/html,text/plain,application/json',
721
+ 'accept-encoding': 'gzip, deflate, br',
722
+ connection: 'close',
723
+ },
724
+ timeoutMs: Math.min(remaining, FETCH_TIMEOUT_MS),
725
+ addresses: hop.addresses,
726
+ });
727
+
728
+ try {
729
+ response = await request();
730
+ } catch (err) {
731
+ const code = err?.code || err?.cause?.code || err?.name || 'unknown';
732
+ /**
733
+ * ⚠️ ONE RETRY, AND ONLY FOR A RESET. A connection reset genuinely is
734
+ * transient. A timeout is not — retrying it spends the timeout twice and
735
+ * is precisely the mistake breaker.mjs was written after.
736
+ */
737
+ const retryable = code === 'ECONNRESET' || code === 'EPIPE';
738
+ if (retryable && deadline - Date.now() > 2_000) {
739
+ try {
740
+ response = await request();
741
+ } catch (err2) {
742
+ return { ok: false, error: `could not fetch ${current}: ${err2?.code || err2?.message || 'connection failed'} (retried once)` };
743
+ }
744
+ } else {
745
+ const hint = code === 'ETIMEDOUT' || code === 'ERR_SOCKET_TIMEOUT'
746
+ ? ' — the server did not answer in time. Pick a lighter page rather than repeating this one.'
747
+ : '';
748
+ return { ok: false, error: `could not fetch ${current}: ${code}${hint}` };
749
+ }
750
+ }
751
+
752
+ const status = response?.status ?? 0;
753
+ const location = response?.headers?.location;
754
+ if (status >= 300 && status < 400 && location) {
755
+ hops += 1;
756
+ if (hops > MAX_REDIRECTS) {
757
+ return { ok: false, error: `${url} redirected more than ${MAX_REDIRECTS} times. Fetch the final URL directly.` };
758
+ }
759
+ try {
760
+ current = new URL(location, current).toString();
761
+ } catch {
762
+ return { ok: false, error: `${current} redirected to something that is not a URL ("${String(location).slice(0, 80)}")` };
763
+ }
764
+ continue; // ⭐ and the guard runs again at the top — that is the whole point
765
+ }
766
+
767
+ finalUrl = current;
768
+ break;
769
+ }
770
+
771
+ /* ── STATUS ────────────────────────────────────────────────────────────── */
772
+ const rawHeaders = response.headers || {};
773
+ const contentType = String(rawHeaders['content-type'] || '').trim();
774
+ const bodyBuf = Buffer.isBuffer(response.body) ? response.body : Buffer.from(String(response.body ?? ''), 'utf8');
775
+
776
+ if (response.status < 200 || response.status >= 300) {
777
+ // ⚠️ THE BODY IS INCLUDED. "HTTP 404" tells a model nothing it can act on;
778
+ // the body usually says "moved to /api/test.html" and names the fix.
779
+ const snippet = decodeBody(bodyBuf, contentType).replace(/\s+/g, ' ').trim().slice(0, 500);
780
+ return {
781
+ ok: false,
782
+ error: `${finalUrl} returned HTTP ${response.status}${snippet ? `: ${snippet}` : ''}`,
783
+ };
784
+ }
785
+
786
+ /* ── CONTENT TYPE ──────────────────────────────────────────────────────── */
787
+ const mime = contentType.split(';')[0].trim().toLowerCase();
788
+ // A server that states no type is almost always serving plain text (raw files,
789
+ // small APIs). Guessing text here is safe because the WRONG guess is caught
790
+ // below by the same binary check `read_file` uses.
791
+ const effective = mime || 'text/plain';
792
+ if (!TEXTUAL.test(effective)) {
793
+ return {
794
+ ok: false,
795
+ // ⚠️ NAMES THE TYPE. "unsupported content" sends the model back to try the
796
+ // same URL a different way; "it is image/png" ends the line of enquiry.
797
+ error: `${finalUrl} is ${effective}, which fetch_url does not read — it converts text, HTML, JSON and XML only.`,
798
+ };
799
+ }
800
+
801
+ let decoded = decodeBody(bodyBuf, contentType);
802
+ if (decoded.includes('\u0000')) {
803
+ return { ok: false, error: `${finalUrl} claims to be ${effective} but the body is binary. Refusing to read it as text.` };
804
+ }
805
+
806
+ /* ── CONVERT ───────────────────────────────────────────────────────────── */
807
+ let text;
808
+ /**
809
+ * ⭐ JSON IS DETECTED BY PARSING IT, NOT ONLY BY BELIEVING THE HEADER — found
810
+ * in the live demo: `https://registry.npmjs.org/zod/latest` came back declared
811
+ * `text/plain`, so a 40KB single-line payload went to the model minified, and
812
+ * every 6000-character window landed mid-token. Sniffing is normally a bad
813
+ * idea, but this variant cannot produce a false positive: the test IS
814
+ * `JSON.parse` succeeding on the whole body. If it parses, it is JSON.
815
+ */
816
+ const looksJson = /json/.test(effective)
817
+ || (effective === 'text/plain' && /^[\s]*[[{]/.test(decoded));
818
+ if (looksJson) {
819
+ // Pretty-printed rather than HTML-converted: a minified JSON payload on one
820
+ // line is unreadable and, worse, unpageable — every window lands mid-token.
821
+ try { text = JSON.stringify(JSON.parse(decoded), null, 2); } catch { text = decoded; }
822
+ } else if (effective === 'text/html' || effective === 'application/xhtml+xml') {
823
+ text = htmlToText(decoded);
824
+ } else {
825
+ // text/plain, text/markdown, xml, css, whatever else is textual — pass
826
+ // through. Converting markdown would destroy the structure it came with.
827
+ text = decoded;
828
+ }
829
+ if (response.truncated) {
830
+ text += `\n\n[fetch_url stopped at ${MAX_BODY_BYTES / (1024 * 1024)}MB — this document is longer than that and the rest was not downloaded]`;
831
+ }
832
+
833
+ /* ── CACHE ─────────────────────────────────────────────────────────────── */
834
+ const record = {
835
+ cachePath: null, absolute: null, text: null, finalUrl, status: response.status,
836
+ contentType: effective, totalChars: text.length, truncated: Boolean(response.truncated),
837
+ };
838
+ const written = writeCache(root, url, text);
839
+ if (written.path) {
840
+ record.cachePath = written.path;
841
+ // The absolute path is kept so a repeat call reads the file back without
842
+ // re-deriving it from the URL hash — the derivation is the part that could
843
+ // silently drift if the naming ever changes.
844
+ record.absolute = written.absolute;
845
+ } else {
846
+ // ⚠️ A memory workspace (and a read-only disk) has nowhere to put it, so the
847
+ // full text is held in the session map instead. It is NOT reported as
848
+ // cached — claiming a cachePath that cannot be read is the reassuring lie
849
+ // this codebase keeps having to delete.
850
+ record.text = text;
851
+ }
852
+ session.set(url, record);
853
+
854
+ return finishWindow({ ...record, text, url, offset, limit, fromCache: false });
855
+ }
856
+
857
+ /** Read the whole converted document back for a repeat call. */
858
+ function readCached(record) {
859
+ if (record.text !== null && record.text !== undefined) return record.text;
860
+ if (!record.absolute || !existsSync(record.absolute)) return null;
861
+ try { return readFileSync(record.absolute, 'utf8'); } catch { return null; }
862
+ }
863
+
864
+ /**
865
+ * Write the FULL converted text so later windows cost nothing.
866
+ *
867
+ * ⚠️ THROUGH `resolveInWorkspace`, like every other path in this package. The
868
+ * filename is a sha1 of the URL, so it cannot contain anything interesting, but
869
+ * routing it through the workspace rules anyway is what keeps "every path goes
870
+ * through one function" true rather than nearly true.
871
+ */
872
+ function writeCache(root, url, text) {
873
+ if (!root || root === '(memory)') return { path: null };
874
+ const rel = cachePathFor(url);
875
+ const target = resolveInWorkspace(root, rel, 'write');
876
+ if (!target.ok) return { path: null };
877
+ try {
878
+ mkdirSync(dirname(target.absolute), { recursive: true });
879
+ writeFileSync(target.absolute, text, 'utf8');
880
+ } catch {
881
+ // A read-only or full disk must not fail the lookup — the window in hand is
882
+ // still useful, and the only thing lost is the free second page.
883
+ return { path: null };
884
+ }
885
+ return { path: target.relative, absolute: target.absolute };
886
+ }
887
+
888
+ /** Assemble the result. One place, so a cached hit and a fresh fetch cannot drift. */
889
+ function finishWindow(r) {
890
+ if (r.offset > 0 && r.offset >= r.text.length) {
891
+ return {
892
+ ok: false,
893
+ error: `offset ${r.offset} is past the end of ${r.url} (${r.text.length} characters). The last window starts at ${Math.max(0, r.text.length - DEFAULT_WINDOW)}.`,
894
+ };
895
+ }
896
+ const w = windowOf(r.text, r.offset, r.limit);
897
+ return {
898
+ ok: true,
899
+ url: r.url,
900
+ finalUrl: r.finalUrl,
901
+ status: r.status,
902
+ contentType: r.contentType,
903
+ totalChars: r.text.length,
904
+ cachePath: r.cachePath ?? null,
905
+ fromCache: Boolean(r.fromCache),
906
+ text: w.text,
907
+ nextOffset: w.nextOffset,
908
+ ...(r.truncated ? { truncated: true } : {}),
909
+ };
910
+ }
911
+
912
+ /* ────────────────────────────────────────────────────────────────────────────
913
+ * SCHEMA
914
+ * ──────────────────────────────────────────────────────────────────────────── */
915
+
916
+ /**
917
+ * ⚠️ THE DESCRIPTION STATES THE LOCALHOST LIMIT OUT LOUD. A model that does not
918
+ * know it will spend a round discovering it, then a second round arguing with
919
+ * the refusal — the two rounds probe D spent on truncation. What the schema says
920
+ * is the cheapest documentation in the system.
921
+ */
922
+ export function fetchToolSchemas() {
923
+ return [
924
+ {
925
+ type: 'function',
926
+ function: {
927
+ name: 'fetch_url',
928
+ description: [
929
+ 'Fetch a public http/https page as readable TEXT — this is how you check an API before you use it,',
930
+ 'instead of guessing an option name or an import shape.',
931
+ 'HTML is converted to text with code blocks kept intact, JSON is pretty-printed, plain text passes through.',
932
+ `Returns a window of ${DEFAULT_WINDOW} characters by default (max ${MAX_WINDOW});`,
933
+ 'the reply carries `nextOffset` — call again with that as `offset` for the next window, which is free',
934
+ 'because the whole document is cached on the first call. `cachePath` names that file if you would rather read it.',
935
+ 'GET only: no headers, cookies, authentication, method or body can be set, by design.',
936
+ 'It CANNOT reach private or local addresses, so it cannot talk to a server you just started on localhost',
937
+ '— use run_command to test that instead.',
938
+ `At most ${MAX_FETCHES_PER_PROCESS} fetches per run and ${MAX_FETCHES_PER_HOST} per host; re-reading a URL you already fetched is free.`,
939
+ ].join(' '),
940
+ parameters: {
941
+ type: 'object',
942
+ properties: {
943
+ url: { type: 'string', description: 'A full http/https URL, e.g. "https://nodejs.org/api/test.html".' },
944
+ offset: { type: 'integer', description: 'Character offset to start the window at. Use the `nextOffset` from the previous call.' },
945
+ limit: { type: 'integer', description: `Characters to return, default ${DEFAULT_WINDOW}, max ${MAX_WINDOW}.` },
946
+ },
947
+ required: ['url'],
948
+ },
949
+ },
950
+ },
951
+ ];
952
+ }