crawldna 0.1.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/LICENSE +689 -0
- package/README.md +525 -0
- package/bin/cli.mjs +607 -0
- package/index.d.ts +376 -0
- package/package.json +64 -0
- package/src/engine/actions.mjs +57 -0
- package/src/engine/consent.mjs +125 -0
- package/src/engine/crawl-page.mjs +511 -0
- package/src/engine/decide.mjs +555 -0
- package/src/engine/perceive.mjs +647 -0
- package/src/engine/reveal.mjs +799 -0
- package/src/eval/metrics.mjs +236 -0
- package/src/eval/report.mjs +149 -0
- package/src/extract.mjs +1208 -0
- package/src/index.mjs +1115 -0
- package/src/lib/browser.mjs +242 -0
- package/src/lib/challenge.mjs +110 -0
- package/src/lib/faithful.mjs +167 -0
- package/src/lib/fetcher.mjs +151 -0
- package/src/lib/incremental.mjs +75 -0
- package/src/lib/layout.mjs +279 -0
- package/src/lib/llm.mjs +534 -0
- package/src/lib/output.mjs +66 -0
- package/src/lib/pool.mjs +30 -0
- package/src/lib/relevance.mjs +139 -0
- package/src/lib/retrieve.mjs +165 -0
- package/src/lib/robots.mjs +125 -0
- package/src/lib/runs.mjs +562 -0
- package/src/lib/semantic.mjs +172 -0
- package/src/lib/settle.mjs +69 -0
- package/src/lib/simhash.mjs +112 -0
- package/src/lib/task.mjs +65 -0
- package/src/lib/url.mjs +155 -0
- package/src/profiles/docs/llms.mjs +77 -0
- package/src/profiles/docs/sitemap.mjs +121 -0
- package/src/profiles/docs.mjs +96 -0
- package/src/reshape.mjs +204 -0
package/src/lib/llm.mjs
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Bogdan Marian Vasaiu
|
|
3
|
+
// The LLM transport layer — the ONE place that talks to a model provider.
|
|
4
|
+
//
|
|
5
|
+
// crawldna's judgment layer (src/engine/decide.mjs) and reshape (src/reshape.mjs)
|
|
6
|
+
// are provider-agnostic: they call `chat(llm, system, user)` and never know which
|
|
7
|
+
// backend answered. Two backends are supported:
|
|
8
|
+
//
|
|
9
|
+
// - 'ollama' — a local Ollama server (the default; uses the `ollama` package,
|
|
10
|
+
// so model loading / keep-alive are handled for you).
|
|
11
|
+
// - 'openai' — ANY OpenAI-compatible Chat Completions API, addressed by a base
|
|
12
|
+
// URL + API key. This is the de-facto standard, so the same code
|
|
13
|
+
// path covers OpenAI, OpenRouter, Groq, Together, Mistral, DeepSeek,
|
|
14
|
+
// and local servers (llama.cpp, LM Studio, vLLM). Ollama itself
|
|
15
|
+
// speaks it at http://localhost:11434/v1, so it works here too.
|
|
16
|
+
//
|
|
17
|
+
// A model config is the small descriptor `{ provider, model, baseUrl, apiKey }`,
|
|
18
|
+
// produced once by `resolveLlm(options)` and threaded through the engine.
|
|
19
|
+
|
|
20
|
+
import ollama, { Ollama } from 'ollama';
|
|
21
|
+
|
|
22
|
+
const PROVIDERS = new Set(['ollama', 'openai']);
|
|
23
|
+
const DEFAULT_OLLAMA_HOST = 'http://127.0.0.1:11434';
|
|
24
|
+
const REQUEST_TIMEOUT_MS = 120000;
|
|
25
|
+
// Reshape turns rework WHOLE documents (long outputs, slow on a local model) — the
|
|
26
|
+
// 120s leash killed a legitimate "redo the original, tidied" turn live. They get a
|
|
27
|
+
// longer, still-bounded budget; the crawl's small judgment calls keep the tight one.
|
|
28
|
+
const RESHAPE_TIMEOUT_MS = 300000;
|
|
29
|
+
const timeoutFor = (kind) => (kind === 'reshape' ? RESHAPE_TIMEOUT_MS : REQUEST_TIMEOUT_MS);
|
|
30
|
+
|
|
31
|
+
// --- LLM call throttle (provider-aware) ------------------------------------
|
|
32
|
+
// A LOCAL model (Ollama) is a single process that answers ~one prompt at a time,
|
|
33
|
+
// so firing the crawl's parallel pages' judgment calls at it ALL AT ONCE only
|
|
34
|
+
// thrashes it: each call gets slower, and Stop drags because in-flight calls must
|
|
35
|
+
// drain. So we cap CONCURRENT calls PER PROVIDER — tight for local, generous for a
|
|
36
|
+
// remote API (OpenAI/DeepSeek/OpenRouter/… scale horizontally, so parallelism there
|
|
37
|
+
// is a clear win). The browser still renders pages in parallel; only the model
|
|
38
|
+
// calls are metered.
|
|
39
|
+
const PROVIDER_CONCURRENCY = { ollama: 2, openai: 16 };
|
|
40
|
+
|
|
41
|
+
function createLimiter(max) {
|
|
42
|
+
let active = 0;
|
|
43
|
+
const waiters = [];
|
|
44
|
+
const pump = () => {
|
|
45
|
+
while (active < max && waiters.length) {
|
|
46
|
+
active++;
|
|
47
|
+
const { fn, resolve, reject } = waiters.shift();
|
|
48
|
+
Promise.resolve()
|
|
49
|
+
.then(fn)
|
|
50
|
+
.then(resolve, reject)
|
|
51
|
+
.finally(() => {
|
|
52
|
+
active--;
|
|
53
|
+
pump();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
run(fn) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
waiters.push({ fn, resolve, reject });
|
|
61
|
+
pump();
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
// Drop everything still QUEUED (not yet started) so a stopped crawl doesn't
|
|
65
|
+
// keep handing work to the model. Callers in decide.mjs `.catch()` and bias to
|
|
66
|
+
// keep/follow, so a cancelled judgment never loses content.
|
|
67
|
+
clear() {
|
|
68
|
+
const pending = waiters.splice(0);
|
|
69
|
+
for (const w of pending) w.reject(new Error('LLM call cancelled (crawl stopped)'));
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const _limiters = new Map();
|
|
75
|
+
function limiterFor(provider) {
|
|
76
|
+
let l = _limiters.get(provider);
|
|
77
|
+
if (!l) {
|
|
78
|
+
l = createLimiter(PROVIDER_CONCURRENCY[provider] || 8);
|
|
79
|
+
_limiters.set(provider, l);
|
|
80
|
+
}
|
|
81
|
+
return l;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Cancel all QUEUED (not-yet-started) LLM calls — called by run.stop() so a stop
|
|
85
|
+
* is near-instant instead of waiting for a backlog of judgment calls to run. */
|
|
86
|
+
export function abortPendingLlm() {
|
|
87
|
+
for (const l of _limiters.values()) l.clear();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Rough token estimate (≈4 chars/token) — only a FALLBACK for when a backend
|
|
91
|
+
* doesn't report real counts; real numbers from the provider are preferred. */
|
|
92
|
+
function estimateTokens(s) {
|
|
93
|
+
return Math.ceil(String(s || '').length / 4);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Reject a promise if it outruns `ms`, so a hung model call can't stall a crawl
|
|
97
|
+
* (or its Stop) indefinitely. The Ollama client has no per-call timeout of its own. */
|
|
98
|
+
function withTimeout(promise, ms, label) {
|
|
99
|
+
let t;
|
|
100
|
+
const timeout = new Promise((_, reject) => {
|
|
101
|
+
t = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
102
|
+
});
|
|
103
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(t));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Strip a trailing slash; treat empty as empty. */
|
|
107
|
+
function trimUrl(raw) {
|
|
108
|
+
return String(raw || '').trim().replace(/\/+$/, '');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build the base URL for an OpenAI-compatible API. Most providers want the `/v1`
|
|
113
|
+
* suffix already in the URL (OpenAI, Ollama), but some carry their own path
|
|
114
|
+
* (OpenRouter's `/api/v1`). So: if the user gave only an origin (no path), append
|
|
115
|
+
* `/v1` for them; otherwise respect whatever path they typed.
|
|
116
|
+
*/
|
|
117
|
+
function openaiBase(raw) {
|
|
118
|
+
let s = trimUrl(raw);
|
|
119
|
+
if (!s) return '';
|
|
120
|
+
try {
|
|
121
|
+
const u = new URL(s);
|
|
122
|
+
if (u.pathname === '' || u.pathname === '/') s = trimUrl(s) + '/v1';
|
|
123
|
+
} catch {
|
|
124
|
+
/* not a full URL — leave it untouched and let the request surface the error */
|
|
125
|
+
}
|
|
126
|
+
return s;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Join a base URL and a path segment with exactly one slash. */
|
|
130
|
+
function joinUrl(base, path) {
|
|
131
|
+
return trimUrl(base) + '/' + String(path).replace(/^\/+/, '');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Is this descriptor the deliberate NO-AI mode? The judgment layer (decide.mjs)
|
|
136
|
+
* checks this to skip its model calls entirely and use the deterministic
|
|
137
|
+
* fallbacks it already has — same behaviour as a model outage, minus the failed
|
|
138
|
+
* calls' latency. One predicate so "AI off" is spelled the same way everywhere.
|
|
139
|
+
*/
|
|
140
|
+
export function llmDisabled(llm) {
|
|
141
|
+
return !llm || llm.provider === 'none';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Normalise raw options into a `{ provider, model, baseUrl, apiKey }` descriptor.
|
|
146
|
+
* Backward compatible: an absent provider means 'ollama', and `ollamaHost` keeps
|
|
147
|
+
* working. For the 'openai' provider, the API key falls back to the environment
|
|
148
|
+
* (CRAWLDNA_API_KEY, then OPENAI_API_KEY) so it never has to be put on the CLI.
|
|
149
|
+
*
|
|
150
|
+
* `noAi: true` wins over everything: it yields the provider-'none' descriptor —
|
|
151
|
+
* the crawl runs on its deterministic fallbacks (heuristic reveal, keep whole,
|
|
152
|
+
* follow all in-scope) and never contacts a model, so no model is required.
|
|
153
|
+
* Note it also drops `embedModel` (#22): no-AI means zero calls to ANY model,
|
|
154
|
+
* embeddings included — rule #6 is absolute.
|
|
155
|
+
*
|
|
156
|
+
* @param {object} [options]
|
|
157
|
+
* @param {boolean} [options.noAi] disable AI entirely (provider 'none')
|
|
158
|
+
* @param {string} [options.provider] 'ollama' (default) | 'openai'
|
|
159
|
+
* @param {string} [options.model]
|
|
160
|
+
* @param {string} [options.embedModel] embedding model id (#22, optional — enables
|
|
161
|
+
* the semantic relevance tier)
|
|
162
|
+
* @param {string} [options.baseUrl] OpenAI-compatible API base URL
|
|
163
|
+
* @param {string} [options.apiKey]
|
|
164
|
+
* @param {string} [options.ollamaHost] Ollama server URL (legacy / ollama provider)
|
|
165
|
+
*/
|
|
166
|
+
export function resolveLlm(options = {}) {
|
|
167
|
+
if (options.noAi) return { provider: 'none', model: '', baseUrl: '', apiKey: '' };
|
|
168
|
+
const provider = PROVIDERS.has(String(options.provider || '').toLowerCase())
|
|
169
|
+
? String(options.provider).toLowerCase()
|
|
170
|
+
: 'ollama';
|
|
171
|
+
const model = options.model || '';
|
|
172
|
+
const embedModel = String(options.embedModel || '').trim();
|
|
173
|
+
|
|
174
|
+
if (provider === 'openai') {
|
|
175
|
+
const apiKey =
|
|
176
|
+
options.apiKey || process.env.CRAWLDNA_API_KEY || process.env.OPENAI_API_KEY || '';
|
|
177
|
+
return { provider, model, embedModel, baseUrl: openaiBase(options.baseUrl), apiKey };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ollama: the baseUrl is the Ollama host (no /v1).
|
|
181
|
+
const baseUrl = trimUrl(options.ollamaHost || options.baseUrl || DEFAULT_OLLAMA_HOST);
|
|
182
|
+
return { provider, model, embedModel, baseUrl, apiKey: '' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- Ollama backend (one cached client per host) ---------------------------
|
|
186
|
+
const _ollamaClients = new Map();
|
|
187
|
+
function ollamaClient(host) {
|
|
188
|
+
const key = trimUrl(host);
|
|
189
|
+
if (!key) return ollama; // package default (127.0.0.1:11434)
|
|
190
|
+
let c = _ollamaClients.get(key);
|
|
191
|
+
if (!c) {
|
|
192
|
+
c = new Ollama({ host: key });
|
|
193
|
+
_ollamaClients.set(key, c);
|
|
194
|
+
}
|
|
195
|
+
return c;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function ollamaChat(llm, system, user, schema, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
199
|
+
const req = {
|
|
200
|
+
model: llm.model,
|
|
201
|
+
messages: [
|
|
202
|
+
{ role: 'system', content: system },
|
|
203
|
+
{ role: 'user', content: user },
|
|
204
|
+
],
|
|
205
|
+
stream: false,
|
|
206
|
+
options: { temperature: 0 },
|
|
207
|
+
};
|
|
208
|
+
// Constrained decoding: force the model to emit EXACTLY this JSON shape (Ollama uses
|
|
209
|
+
// XGrammar). Removes the #1 cause of parse failures (code fences, preambles, partial
|
|
210
|
+
// or wrong-shaped JSON) and is markedly faster — no tokens spent on formatting. Only
|
|
211
|
+
// when a schema is expected; free-form calls (reshape, the health ping) pass none.
|
|
212
|
+
if (schema) req.format = schema;
|
|
213
|
+
const res = await withTimeout(
|
|
214
|
+
ollamaClient(llm.baseUrl).chat(req),
|
|
215
|
+
timeoutMs,
|
|
216
|
+
'Ollama request',
|
|
217
|
+
);
|
|
218
|
+
const content = res?.message?.content || '';
|
|
219
|
+
// Ollama reports real token counts (prompt_eval_count / eval_count); fall back to
|
|
220
|
+
// an estimate only if a build doesn't.
|
|
221
|
+
const usage = {
|
|
222
|
+
inputTokens: res?.prompt_eval_count ?? estimateTokens(system + user),
|
|
223
|
+
outputTokens: res?.eval_count ?? estimateTokens(content),
|
|
224
|
+
cachedInputTokens: 0, // Ollama reuses its KV cache internally but doesn't report it
|
|
225
|
+
};
|
|
226
|
+
return { content, usage };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- OpenAI-compatible backend (raw fetch, zero new deps) -------------------
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Build the chat messages for an OpenAI-compatible request (#4 prompt caching).
|
|
233
|
+
*
|
|
234
|
+
* The judgment system prompts are deliberately BYTE-IDENTICAL across every call of
|
|
235
|
+
* their type (per-call data lives in the user message), so providers with automatic
|
|
236
|
+
* prefix caching (OpenAI, DeepSeek, vLLM, …) reuse them without being asked —
|
|
237
|
+
* repeat input tokens become ~10× cheaper/faster on a crawl's thousands of calls.
|
|
238
|
+
*
|
|
239
|
+
* OpenRouter is the one place an EXPLICIT marker helps: Anthropic models behind it
|
|
240
|
+
* cache only blocks tagged `cache_control`, and OpenRouter documents this content-
|
|
241
|
+
* parts form for all models (it strips the field where unsupported). Every other
|
|
242
|
+
* endpoint gets the plain-string system message, so nothing changes for them.
|
|
243
|
+
* Exported for tests.
|
|
244
|
+
*/
|
|
245
|
+
export function buildOpenAiMessages(llm, system, user) {
|
|
246
|
+
let sys = system;
|
|
247
|
+
try {
|
|
248
|
+
const host = new URL(llm.baseUrl).hostname;
|
|
249
|
+
if (host === 'openrouter.ai' || host.endsWith('.openrouter.ai')) {
|
|
250
|
+
sys = [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }];
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
/* unparsable base URL — keep the plain string; the request will surface the error */
|
|
254
|
+
}
|
|
255
|
+
return [
|
|
256
|
+
{ role: 'system', content: sys },
|
|
257
|
+
{ role: 'user', content: user },
|
|
258
|
+
];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function openaiChat(llm, system, user, schema, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
262
|
+
if (!llm.baseUrl) throw new Error('no API base URL set');
|
|
263
|
+
const headers = {
|
|
264
|
+
'content-type': 'application/json',
|
|
265
|
+
...(llm.apiKey ? { authorization: 'Bearer ' + llm.apiKey } : {}),
|
|
266
|
+
};
|
|
267
|
+
const base = {
|
|
268
|
+
model: llm.model,
|
|
269
|
+
messages: buildOpenAiMessages(llm, system, user),
|
|
270
|
+
temperature: 0,
|
|
271
|
+
stream: false,
|
|
272
|
+
};
|
|
273
|
+
// When the caller expects JSON, ask for guaranteed-valid JSON via `json_object`: it
|
|
274
|
+
// kills the code fences / preambles that break parsing, and is broadly supported
|
|
275
|
+
// (OpenAI, DeepSeek, Groq, vLLM, LM Studio…). The prompts already contain the word
|
|
276
|
+
// "JSON" (OpenAI requires it for this mode). If a provider REJECTS response_format, we
|
|
277
|
+
// retry once WITHOUT it — a crawl must never break on an endpoint lacking the feature.
|
|
278
|
+
const send = (useFormat) =>
|
|
279
|
+
fetch(joinUrl(llm.baseUrl, 'chat/completions'), {
|
|
280
|
+
method: 'POST',
|
|
281
|
+
headers,
|
|
282
|
+
body: JSON.stringify(useFormat ? { ...base, response_format: { type: 'json_object' } } : base),
|
|
283
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// TRANSIENT-FAILURE RETRY. On a paid API a crawl fires thousands of judgment calls;
|
|
287
|
+
// rate limits (429) and server hiccups (5xx, connection resets) are ROUTINE there,
|
|
288
|
+
// and every failed judgment call triggers the completeness-bias fallback — "follow/
|
|
289
|
+
// keep EVERYTHING" — which quietly blows the crawl off-task. So retry up to twice
|
|
290
|
+
// with backoff, honouring Retry-After. A TIMEOUT is not retried: the per-call leash
|
|
291
|
+
// is already generous, and the crawl's keep-bias makes a rare loss safe.
|
|
292
|
+
const FORMAT_REJECT = [400, 404, 415, 422, 501];
|
|
293
|
+
const RETRYABLE = new Set([429, 500, 502, 503, 529]);
|
|
294
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
295
|
+
|
|
296
|
+
let useFormat = !!schema;
|
|
297
|
+
let r = null;
|
|
298
|
+
let lastErr = null;
|
|
299
|
+
for (let attempt = 0; attempt <= 2; attempt++) {
|
|
300
|
+
if (attempt > 0) {
|
|
301
|
+
const ra = r && Number(r.headers.get('retry-after'));
|
|
302
|
+
await sleep(Math.min(15000, ra > 0 ? ra * 1000 : 500 * 2 ** (attempt - 1)));
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
r = await send(useFormat);
|
|
306
|
+
lastErr = null;
|
|
307
|
+
} catch (err) {
|
|
308
|
+
if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) throw err;
|
|
309
|
+
lastErr = err; // network-level hiccup (reset, DNS) — retryable like a 5xx
|
|
310
|
+
r = null;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (r.ok) break;
|
|
314
|
+
if (useFormat && FORMAT_REJECT.includes(r.status)) {
|
|
315
|
+
useFormat = false; // provider doesn't support response_format — degrade, don't fail
|
|
316
|
+
try {
|
|
317
|
+
r = await send(false);
|
|
318
|
+
if (r.ok) break;
|
|
319
|
+
} catch (err) {
|
|
320
|
+
if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) throw err;
|
|
321
|
+
lastErr = err;
|
|
322
|
+
r = null;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (!RETRYABLE.has(r.status)) break; // a real, non-transient error — surface it
|
|
327
|
+
}
|
|
328
|
+
if (lastErr) throw lastErr;
|
|
329
|
+
if (!r.ok) {
|
|
330
|
+
const body = await r.text().catch(() => '');
|
|
331
|
+
throw new Error(`LLM HTTP ${r.status}${body ? ': ' + body.slice(0, 200) : ''}`);
|
|
332
|
+
}
|
|
333
|
+
const d = await r.json().catch(() => null);
|
|
334
|
+
const content = d?.choices?.[0]?.message?.content || '';
|
|
335
|
+
// OpenAI-compatible APIs return token usage; estimate only if absent.
|
|
336
|
+
const u = d?.usage || {};
|
|
337
|
+
const usage = {
|
|
338
|
+
inputTokens: u.prompt_tokens ?? estimateTokens(system + user),
|
|
339
|
+
outputTokens: u.completion_tokens ?? estimateTokens(content),
|
|
340
|
+
// #4: how much of the input was served from the provider's prompt cache —
|
|
341
|
+
// OpenAI-style (prompt_tokens_details.cached_tokens) or DeepSeek-style
|
|
342
|
+
// (prompt_cache_hit_tokens). Metered so a run can SHOW the cached share growing
|
|
343
|
+
// (those tokens are ~10× cheaper); 0 when the provider doesn't report it.
|
|
344
|
+
cachedInputTokens: Number(u.prompt_tokens_details?.cached_tokens ?? u.prompt_cache_hit_tokens ?? 0) || 0,
|
|
345
|
+
};
|
|
346
|
+
return { content, usage };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* One chat turn against the configured provider. Temperature 0, non-streaming.
|
|
351
|
+
* Throws on transport/HTTP errors — callers in the crawl wrap this in
|
|
352
|
+
* `.catch()` and bias toward keep/follow, so a model outage never loses content;
|
|
353
|
+
* reshape surfaces the message to the user.
|
|
354
|
+
*
|
|
355
|
+
* @param {{provider:string, model:string, baseUrl:string, apiKey:string}} llm
|
|
356
|
+
* @param {object} [schema] optional JSON schema → constrained/guaranteed JSON output
|
|
357
|
+
* @param {string} [kind] a label for WHAT this call is (reveal/scope/links/nav-plan/
|
|
358
|
+
* reshape/health) — reported to the usage sink so tokens can be
|
|
359
|
+
* attributed per call type, not just totalled (see src/eval).
|
|
360
|
+
* @returns {Promise<string>}
|
|
361
|
+
*/
|
|
362
|
+
export async function chat(llm, system, user, schema = null, kind = '') {
|
|
363
|
+
// Defensive backstop: decide.mjs short-circuits before ever reaching here in
|
|
364
|
+
// no-AI mode; anything else that leaks a 'none' descriptor (e.g. reshape) gets
|
|
365
|
+
// a clear reason instead of a misleading "no model selected".
|
|
366
|
+
if (llmDisabled(llm)) throw new Error('AI is disabled for this run (no-AI mode) — no model calls are made');
|
|
367
|
+
if (!llm.model) throw new Error('no model selected');
|
|
368
|
+
const provider = llm.provider === 'openai' ? 'openai' : 'ollama';
|
|
369
|
+
// Meter concurrent calls per provider (tight for local, generous for remote).
|
|
370
|
+
return limiterFor(provider).run(async () => {
|
|
371
|
+
const timeoutMs = timeoutFor(kind);
|
|
372
|
+
const { content, usage } = provider === 'openai'
|
|
373
|
+
? await openaiChat(llm, system, user, schema, timeoutMs)
|
|
374
|
+
: await ollamaChat(llm, system, user, schema, timeoutMs);
|
|
375
|
+
// Report token usage to an optional sink on the descriptor (set by the crawl)
|
|
376
|
+
// so cost can be approximated. The `kind` lets the sink break the total down by
|
|
377
|
+
// call type (which judgment actually spends the tokens). Metering must never
|
|
378
|
+
// break the actual call.
|
|
379
|
+
if (typeof llm.__onUsage === 'function') {
|
|
380
|
+
try {
|
|
381
|
+
llm.__onUsage({
|
|
382
|
+
provider,
|
|
383
|
+
kind: kind || 'other',
|
|
384
|
+
inputTokens: usage.inputTokens || 0,
|
|
385
|
+
outputTokens: usage.outputTokens || 0,
|
|
386
|
+
cachedInputTokens: usage.cachedInputTokens || 0,
|
|
387
|
+
});
|
|
388
|
+
} catch {
|
|
389
|
+
/* ignore */
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return content;
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* #22 — embed texts with the configured `embedModel`, through the SAME provider
|
|
398
|
+
* seam as chat: Ollama `/api/embed` or any OpenAI-compatible `/v1/embeddings`.
|
|
399
|
+
* Returns one vector per input, in input order. Usage is metered to the sink
|
|
400
|
+
* under kind 'embed', so a run's report shows exactly what the semantic tier
|
|
401
|
+
* costs next to the chat calls.
|
|
402
|
+
*
|
|
403
|
+
* Contract (rule #6): in no-AI mode this THROWS — embeddings are model calls
|
|
404
|
+
* like any other, and no-AI means zero calls to ANY model. Callers (the
|
|
405
|
+
* semantic scorer) check `llm.embedModel` first and fall back to the lexical
|
|
406
|
+
* floor; this guard is the backstop, not the routine path.
|
|
407
|
+
*
|
|
408
|
+
* @param {{provider:string, embedModel?:string, baseUrl:string, apiKey:string}} llm
|
|
409
|
+
* @param {string[]|string} texts
|
|
410
|
+
* @returns {Promise<number[][]>}
|
|
411
|
+
*/
|
|
412
|
+
export async function embed(llm, texts) {
|
|
413
|
+
if (llmDisabled(llm)) throw new Error('AI is disabled for this run (no-AI mode) — no model calls are made, embeddings included');
|
|
414
|
+
const model = llm && llm.embedModel;
|
|
415
|
+
if (!model) throw new Error('no embedModel configured');
|
|
416
|
+
const list = (Array.isArray(texts) ? texts : [texts]).map((t) => String(t ?? ''));
|
|
417
|
+
if (!list.length) return [];
|
|
418
|
+
const provider = llm.provider === 'openai' ? 'openai' : 'ollama';
|
|
419
|
+
return limiterFor(provider).run(async () => {
|
|
420
|
+
let vectors;
|
|
421
|
+
let usage;
|
|
422
|
+
if (provider === 'openai') {
|
|
423
|
+
if (!llm.baseUrl) throw new Error('no API base URL set');
|
|
424
|
+
const r = await fetch(joinUrl(llm.baseUrl, 'embeddings'), {
|
|
425
|
+
method: 'POST',
|
|
426
|
+
headers: {
|
|
427
|
+
'content-type': 'application/json',
|
|
428
|
+
...(llm.apiKey ? { authorization: 'Bearer ' + llm.apiKey } : {}),
|
|
429
|
+
},
|
|
430
|
+
body: JSON.stringify({ model, input: list }),
|
|
431
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
432
|
+
});
|
|
433
|
+
if (!r.ok) {
|
|
434
|
+
const body = await r.text().catch(() => '');
|
|
435
|
+
throw new Error(`embeddings HTTP ${r.status}${body ? ': ' + body.slice(0, 200) : ''}`);
|
|
436
|
+
}
|
|
437
|
+
const d = await r.json().catch(() => null);
|
|
438
|
+
vectors = (Array.isArray(d?.data) ? d.data : [])
|
|
439
|
+
.slice()
|
|
440
|
+
.sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
|
|
441
|
+
.map((e) => e.embedding);
|
|
442
|
+
usage = { inputTokens: d?.usage?.prompt_tokens ?? estimateTokens(list.join(' ')), outputTokens: 0, cachedInputTokens: 0 };
|
|
443
|
+
} else {
|
|
444
|
+
const res = await withTimeout(
|
|
445
|
+
ollamaClient(llm.baseUrl).embed({ model, input: list }),
|
|
446
|
+
REQUEST_TIMEOUT_MS,
|
|
447
|
+
'Ollama embed',
|
|
448
|
+
);
|
|
449
|
+
vectors = res?.embeddings || [];
|
|
450
|
+
usage = { inputTokens: res?.prompt_eval_count ?? estimateTokens(list.join(' ')), outputTokens: 0, cachedInputTokens: 0 };
|
|
451
|
+
}
|
|
452
|
+
if (!Array.isArray(vectors) || vectors.length !== list.length || vectors.some((v) => !Array.isArray(v) || !v.length)) {
|
|
453
|
+
throw new Error(`embedding backend returned ${Array.isArray(vectors) ? vectors.length : 0} vector(s) for ${list.length} input(s)`);
|
|
454
|
+
}
|
|
455
|
+
if (typeof llm.__onUsage === 'function') {
|
|
456
|
+
try {
|
|
457
|
+
llm.__onUsage({ provider, kind: 'embed', inputTokens: usage.inputTokens || 0, outputTokens: 0, cachedInputTokens: 0 });
|
|
458
|
+
} catch {
|
|
459
|
+
/* metering must never break the call */
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return vectors;
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* One-time health check run before a crawl: confirm the configured model actually
|
|
468
|
+
* answers. The crawl's judgment calls (decide.mjs) all `.catch()` and bias toward
|
|
469
|
+
* keep/follow/reveal, so a misconfigured model (wrong key, unreachable host,
|
|
470
|
+
* un-pulled model) would otherwise degrade to heuristics SILENTLY — bad output
|
|
471
|
+
* with no explanation. This lets the caller warn LOUDLY instead. Best-effort and
|
|
472
|
+
* bounded: any throw means "not usable" with a short reason; it never throws.
|
|
473
|
+
*
|
|
474
|
+
* @param {{provider:string, model:string, baseUrl:string, apiKey:string}} llm
|
|
475
|
+
* @returns {Promise<{ ok: boolean, reason?: string }>}
|
|
476
|
+
*/
|
|
477
|
+
export async function checkModel(llm) {
|
|
478
|
+
if (!llm || !llm.model) return { ok: false, reason: 'no model selected' };
|
|
479
|
+
if (llm.provider === 'openai' && !llm.baseUrl) return { ok: false, reason: 'no API base URL set' };
|
|
480
|
+
try {
|
|
481
|
+
await chat(llm, 'Reply with the single word OK.', 'ping', null, 'health');
|
|
482
|
+
return { ok: true };
|
|
483
|
+
} catch (err) {
|
|
484
|
+
return { ok: false, reason: String((err && err.message) || err).slice(0, 200) };
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* List the models a provider offers, for the setup UI's picker.
|
|
490
|
+
* Shape mirrors the old Ollama probe: `{ ok, models: [{ name, isCloud, size }] }`.
|
|
491
|
+
* - ollama → GET /api/tags (cloud models report size 0 or a -cloud suffix).
|
|
492
|
+
* - openai → GET /models (every listed model is "cloud"); needs a valid key for
|
|
493
|
+
* providers that gate the endpoint, in which case `ok:false` lets the UI fall
|
|
494
|
+
* back to a typed model id.
|
|
495
|
+
*
|
|
496
|
+
* @param {{provider:string, baseUrl:string, apiKey:string}} llm
|
|
497
|
+
* @returns {Promise<{ ok:boolean, models:Array<{name:string,isCloud:boolean,size:number}>, error?:string }>}
|
|
498
|
+
*/
|
|
499
|
+
export async function listModels(llm) {
|
|
500
|
+
if (llm.provider === 'openai') {
|
|
501
|
+
if (!llm.baseUrl) return { ok: false, models: [], error: 'no base URL' };
|
|
502
|
+
try {
|
|
503
|
+
const r = await fetch(joinUrl(llm.baseUrl, 'models'), {
|
|
504
|
+
headers: llm.apiKey ? { authorization: 'Bearer ' + llm.apiKey } : {},
|
|
505
|
+
signal: AbortSignal.timeout(10000),
|
|
506
|
+
});
|
|
507
|
+
if (!r.ok) return { ok: false, models: [], error: 'HTTP ' + r.status };
|
|
508
|
+
const d = await r.json().catch(() => null);
|
|
509
|
+
const arr = Array.isArray(d?.data) ? d.data : Array.isArray(d?.models) ? d.models : [];
|
|
510
|
+
const models = arr
|
|
511
|
+
.map((m) => ({ name: m.id || m.name || '', isCloud: true, size: 0 }))
|
|
512
|
+
.filter((m) => m.name);
|
|
513
|
+
return { ok: true, models };
|
|
514
|
+
} catch (err) {
|
|
515
|
+
return { ok: false, models: [], error: String((err && err.message) || err) };
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ollama
|
|
520
|
+
const base = trimUrl(llm.baseUrl) || 'http://localhost:11434';
|
|
521
|
+
try {
|
|
522
|
+
const r = await fetch(base + '/api/tags', { signal: AbortSignal.timeout(10000) });
|
|
523
|
+
if (!r.ok) return { ok: false, models: [] };
|
|
524
|
+
const d = await r.json();
|
|
525
|
+
const models = (d.models || []).map((m) => ({
|
|
526
|
+
name: m.name,
|
|
527
|
+
isCloud: /[:-]cloud\b/i.test(m.name || '') || m.size === 0,
|
|
528
|
+
size: m.size || 0,
|
|
529
|
+
}));
|
|
530
|
+
return { ok: true, models };
|
|
531
|
+
} catch {
|
|
532
|
+
return { ok: false, models: [] };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Bogdan Marian Vasaiu
|
|
3
|
+
// Disk output (§9): a flat bundle of AI-grouped .md files plus a stable
|
|
4
|
+
// manifest.json. The grouping (one file by default, several when the task asks
|
|
5
|
+
// to split) is decided upstream in lib/layout.mjs; this module only writes a
|
|
6
|
+
// pre-built `files` list and the manifest to a directory.
|
|
7
|
+
|
|
8
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Write a bundle of grouped Markdown files + manifest.json to `dir`. When `documents`
|
|
13
|
+
* is given (the opt-in #10 per-page format), it ALSO writes one .md per page under a
|
|
14
|
+
* `documents/` subfolder plus a root `index.md` and `documents.jsonl` — pure repackaging
|
|
15
|
+
* of the same content, so a programmatic consumer can load pages individually.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} dir
|
|
18
|
+
* @param {object} bundle
|
|
19
|
+
* @param {Array<{ filename: string, markdown: string }>} [bundle.files]
|
|
20
|
+
* @param {object} bundle.manifest
|
|
21
|
+
* @param {{ files?: Array<{filename,markdown}>, index?: {filename,markdown}, jsonl?: {filename,content} }} [bundle.documents]
|
|
22
|
+
* @param {{ files?: Array<{filename,markdown}> }} [bundle.states] faithful per-state record (states/)
|
|
23
|
+
* @returns {Promise<{ dir: string, manifestPath: string, files: number, documents: number }>}
|
|
24
|
+
*/
|
|
25
|
+
export async function writeBundle(dir, { files = [], manifest, documents = null, states = null }) {
|
|
26
|
+
const root = path.resolve(dir);
|
|
27
|
+
await mkdir(root, { recursive: true });
|
|
28
|
+
|
|
29
|
+
for (const f of files) {
|
|
30
|
+
// basename guards against any stray path separators in a filename.
|
|
31
|
+
const abs = path.join(root, path.basename(f.filename));
|
|
32
|
+
await writeFile(abs, f.markdown, 'utf8');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let docCount = 0;
|
|
36
|
+
if (documents) {
|
|
37
|
+
if (documents.files && documents.files.length) {
|
|
38
|
+
const docDir = path.join(root, 'documents');
|
|
39
|
+
await mkdir(docDir, { recursive: true });
|
|
40
|
+
for (const f of documents.files) {
|
|
41
|
+
await writeFile(path.join(docDir, path.basename(f.filename)), f.markdown, 'utf8');
|
|
42
|
+
docCount++;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (documents.index) {
|
|
46
|
+
await writeFile(path.join(root, path.basename(documents.index.filename)), documents.index.markdown, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
if (documents.jsonl) {
|
|
49
|
+
await writeFile(path.join(root, path.basename(documents.jsonl.filename)), documents.jsonl.content, 'utf8');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The faithful per-state record (reveal snapshots) — one .md per multi-state page.
|
|
54
|
+
if (states && states.files && states.files.length) {
|
|
55
|
+
const statesDir = path.join(root, 'states');
|
|
56
|
+
await mkdir(statesDir, { recursive: true });
|
|
57
|
+
for (const f of states.files) {
|
|
58
|
+
await writeFile(path.join(statesDir, path.basename(f.filename)), f.markdown, 'utf8');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const manifestPath = path.join(root, 'manifest.json');
|
|
63
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
64
|
+
|
|
65
|
+
return { dir: root, manifestPath, files: files.length, documents: docCount };
|
|
66
|
+
}
|
package/src/lib/pool.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Bogdan Marian Vasaiu
|
|
3
|
+
// A tiny fixed-size concurrency pool.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Run `fn` over `items` with at most `concurrency` in flight.
|
|
7
|
+
* Results are returned in input order. `shouldStop()` (optional) is polled
|
|
8
|
+
* between items so a graceful stop drains quickly. Errors are captured per
|
|
9
|
+
* item as `{ __error }` rather than rejecting the whole batch.
|
|
10
|
+
*/
|
|
11
|
+
export async function mapPool(items, concurrency, fn, { shouldStop } = {}) {
|
|
12
|
+
const results = new Array(items.length);
|
|
13
|
+
const size = Math.max(1, Math.min(Number(concurrency) || 1, items.length || 1));
|
|
14
|
+
let cursor = 0;
|
|
15
|
+
|
|
16
|
+
async function worker() {
|
|
17
|
+
while (cursor < items.length) {
|
|
18
|
+
if (shouldStop && shouldStop()) return;
|
|
19
|
+
const i = cursor++;
|
|
20
|
+
try {
|
|
21
|
+
results[i] = await fn(items[i], i);
|
|
22
|
+
} catch (err) {
|
|
23
|
+
results[i] = { __error: err };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
await Promise.all(Array.from({ length: size }, worker));
|
|
29
|
+
return results;
|
|
30
|
+
}
|