acuvo-code 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
|
@@ -0,0 +1,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(/&/g, '&')
|
|
79
|
+
.replace(/</g, '<')
|
|
80
|
+
.replace(/>/g, '>')
|
|
81
|
+
.replace(/"/g, '"')
|
|
82
|
+
.replace(/'|'/g, "'")
|
|
83
|
+
.replace(/ /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 };
|