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,255 @@
1
+ /**
2
+ * ── ⭐⭐⭐ THE CACHE LIVES ON ONE UPSTREAM, SO STAY ON IT ─────────────────────
3
+ *
4
+ * Roman, 2026-08-19: *"the 90 percent caching has to stay permanent always.
5
+ * Anytime Acuvo software is wrapping DeepSeek the caching must be that high 90s
6
+ * otherwise we're cooked."*
7
+ *
8
+ * ⚠️ MEASURED ON A LIVE RUN THE SAME DAY — a 5-round task came back at
9
+ * **cache 59%**, because one round was served by the pin's SECOND name:
10
+ *
11
+ * served by StreamLake (1st choice) 11,520/11,714 cached 98.3% $0.000172
12
+ * served by Baidu (2nd choice) 0/11,714 cached 0.0% $0.000791
13
+ *
14
+ * **4.6× the cost for byte-identical input.** Over 40 pinned calls the scatter
15
+ * measured StreamLake 38 / Baidu 2 — a ~5% event, and 5% of rounds landing cold
16
+ * is what turns 98% into 59% on a short task.
17
+ *
18
+ * ── ⚠️ WHY REORDERING THE PIN CANNOT FIX THIS ───────────────────────────────
19
+ *
20
+ * StreamLake was ALREADY first. `provider.order` is a preference, not a lock, so
21
+ * OpenRouter may route past it whenever it likes. The only thing that forces
22
+ * adherence is `allow_fallbacks: false`.
23
+ *
24
+ * ── ⚠️⚠️ AND WHY WE CANNOT SIMPLY SET THAT ON THE PIN ───────────────────────
25
+ *
26
+ * Two reasons, both measured and both in `model.mjs`:
27
+ *
28
+ * 1. **"Never single"** is this package's standing rule — a cheaper request
29
+ * that does not happen is not cheaper.
30
+ * 2. A pinned name is **not proof it can be reached**. `deepseek-v4-pro`'s pin
31
+ * begins with `DeepSeek`, which answers *"No endpoints found"* for this
32
+ * account — an OpenRouter data-policy exclusion, not an outage. Forcing
33
+ * `allow_fallbacks:false` on the first name would cost EVERY pro round an
34
+ * extra failed hop, forever.
35
+ *
36
+ * ── ⭐ SO: LEARN, THEN LOCK ─────────────────────────────────────────────────
37
+ *
38
+ * Round 1 routes exactly as it does today — full preference list, fallbacks on.
39
+ * We then read who actually served it off the response. That provider is now
40
+ * **known-reachable** (it just served us) and **warm** (it holds our prefix), so
41
+ * every later round in the session asks for that one name with fallbacks off.
42
+ *
43
+ * On any failure the warm pin is forgotten and the next round routes normally,
44
+ * so a provider going down costs one round, not the session. "Never single"
45
+ * survives: we still fall back — the difference is that we fall back *after an
46
+ * explicit failure* instead of silently, mid-session, onto a cold cache.
47
+ *
48
+ * Pure and dependency-free: the state is a plain object the caller owns, so a
49
+ * test needs no network and two sessions cannot contaminate each other.
50
+ */
51
+
52
+ /** A fresh, empty memory of which upstream is warm for which model. */
53
+ export function freshWarmth() {
54
+ return { byModel: new Map() };
55
+ }
56
+
57
+ /**
58
+ * Record who actually served a round.
59
+ *
60
+ * ⚠️ ONLY EVER CALLED WITH A PROVIDER THE RESPONSE NAMED. Guessing here would
61
+ * pin us to a provider that never served, which is the cold-cache bug with
62
+ * extra steps.
63
+ */
64
+ export function rememberWarm(state, model, provider) {
65
+ const m = String(model ?? '').trim();
66
+ const p = String(provider ?? '').trim();
67
+ if (!state?.byModel || !m || !p) return state;
68
+ state.byModel.set(m, p);
69
+ return state;
70
+ }
71
+
72
+ /** Forget the warm provider for a model — call this on any failed round. */
73
+ export function forgetWarm(state, model) {
74
+ const m = String(model ?? '').trim();
75
+ if (state?.byModel && m) state.byModel.delete(m);
76
+ return state;
77
+ }
78
+
79
+ export function warmProviderFor(state, model) {
80
+ const m = String(model ?? '').trim();
81
+ return (state?.byModel && m && state.byModel.get(m)) || null;
82
+ }
83
+
84
+ /**
85
+ * The provider preference for the NEXT call.
86
+ *
87
+ * @returns {{ order: string[], strict: boolean, reason: string }}
88
+ *
89
+ * ⚠️ `strict` is only ever true alongside a single name we have SEEN SERVE a
90
+ * round. It is never true for a name read from configuration — that is the
91
+ * distinction between "this endpoint answered us 20 seconds ago" and "somebody
92
+ * typed this", and only the first justifies removing the fallback.
93
+ */
94
+ export function routeFor(state, model, baseOrder = []) {
95
+ const base = Array.isArray(baseOrder) ? baseOrder.filter(Boolean) : [];
96
+ const warm = warmProviderFor(state, model);
97
+
98
+ if (!warm) {
99
+ return {
100
+ order: base,
101
+ strict: false,
102
+ reason: base.length
103
+ ? 'first round of the session — routing on the configured preference, fallbacks on'
104
+ : 'no pin configured',
105
+ };
106
+ }
107
+
108
+ /**
109
+ * ⭐ THE WARM NAME ALONE. Sending `[warm, ...others]` would be pointless: that
110
+ * is a preference list again, and a preference list is exactly what let a
111
+ * round land on Baidu while StreamLake sat first.
112
+ */
113
+ return {
114
+ order: [warm],
115
+ strict: true,
116
+ reason: `${warm} served an earlier round and holds this session's prompt cache`,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Did this round land where the cache is?
122
+ *
123
+ * ⭐ Reported rather than merely acted on, because the failure this whole module
124
+ * exists to fix was **invisible**: every layer called a fallback `pinTook: 1`
125
+ * and a 4.6× bill looked like a healthy reading.
126
+ */
127
+ export function describeRouting({ expected = null, served = null } = {}) {
128
+ const e = String(expected ?? '').trim();
129
+ const s = String(served ?? '').trim();
130
+ if (!e || !s) return { warm: null, note: null };
131
+ if (e === s) return { warm: true, note: null };
132
+ return {
133
+ warm: false,
134
+ note: `${s} served this round instead of ${e}, so the prompt cache did not apply — `
135
+ + 'the same bytes cost roughly 4.6× more. The next round will route normally and re-learn.',
136
+ };
137
+ }
138
+
139
+ // ── ⭐⭐⭐ ACROSS SESSIONS, WHICH IS WHERE THE LAST 8 POINTS LIVE ─────────────
140
+ //
141
+ // Roman, 2026-08-19: *"the caching is inconsistent with what we said it would
142
+ // be, and we can't have that, it needs to be 90."*
143
+ //
144
+ // ⚠️ MEASURED, THREE LIVE RUNS: 59% → 74% → 82%. The remaining gap is ROUND ONE,
145
+ // and within a single session it is unfixable — nothing is cached before the
146
+ // first request. But the system prompt and the tool schemas are **byte-identical
147
+ // on every run this CLI ever makes**, and a provider's prefix cache lives
148
+ // upstream for minutes to hours. So round one only has to be cold ONCE, ever.
149
+ //
150
+ // ⭐ WE WERE THROWING THAT AWAY. Round one routed on the configured preference
151
+ // list with fallbacks on, so it could land on a different upstream than the last
152
+ // run — walking past a warm cache that already held our exact prefix.
153
+ //
154
+ // Persisting the name under HOME (never the workspace — see `account.mjs`'s
155
+ // argument, and note `WRITE_FORBIDDEN_ROOTS` does not cover `.acuvo`) means the
156
+ // next run starts warm. On a machine that runs this tool twice, round one is
157
+ // cached too, and the session average moves from the low 80s into the 90s.
158
+ //
159
+ // ⚠️ IT IS A HINT, NEVER A LOCK ON A COLD START. If the remembered upstream has
160
+ // gone away the round fails once, `forgetWarm` clears it, and the next attempt
161
+ // uses the full list — the same failure path the in-session lock already uses.
162
+
163
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
164
+ import { homedir } from 'node:os';
165
+ import { join, dirname } from 'node:path';
166
+
167
+ /** Beside the credential, for the same reason it lives there rather than in a repo. */
168
+ export function warmthPath(env = process.env, home = homedir()) {
169
+ const override = String(env?.ACUVO_HOME ?? '').trim();
170
+ return join(override || join(home, '.acuvo'), 'warm-providers.json');
171
+ }
172
+
173
+ /**
174
+ * Load what served us last time.
175
+ *
176
+ * ⚠️ NEVER THROWS. A corrupt or absent file means "we do not know", which is
177
+ * exactly the state a first run is in — and a cache hint that could break a run
178
+ * would be a worse trade than the hit rate it buys.
179
+ */
180
+ export function loadWarmth(env = process.env, home = homedir()) {
181
+ const state = freshWarmth();
182
+ try {
183
+ const raw = JSON.parse(readFileSync(warmthPath(env, home), 'utf8'));
184
+ for (const [model, provider] of Object.entries(raw?.byModel ?? {})) {
185
+ if (typeof provider === 'string' && provider.trim()) state.byModel.set(model, provider.trim());
186
+ }
187
+ } catch { /* unknown is a valid answer */ }
188
+ return state;
189
+ }
190
+
191
+ /** Persist for the next run. Best-effort: never fail a completed run over a hint. */
192
+ export function saveWarmth(state, env = process.env, home = homedir()) {
193
+ try {
194
+ const path = warmthPath(env, home);
195
+ mkdirSync(dirname(path), { recursive: true });
196
+ writeFileSync(path, `${JSON.stringify({ byModel: Object.fromEntries(state?.byModel ?? []) }, null, 2)}\n`);
197
+ return true;
198
+ } catch {
199
+ return false;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * ── ⭐⭐ ONE LINE FOR `/model`: WHAT ACTUALLY SERVED, AND WHAT IT COST ───────
205
+ *
206
+ * `aggregateProviders` in `turn.mjs` already counts this per turn, and
207
+ * `formatSummary` prints it once when the turn ends. This is the same facts
208
+ * worded for someone asking mid-session — the moment people actually ask,
209
+ * because the question is "why is this costing more than it did".
210
+ *
211
+ * ⚠️ `pinFellBack` IS THE WHOLE POINT. A later name in the pin serving a round
212
+ * is a cold prefix cache billed at up to 4.6x — measured on one byte-identical
213
+ * payload, 98.3% cached on StreamLake and 0.0% on Baidu — and it raises no
214
+ * error anywhere. `pinTook` and `pinMissed` are both loud by comparison. So the
215
+ * silent case gets the sentence.
216
+ *
217
+ * ⚠️ RETURNS `null`, NOT A REASSURING STRING, when nothing is known. A
218
+ * transport that reports no routing is UNKNOWN, and telling someone their
219
+ * cache is fine on no evidence is worse than saying nothing — the same rule
220
+ * `parseReply` follows when it leaves `provider` null rather than "unpinned".
221
+ *
222
+ * @param {{ pin?: string[]|null, served?: Record<string, number>, pinTook?: number,
223
+ * pinFellBack?: number, pinMissed?: number, roundsUnknown?: number }|null} providers
224
+ * @returns {string|null}
225
+ */
226
+ export function routingNote(providers) {
227
+ if (!providers) return null;
228
+ const served = Object.entries(providers.served ?? {});
229
+ if (served.length === 0) return null;
230
+
231
+ // Busiest first — the one that served most rounds is the one that matters.
232
+ const ranked = [...served].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
233
+ const total = ranked.reduce((n, [, count]) => n + count, 0);
234
+ const where = ranked.length === 1
235
+ ? ranked[0][0]
236
+ : `${ranked.map(([name, count]) => `${name} x${count}`).join(', ')}`;
237
+
238
+ const fellBack = Number(providers.pinFellBack ?? 0);
239
+ const missed = Number(providers.pinMissed ?? 0);
240
+ const pin = Array.isArray(providers.pin) && providers.pin.length > 0 ? providers.pin : null;
241
+
242
+ const head = `served by ${where} over ${total} round${total === 1 ? '' : 's'}`;
243
+ if (!pin) return `${head} — no provider pin, so the prompt cache is wherever routing landed.`;
244
+
245
+ if (missed > 0 && fellBack === 0 && missed === total) {
246
+ return `${head} — the pin (${pin.join(', ')}) matched nothing. Usually a name typo; `
247
+ + 'the prompt cache is cold every round until it is fixed.';
248
+ }
249
+ if (fellBack > 0 || missed > 0) {
250
+ const cold = fellBack + missed;
251
+ return `${head} — ${cold} of ${total} round${cold === 1 ? '' : 's'} did NOT land on `
252
+ + `${pin[0]}, so those paid a cold prefix cache (up to 4.6x the same bytes).`;
253
+ }
254
+ return `${head} — pinned to ${pin[0]} and it held every round, so the prompt cache applied.`;
255
+ }
@@ -0,0 +1,401 @@
1
+ /**
2
+ * ── ⭐⭐ WEB SEARCH — THE HALF OF THE WEB THE AGENT COULD NOT REACH ──────────
3
+ *
4
+ * `fetch_url` could read a page it was TOLD about. It could not FIND one. For a
5
+ * coding agent that gap is the difference between checking an API and guessing
6
+ * at it — and guessing at an option name is the single commonest way a model
7
+ * writes confident, wrong code.
8
+ *
9
+ * ── WHAT WAS MEASURED, BEFORE ANY OF THIS WAS WRITTEN ───────────────────────
10
+ *
11
+ * Ten keyless backends were probed from a real machine. The byte counts alone
12
+ * would have picked the wrong winner, which is exactly why parsing came second:
13
+ *
14
+ * ddg-html 200 · 10 results · 10 snippets · real URLs, already unwrapped
15
+ * ddg-lite 200 · 10 results · parses fine once you accept SINGLE-quoted
16
+ * class attributes (`class='result-link'`) — my first regex
17
+ * assumed double quotes and reported zero
18
+ * stackexchange 200 · real JSON · NO KEY · 300 requests/day quota
19
+ * marginalia 200 · 162KB · 29 distinct hosts … and TWO parseable links,
20
+ * both boilerplate (ip2location, creativecommons)
21
+ * searx.be 200 · JSON format disabled, serves a consent wall instead
22
+ * searxng.site 403 · priv.au 403 · mojeek walled
23
+ *
24
+ * ⚠️⭐ MARGINALIA IS THE LESSON. On the first probe it looked like the BEST
25
+ * backend — 162KB and 29 hosts, more than anything else returned. Parsing it
26
+ * showed 29 footer links and no results. **A byte-level proxy for "did this
27
+ * work" ranks a boilerplate-heavy page above a correct one.** Never accept a
28
+ * size or a status as evidence that a fetch produced usable content.
29
+ *
30
+ * ── ⚠️ THE HONEST LIMIT OF "NEVER SINGLE" HERE ──────────────────────────────
31
+ *
32
+ * `ddg-html` and `ddg-lite` are two endpoints of ONE operator. Listing them as
33
+ * two providers would be false redundancy — if DuckDuckGo blocks this IP, both
34
+ * die together. The genuinely independent leg is StackExchange, and it is a
35
+ * DIFFERENT KIND of source (programming Q&A, not the open web), so it is used
36
+ * as a specialist rather than a drop-in replacement. That is stated here rather
37
+ * than papered over, because a fallback that cannot actually fall back is worse
38
+ * than no fallback: it buys confidence it has not earned.
39
+ */
40
+
41
+ import { htmlToText } from './fetch-text.mjs';
42
+
43
+ export const SEARCH_TIMEOUT_MS = 12_000;
44
+ export const MAX_SEARCHES_PER_PROCESS = 12;
45
+ export const DEFAULT_LIMIT = 6;
46
+ export const MAX_LIMIT = 12;
47
+ export const MAX_QUERY_CHARS = 400;
48
+ export const MAX_SNIPPET_CHARS = 320;
49
+
50
+ /**
51
+ * ⚠️ A REAL BROWSER STRING, on purpose. These endpoints exist for browsers and
52
+ * serve a consent page to anything that announces itself as a script — which
53
+ * would arrive here as a 200 full of nothing, the failure that looks like
54
+ * success. It is not a disguise: the request is a plain public GET, rate
55
+ * limited below, and identifies no user.
56
+ */
57
+ const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15';
58
+
59
+ let searchesThisProcess = 0;
60
+
61
+ /** Test seam: the per-process cap must not leak between test files. */
62
+ export function resetSearchState() {
63
+ searchesThisProcess = 0;
64
+ }
65
+
66
+ /**
67
+ * ⚠️ SINGLE **AND** DOUBLE QUOTES. DuckDuckGo's two endpoints disagree with
68
+ * each other — `/html/` writes `class="result__a"` and `/lite/` writes
69
+ * `class='result-link'`. A parser that assumes one silently returns zero
70
+ * results against the other, and "zero results" is indistinguishable from "no
71
+ * matches for your query" unless you go looking. This cost a probe cycle.
72
+ */
73
+ const attr = (name, value) => new RegExp(`${name}\\s*=\\s*["']${value}["']`);
74
+
75
+ function stripTags(html) {
76
+ return String(html)
77
+ .replace(/<[^>]*>/g, '')
78
+ .replace(/&amp;/g, '&')
79
+ .replace(/&lt;/g, '<')
80
+ .replace(/&gt;/g, '>')
81
+ .replace(/&quot;/g, '"')
82
+ .replace(/&#x27;|&#39;/g, "'")
83
+ .replace(/&nbsp;/g, ' ')
84
+ .replace(/\s+/g, ' ')
85
+ .trim();
86
+ }
87
+
88
+ /**
89
+ * ⚠️ DDG SOMETIMES WRAPS RESULTS IN ITS OWN REDIRECTOR
90
+ * (`//duckduckgo.com/l/?uddg=<encoded>`). Handing that to the model would send
91
+ * the next `fetch_url` to duckduckgo.com instead of the page, and the model
92
+ * would have no way to tell — the URL LOOKS like a result.
93
+ */
94
+ export function unwrapRedirect(href) {
95
+ const raw = String(href || '');
96
+ const m = /[?&]uddg=([^&]+)/.exec(raw);
97
+ let out = raw;
98
+ if (m) {
99
+ try { out = decodeURIComponent(m[1]); } catch { /* keep the raw form */ }
100
+ }
101
+ if (out.startsWith('//')) out = `https:${out}`;
102
+ return out;
103
+ }
104
+
105
+ function usable(url) {
106
+ try {
107
+ const u = new URL(url);
108
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
109
+ // A result pointing back at the search engine is navigation, not an answer.
110
+ return !/(^|\.)duckduckgo\.com$/i.test(u.hostname);
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ function clampSnippet(s) {
117
+ const t = stripTags(s);
118
+ return t.length > MAX_SNIPPET_CHARS ? `${t.slice(0, MAX_SNIPPET_CHARS - 1).trimEnd()}…` : t;
119
+ }
120
+
121
+ /* ── the providers ────────────────────────────────────────────────────────── */
122
+
123
+ function parseDuckDuckGo(html, { linkClass, snippetClass }) {
124
+ const out = [];
125
+ const anchorRe = new RegExp(`<a\\b([^>]*)>([\\s\\S]*?)<\\/a>`, 'g');
126
+ const linkIs = attr('class', linkClass);
127
+ for (const m of String(html).matchAll(anchorRe)) {
128
+ const attrs = m[1];
129
+ if (!linkIs.test(attrs)) continue;
130
+ const href = /href\s*=\s*["']([^"']+)["']/.exec(attrs);
131
+ if (!href) continue;
132
+ const url = unwrapRedirect(href[1]);
133
+ const title = stripTags(m[2]);
134
+ if (title && usable(url)) out.push({ title, url, snippet: '' });
135
+ }
136
+
137
+ // Snippets are siblings, not children — matched positionally, in document
138
+ // order, which is the order DuckDuckGo emits them in.
139
+ const snipRe = new RegExp(`class\\s*=\\s*["']${snippetClass}["'][^>]*>([\\s\\S]*?)<\\/(?:td|a|div)>`, 'g');
140
+ const snippets = [...String(html).matchAll(snipRe)].map((m) => clampSnippet(m[1]));
141
+ for (let i = 0; i < out.length; i += 1) if (snippets[i]) out[i].snippet = snippets[i];
142
+ return out;
143
+ }
144
+
145
+ const PROVIDERS = [
146
+ {
147
+ id: 'duckduckgo',
148
+ kind: 'general',
149
+ url: (q) => `https://html.duckduckgo.com/html/?q=${encodeURIComponent(q)}`,
150
+ parse: (body) => parseDuckDuckGo(body, { linkClass: 'result__a', snippetClass: 'result__snippet' }),
151
+ },
152
+ {
153
+ /**
154
+ * ⚠️ NOT AN INDEPENDENT PROVIDER — the same operator behind a lighter
155
+ * template. It is here because the two endpoints fail SEPARATELY (markup
156
+ * changes, per-endpoint throttling), not because it survives a block.
157
+ */
158
+ id: 'duckduckgo-lite',
159
+ kind: 'general',
160
+ url: (q) => `https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(q)}`,
161
+ parse: (body) => parseDuckDuckGo(body, { linkClass: 'result-link', snippetClass: 'result-snippet' }),
162
+ },
163
+ {
164
+ /**
165
+ * ⭐ THE GENUINELY INDEPENDENT LEG, and the one most likely to be RIGHT for
166
+ * a coding agent: a real JSON API, no key, 300 requests/day.
167
+ */
168
+ id: 'stackoverflow',
169
+ kind: 'code',
170
+ url: (q) => 'https://api.stackexchange.com/2.3/search/advanced'
171
+ + `?order=desc&sort=relevance&site=stackoverflow&pagesize=10&q=${encodeURIComponent(q)}`,
172
+ parse: (body) => {
173
+ const j = JSON.parse(body);
174
+ if (j.error_message) throw new Error(String(j.error_message));
175
+ return (j.items || [])
176
+ .filter((it) => it && it.link)
177
+ .map((it) => ({
178
+ title: stripTags(it.title || it.link),
179
+ url: it.link,
180
+ snippet: clampSnippet(
181
+ `${it.is_answered ? 'answered' : 'unanswered'} · score ${it.score ?? 0}`
182
+ + `${Array.isArray(it.tags) && it.tags.length ? ` · ${it.tags.slice(0, 5).join(', ')}` : ''}`,
183
+ ),
184
+ }));
185
+ },
186
+ },
187
+ ];
188
+
189
+ export const PROVIDER_IDS = Object.freeze(PROVIDERS.map((p) => p.id));
190
+
191
+ async function defaultFetchImpl(url, timeoutMs) {
192
+ const ac = new AbortController();
193
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
194
+ try {
195
+ const res = await fetch(url, {
196
+ signal: ac.signal,
197
+ redirect: 'follow',
198
+ headers: {
199
+ 'user-agent': UA,
200
+ accept: 'text/html,application/json;q=0.9,*/*;q=0.8',
201
+ 'accept-language': 'en-US,en;q=0.9',
202
+ },
203
+ });
204
+ return { status: res.status, body: await res.text() };
205
+ } finally {
206
+ clearTimeout(timer);
207
+ }
208
+ }
209
+
210
+ /**
211
+ * ⚠️ A CONSENT WALL ARRIVES AS A 200. Detecting it by status is impossible;
212
+ * detecting it by "did we parse any results" is the only reliable signal, so
213
+ * an empty parse from a 200 is treated as a PROVIDER FAILURE and the chain
214
+ * advances — not as "the web has nothing on this".
215
+ */
216
+ function describeEmpty(body) {
217
+ if (/captcha|unusual traffic|are you a robot/i.test(body)) return 'the endpoint served a bot check instead of results';
218
+ if (/consent|cookie policy/i.test(body)) return 'the endpoint served a consent wall instead of results';
219
+ if (/enable javascript/i.test(body)) return 'the endpoint requires JavaScript to render results';
220
+ return 'the endpoint answered but no results could be parsed out of it';
221
+ }
222
+
223
+ /**
224
+ * Search the public web. Returns results the model can then `fetch_url`.
225
+ *
226
+ * ⚠️ THE RETURN SHAPE DISTINGUISHES THREE OUTCOMES that a bare array cannot:
227
+ * results found · a provider answered with genuinely nothing · every provider
228
+ * failed. Collapsing the last two into `[]` teaches the model that its query
229
+ * was bad when the truth is that the network was.
230
+ */
231
+ export async function webSearch(params = {}) {
232
+ const query = String(params.query ?? '').trim();
233
+ const limit = Math.max(1, Math.min(MAX_LIMIT, Number(params.limit) || DEFAULT_LIMIT));
234
+ const fetchImpl = params.fetchImpl || defaultFetchImpl;
235
+ const timeoutMs = Number(params.timeoutMs) || SEARCH_TIMEOUT_MS;
236
+
237
+ if (!query) return { ok: false, error: 'web_search needs a `query` — an empty search has no answer to give you.' };
238
+ if (query.length > MAX_QUERY_CHARS) {
239
+ return { ok: false, error: `that query is ${query.length} characters; keep it under ${MAX_QUERY_CHARS}. Search engines match keywords, not essays — cut it to the distinctive terms.` };
240
+ }
241
+ if (searchesThisProcess >= MAX_SEARCHES_PER_PROCESS) {
242
+ return {
243
+ ok: false,
244
+ error: `this run has already made ${MAX_SEARCHES_PER_PROCESS} web searches, which is the cap. If the answer has not turned up, searching again with the same words will not find it — read one of the results with fetch_url instead.`,
245
+ };
246
+ }
247
+ searchesThisProcess += 1;
248
+
249
+ /**
250
+ * ⭐ THE CODE PROVIDER GOES FIRST FOR CODE QUESTIONS. A StackOverflow answer
251
+ * is more useful to a coding agent than a listicle, and it costs one request
252
+ * from a 300/day quota rather than scraping HTML.
253
+ */
254
+ const wantCode = params.kind === 'code'
255
+ || /\b(error|exception|typeerror|referenceerror|stack ?trace|undefined is not|cannot read|npm|pip|cargo|traceback|segfault|econnrefused|enoent)\b/i.test(query);
256
+ const order = wantCode
257
+ ? [...PROVIDERS].sort((a, b) => (a.kind === 'code' ? -1 : 0) - (b.kind === 'code' ? -1 : 0))
258
+ : PROVIDERS;
259
+
260
+ const chosen = Array.isArray(params.providers) && params.providers.length > 0
261
+ ? order.filter((p) => params.providers.includes(p.id))
262
+ : order;
263
+
264
+ if (chosen.length === 0) {
265
+ return { ok: false, error: `no known search provider matched ${JSON.stringify(params.providers)} — known providers are ${PROVIDER_IDS.join(', ')}.` };
266
+ }
267
+
268
+ const tried = [];
269
+ for (const provider of chosen) {
270
+ let res;
271
+ try {
272
+ res = await fetchImpl(provider.url(query), timeoutMs);
273
+ } catch (e) {
274
+ const why = e?.name === 'AbortError' ? `no answer within ${timeoutMs}ms` : String(e?.message || e);
275
+ tried.push({ provider: provider.id, ok: false, why });
276
+ continue;
277
+ }
278
+
279
+ const status = Number(res?.status ?? 0);
280
+ const body = String(res?.body ?? '');
281
+ if (status < 200 || status >= 300) {
282
+ tried.push({ provider: provider.id, ok: false, why: `HTTP ${status}` });
283
+ continue;
284
+ }
285
+
286
+ let parsed;
287
+ try {
288
+ parsed = provider.parse(body);
289
+ } catch (e) {
290
+ tried.push({ provider: provider.id, ok: false, why: `the response could not be read: ${String(e?.message || e)}` });
291
+ continue;
292
+ }
293
+
294
+ if (!parsed || parsed.length === 0) {
295
+ tried.push({ provider: provider.id, ok: false, why: describeEmpty(body) });
296
+ continue;
297
+ }
298
+
299
+ // Same page from two providers is one result, not two.
300
+ const seen = new Set();
301
+ const results = [];
302
+ for (const r of parsed) {
303
+ const key = r.url.replace(/[#?].*$/, '').replace(/\/$/, '').toLowerCase();
304
+ if (seen.has(key)) continue;
305
+ seen.add(key);
306
+ results.push({ ...r, source: provider.id });
307
+ if (results.length >= limit) break;
308
+ }
309
+
310
+ return { ok: true, query, provider: provider.id, results, tried };
311
+ }
312
+
313
+ /**
314
+ * ⚠️ EVERY PROVIDER FAILED — and the reply says so IN THOSE WORDS, with each
315
+ * reason. An empty result list here would read to the model as "there is
316
+ * nothing about this on the web", which is a lie that sends it back to
317
+ * guessing with more confidence than before.
318
+ */
319
+ return {
320
+ ok: false,
321
+ error: `no search provider could be reached, so this is NOT evidence that nothing exists — do not treat it as a negative result. ${tried.map((t) => `${t.provider}: ${t.why}`).join(' · ')}`,
322
+ tried,
323
+ };
324
+ }
325
+
326
+ /**
327
+ * Render results for the model: compact, and every URL fetchable as-is.
328
+ *
329
+ * ⚠️ A FALLBACK MUST ANNOUNCE ITSELF. Caught by running this for real: the
330
+ * primary was serving a bot check, the chain quietly fell through to
331
+ * StackOverflow, and the output read exactly like a normal search — one thin
332
+ * result presented as if it were the whole web's answer. A model reading that
333
+ * concludes the topic is obscure. The degraded case has to LOOK degraded.
334
+ */
335
+ export function formatResults(out) {
336
+ if (!out?.ok) return String(out?.error ?? 'search failed');
337
+ const lines = [];
338
+ if (Array.isArray(out.tried) && out.tried.length > 0) {
339
+ lines.push(
340
+ `note: ${out.tried.map((t) => `${t.provider} failed (${t.why})`).join('; ')}`
341
+ + ` — these results come from the fallback, so coverage is narrower than usual.`,
342
+ );
343
+ }
344
+ lines.push(`${out.results.length} result${out.results.length === 1 ? '' : 's'} from ${out.provider}:`);
345
+ out.results.forEach((r, i) => {
346
+ lines.push(`${i + 1}. ${r.title}`);
347
+ lines.push(` ${r.url}`);
348
+ if (r.snippet) lines.push(` ${r.snippet}`);
349
+ });
350
+ lines.push('Read one with fetch_url before relying on it — a snippet is a hint, not a source.');
351
+ return lines.join('\n');
352
+ }
353
+
354
+ export function webSearchToolSchemas() {
355
+ return [
356
+ {
357
+ type: 'function',
358
+ function: {
359
+ name: 'web_search',
360
+ description: [
361
+ 'Search the public web and get back titles, URLs and snippets.',
362
+ 'Use it when you do not know WHICH page has the answer — an API you have not used, a library version,',
363
+ 'an error message you do not recognise. Then call fetch_url on the most promising result to read it.',
364
+ 'A snippet is a hint, not a source: never quote an API signature you have only seen in a snippet.',
365
+ 'Programming questions and error messages are routed to StackOverflow first, the open web otherwise.',
366
+ `At most ${MAX_SEARCHES_PER_PROCESS} searches per run, so make each query specific.`,
367
+ 'If every provider fails it says so explicitly — that is a network failure, NOT evidence that nothing exists.',
368
+ ].join(' '),
369
+ parameters: {
370
+ type: 'object',
371
+ properties: {
372
+ query: {
373
+ type: 'string',
374
+ description: 'Distinctive keywords, not a sentence. e.g. "node fs.readFileSync encoding utf8 buffer" rather than "how do I read a file in node".',
375
+ },
376
+ limit: { type: 'integer', description: `How many results, default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}.` },
377
+ kind: {
378
+ type: 'string',
379
+ enum: ['general', 'code'],
380
+ description: 'Force the source: "code" searches StackOverflow first. Usually leave it out — error-shaped queries are detected.',
381
+ },
382
+ },
383
+ required: ['query'],
384
+ },
385
+ },
386
+ },
387
+ ];
388
+ }
389
+
390
+ /** Kept for the doctor: what this capability needs, and what it costs. */
391
+ export function searchChecks() {
392
+ return {
393
+ id: 'web.search',
394
+ label: 'web search',
395
+ providers: PROVIDER_IDS,
396
+ needsKey: false,
397
+ note: 'keyless. StackOverflow allows 300 requests/day per IP; DuckDuckGo is unmetered but may serve a bot check.',
398
+ };
399
+ }
400
+
401
+ export { htmlToText };