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.
@@ -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
+ }
@@ -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
+ }