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,562 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // Run cache (§9): every crawl is saved, whether or not the caller asked for it.
4
+ //
5
+ // A run is one folder under the cache root. Each submitted link is an
6
+ // independent SCAN, stored in its own subfolder:
7
+ // <cacheRoot>/<runId>/
8
+ // manifest.json full, machine-friendly index (scans[] → files + pages)
9
+ // run.json a small summary used to list runs quickly; carries a
10
+ // status: 'running' → 'done' | 'stopped' (resumable)
11
+ // <scanId>/ one subfolder per link, e.g. 01-docusaurus-io/
12
+ // <grouped>.md that link's AI-grouped Markdown files (lib/layout.mjs)
13
+ // manifest.json a self-contained per-scan index
14
+ // pages.jsonl incremental journal (#13) while crawling / when interrupted
15
+ //
16
+ // The run is just the container recording which links were crawled together;
17
+ // the user can open one link on its own or the whole run grouped. The cache root
18
+ // defaults to `<project>/.crawldna/runs`, overridable with the `cacheDir` option or
19
+ // the CRAWLDNA_CACHE_DIR env var. Runs are kept until explicitly deleted.
20
+
21
+ import { mkdir, writeFile, appendFile, readFile, readdir, rm } from 'node:fs/promises';
22
+ import path from 'node:path';
23
+ import { writeBundle } from './output.mjs';
24
+ import { slug, hostOf, normalizeUrl } from './url.mjs';
25
+
26
+ const ID_RE = /^[A-Za-z0-9_-]+$/;
27
+
28
+ /**
29
+ * Resolve the cache root directory (explicit option > env > the CONSUMER's cwd).
30
+ * The default is rooted at `process.cwd()` — the project that is RUNNING crawldna —
31
+ * never the package's own install location. When crawldna is imported as a
32
+ * dependency, the old package-relative default resolved inside `node_modules/`,
33
+ * which is both surprising and wiped on reinstall. The runs cache belongs to the
34
+ * caller's project, so cwd is the correct, portable default.
35
+ */
36
+ export function cacheRoot(opts = {}) {
37
+ if (opts && opts.cacheDir) return path.resolve(opts.cacheDir);
38
+ if (process.env.CRAWLDNA_CACHE_DIR) return path.resolve(process.env.CRAWLDNA_CACHE_DIR);
39
+ return path.join(process.cwd(), '.crawldna', 'runs');
40
+ }
41
+
42
+ function runDir(id, opts = {}) {
43
+ if (!ID_RE.test(String(id))) throw new Error(`invalid run id: ${id}`);
44
+ return path.join(cacheRoot(opts), id);
45
+ }
46
+
47
+ /** A sortable, collision-resistant run id: 20260615-084021-3f9c1a. */
48
+ export function newRunId(d = new Date()) {
49
+ const p = (n) => String(n).padStart(2, '0');
50
+ const ts =
51
+ `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-` +
52
+ `${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
53
+ const rand = Math.random().toString(16).slice(2, 8);
54
+ return `${ts}-${rand}`;
55
+ }
56
+
57
+ /**
58
+ * A path-safe, ordered id for one scan (link) within a run: "01-docusaurus-io".
59
+ * The index prefix keeps it unique even when two links share a host.
60
+ */
61
+ export function scanIdFor(url, i) {
62
+ return `${String((i || 0) + 1).padStart(2, '0')}-${slug(hostOf(url) || '')}`;
63
+ }
64
+
65
+ function sanitizeOptions(o = {}) {
66
+ const clean = {};
67
+ for (const [k, v] of Object.entries(o)) {
68
+ if (typeof v === 'function') continue; // drop onEvent etc.
69
+ if (k === 'llm' || k.startsWith('__')) continue; // runtime objects (resolved provider, resume payload)
70
+ if (k === 'apiKey') continue; // never write a secret to disk; resume re-reads it from flags/env
71
+ if (v instanceof RegExp) clean[k] = v.source;
72
+ else clean[k] = v;
73
+ }
74
+ return clean;
75
+ }
76
+
77
+ const scanPages = (s) => (s && s.stats && s.stats.pages) || (s && s.pages ? s.pages.length : 0) || 0;
78
+ const aggregatePages = (scans) => scans.reduce((n, s) => n + scanPages(s), 0);
79
+
80
+ /** Sum AI token usage across scans — a fallback when no run-level total is given.
81
+ * Merges the per-call-type `byKind` breakdown too, so the saved run keeps WHERE the
82
+ * tokens went (reveal/scope/links/nav-plan) and not just the grand total. */
83
+ function aggregateTokens(scans) {
84
+ const t = { calls: 0, inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, byKind: {} };
85
+ for (const s of scans) {
86
+ const u = s && s.stats && s.stats.tokens;
87
+ if (!u) continue;
88
+ t.calls += u.calls || 0;
89
+ t.inputTokens += u.inputTokens || 0;
90
+ t.outputTokens += u.outputTokens || 0;
91
+ t.cachedInputTokens += u.cachedInputTokens || 0;
92
+ for (const [kind, k] of Object.entries(u.byKind || {})) {
93
+ const dst = t.byKind[kind] || (t.byKind[kind] = { calls: 0, inputTokens: 0, outputTokens: 0, cachedInputTokens: 0 });
94
+ dst.calls += k.calls || 0;
95
+ dst.inputTokens += k.inputTokens || 0;
96
+ dst.outputTokens += k.outputTokens || 0;
97
+ dst.cachedInputTokens += k.cachedInputTokens || 0;
98
+ }
99
+ }
100
+ return t;
101
+ }
102
+
103
+ /** The full, machine-friendly index for one scan (also written into its folder). */
104
+ function buildScanManifest(scan) {
105
+ // url -> first file that contains its content
106
+ const pageToFile = new Map();
107
+ for (const f of scan.files || []) {
108
+ for (const url of f.pages || []) if (!pageToFile.has(url)) pageToFile.set(url, f.filename);
109
+ }
110
+ const m = {
111
+ scanId: scan.scanId,
112
+ url: scan.url,
113
+ task: scan.task,
114
+ title: scan.title,
115
+ stats: scan.stats || { pages: (scan.pages || []).length },
116
+ warnings: scan.warnings || [],
117
+ files: (scan.files || []).map((f) => ({ filename: f.filename, title: f.title, bytes: f.bytes, pages: f.pages })),
118
+ pages: (scan.pages || []).map((p) => ({
119
+ url: p.url,
120
+ task: p.task,
121
+ title: p.title,
122
+ strategy: p.meta && p.meta.strategy,
123
+ framework: (p.meta && p.meta.framework) || undefined,
124
+ file: pageToFile.get(p.url) || null,
125
+ })),
126
+ };
127
+ // #10 per-document format (opt-in): metadata-only index of the per-page files, so a
128
+ // consumer can load pages individually from documents/ + documents.jsonl. Omitted when
129
+ // the format wasn't produced, so default manifests are unchanged.
130
+ if (scan.documents && scan.documents.length) {
131
+ m.documents = scan.documents.map((d) => ({
132
+ id: d.id,
133
+ url: d.url,
134
+ title: d.title,
135
+ fetchedAt: d.fetchedAt,
136
+ bytes: d.bytes,
137
+ file: `documents/${d.file}`,
138
+ headings: d.headings,
139
+ }));
140
+ }
141
+ return m;
142
+ }
143
+
144
+ function buildManifest({ id, createdAt, durationMs, targets, options, scans, warnings, tokens, status }) {
145
+ return {
146
+ runId: id,
147
+ createdAt,
148
+ status: status || 'done',
149
+ durationMs,
150
+ targets: (targets || []).map((t) => ({ url: t.url, task: t.task })),
151
+ options: sanitizeOptions(options),
152
+ stats: { pages: aggregatePages(scans), durationMs, scans: scans.length, tokens: tokens || aggregateTokens(scans) },
153
+ scans: scans.map(buildScanManifest),
154
+ warnings: warnings || [],
155
+ };
156
+ }
157
+
158
+ function buildSummary({ id, createdAt, durationMs, scans, warnings, tokens, status }) {
159
+ return {
160
+ id,
161
+ createdAt,
162
+ status: status || 'done',
163
+ durationMs,
164
+ pages: aggregatePages(scans),
165
+ tokens: tokens || aggregateTokens(scans),
166
+ scans: scans.map((s) => ({
167
+ scanId: s.scanId,
168
+ url: s.url,
169
+ task: s.task,
170
+ title: s.title,
171
+ pages: scanPages(s),
172
+ files: (s.files || []).map((f) => ({ filename: f.filename, bytes: f.bytes })),
173
+ warnings: (s.warnings || []).length,
174
+ })),
175
+ warnings: (warnings || []).length,
176
+ };
177
+ }
178
+
179
+ // --- incremental persistence (#13) ------------------------------------------
180
+ // When saving is on, the crawl journals its state AS IT GOES so a crash (or a
181
+ // voluntary Stop) never loses extracted content:
182
+ // run.json written at START by initRun with status 'running'
183
+ // (+ targets/options, so a crashed run stays resumable),
184
+ // rewritten at the end by saveRun with 'done'/'stopped'
185
+ // <scanId>/pages.jsonl one JSON line per KEPT page: { page, links } — verbatim,
186
+ // append-only; resume replays it to rebuild the dedup
187
+ // state and to re-seed the frontier with the page's links
188
+ // pages.jsonl is deleted on a clean 'done' save (the same pages then live in the
189
+ // consolidated .md); it is KEPT on 'stopped' so the run remains resumable.
190
+
191
+ /**
192
+ * Create (or, on resume, re-open) a run folder up front and mark it 'running'.
193
+ * The original createdAt is preserved when the folder already exists.
194
+ */
195
+ export async function initRun({ id = null, targets = [], options = {} }) {
196
+ const runId = id || newRunId();
197
+ const dir = runDir(runId, options);
198
+ await mkdir(dir, { recursive: true });
199
+ let createdAt = new Date().toISOString();
200
+ try {
201
+ const prev = JSON.parse(await readFile(path.join(dir, 'run.json'), 'utf8'));
202
+ if (prev && prev.createdAt) createdAt = prev.createdAt;
203
+ } catch {
204
+ /* fresh run */
205
+ }
206
+ const summary = {
207
+ id: runId,
208
+ createdAt,
209
+ status: 'running',
210
+ durationMs: 0,
211
+ pages: 0,
212
+ // targets + options make a CRASHED run resumable: with no final manifest yet,
213
+ // resume reads them from here.
214
+ targets: (targets || []).map((t) => ({ url: t.url, task: t.task })),
215
+ options: sanitizeOptions(options),
216
+ scans: [],
217
+ };
218
+ await writeFile(path.join(dir, 'run.json'), JSON.stringify(summary, null, 2) + '\n', 'utf8');
219
+ return { id: runId, dir, createdAt };
220
+ }
221
+
222
+ /** Append one kept page ({ page, links }) to a scan's journal. */
223
+ export async function appendJournal(id, scanId, record, opts = {}) {
224
+ const sid = String(scanId);
225
+ if (!ID_RE.test(sid)) throw new Error(`invalid scan id: ${sid}`);
226
+ const dir = path.join(runDir(id, opts), sid);
227
+ await mkdir(dir, { recursive: true });
228
+ await appendFile(path.join(dir, 'pages.jsonl'), JSON.stringify(record) + '\n', 'utf8');
229
+ }
230
+
231
+ /**
232
+ * Read a scan's journal. A crash can tear the last line mid-write: unparsable
233
+ * lines are skipped — that page simply gets re-crawled on resume (never lost).
234
+ */
235
+ export async function readJournal(id, scanId, opts = {}) {
236
+ const sid = String(scanId);
237
+ if (!ID_RE.test(sid)) throw new Error(`invalid scan id: ${sid}`);
238
+ let raw;
239
+ try {
240
+ raw = await readFile(path.join(runDir(id, opts), sid, 'pages.jsonl'), 'utf8');
241
+ } catch {
242
+ return [];
243
+ }
244
+ const out = [];
245
+ for (const line of raw.split('\n')) {
246
+ const s = line.trim();
247
+ if (!s) continue;
248
+ try {
249
+ out.push(JSON.parse(s));
250
+ } catch {
251
+ /* torn tail line from a crash — skip; the page will be re-crawled */
252
+ }
253
+ }
254
+ return out;
255
+ }
256
+
257
+ /**
258
+ * Load everything resume needs from an interrupted run: its targets, its saved
259
+ * options and each scan's journaled pages. Prefers the final manifest (a stopped
260
+ * run wrote one); falls back to the initial run.json (a crashed run didn't).
261
+ */
262
+ export async function loadRunForResume(id, opts = {}) {
263
+ const dir = runDir(id, opts);
264
+ let summary;
265
+ try {
266
+ summary = JSON.parse(await readFile(path.join(dir, 'run.json'), 'utf8'));
267
+ } catch {
268
+ throw new Error(`run ${id} not found in ${cacheRoot(opts)}`);
269
+ }
270
+ let manifest = null;
271
+ try {
272
+ manifest = JSON.parse(await readFile(path.join(dir, 'manifest.json'), 'utf8'));
273
+ } catch {
274
+ /* crashed before the final save — run.json carries targets/options */
275
+ }
276
+ const status = summary.status || (manifest && manifest.status) || 'done';
277
+ const targets = (manifest && manifest.targets) || summary.targets || [];
278
+ const options = (manifest && manifest.options) || summary.options || {};
279
+ const journals = {};
280
+ for (let i = 0; i < targets.length; i++) {
281
+ const sid = scanIdFor(targets[i].url, i);
282
+ journals[sid] = await readJournal(id, sid, opts);
283
+ }
284
+ return { id, dir, createdAt: summary.createdAt, status, targets, options, journals };
285
+ }
286
+
287
+ /** True when two target lists cover the SAME set of (normalized) URLs. */
288
+ export function targetsMatch(a, b) {
289
+ const norm = (list) => new Set((list || []).map((t) => normalizeUrl(t && t.url) || (t && t.url)).filter(Boolean));
290
+ const sa = norm(a);
291
+ const sb = norm(b);
292
+ if (sa.size === 0 || sa.size !== sb.size) return false;
293
+ for (const x of sa) if (!sb.has(x)) return false;
294
+ return true;
295
+ }
296
+
297
+ /**
298
+ * #6 — find the most recent finished INCREMENTAL run for the same target(s) whose
299
+ * journal is still on disk, to reuse as the freshness baseline. Returns
300
+ * { id, journals } or null (no baseline yet → the caller does a full crawl).
301
+ */
302
+ export async function findBaselineRun(targets, opts = {}) {
303
+ for (const r of await listRuns(opts)) {
304
+ // listRuns is newest-first
305
+ if (r.status !== 'done') continue;
306
+ let loaded;
307
+ try {
308
+ loaded = await loadRunForResume(r.id, opts);
309
+ } catch {
310
+ continue;
311
+ }
312
+ if (!loaded.options || !loaded.options.incremental) continue;
313
+ if (!targetsMatch(loaded.targets, targets)) continue;
314
+ const hasJournal = Object.values(loaded.journals || {}).some((j) => Array.isArray(j) && j.length);
315
+ if (!hasJournal) continue;
316
+ return { id: r.id, journals: loaded.journals };
317
+ }
318
+ return null;
319
+ }
320
+
321
+ /**
322
+ * Persist a finished run to the cache.
323
+ * @param {object} a
324
+ * @param {Array<{url,task}>} a.targets
325
+ * @param {object} a.options
326
+ * @param {Array} a.scans per-link scans: { scanId, url, task, title, pages, files, stats, warnings }
327
+ * @param {string} [a.id] reuse an existing run folder (incremental runs / resume)
328
+ * @param {string} [a.createdAt] preserve the original creation time on resume
329
+ * @param {string} [a.status] 'done' (frontier drained) | 'stopped' (voluntary stop — resumable)
330
+ * @returns {Promise<{ id, dir, manifest, summary }>}
331
+ */
332
+ export async function saveRun({
333
+ targets,
334
+ options,
335
+ scans = [],
336
+ durationMs = 0,
337
+ warnings = [],
338
+ tokens = null,
339
+ id: existingId = null,
340
+ createdAt: existingCreatedAt = null,
341
+ status = 'done',
342
+ }) {
343
+ const id = existingId || newRunId();
344
+ const dir = runDir(id, options);
345
+ const createdAt = existingCreatedAt || new Date().toISOString();
346
+
347
+ await mkdir(dir, { recursive: true });
348
+
349
+ // Each scan gets its own subfolder with its files + a self-contained manifest.
350
+ // When the opt-in per-document format was produced (scan._docBundle), it is written
351
+ // alongside under documents/ + index.md + documents.jsonl (see lib/output.mjs).
352
+ for (const scan of scans) {
353
+ const sid = String(scan.scanId);
354
+ if (!ID_RE.test(sid)) throw new Error(`invalid scan id: ${sid}`);
355
+ await writeBundle(path.join(dir, sid), {
356
+ files: scan.files || [],
357
+ manifest: buildScanManifest(scan),
358
+ documents: scan._docBundle || null,
359
+ states: scan._statesBundle || null,
360
+ });
361
+ }
362
+
363
+ const manifest = buildManifest({ id, createdAt, durationMs, targets, options, scans, warnings, tokens, status });
364
+ const summary = buildSummary({ id, createdAt, durationMs, scans, warnings, tokens, status });
365
+
366
+ await writeFile(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8');
367
+ await writeFile(path.join(dir, 'run.json'), JSON.stringify(summary, null, 2) + '\n', 'utf8');
368
+
369
+ // A clean 'done' save supersedes the incremental journal (the same pages now live
370
+ // in the consolidated files); a 'stopped' run keeps its journal so resume works.
371
+ // #6 — an incremental run ALSO keeps its journal: the next incremental crawl reads
372
+ // it as the baseline to decide which pages are still fresh (per-page content + its
373
+ // stored lastmod live only in the journal, not in the consolidated .md).
374
+ if (status === 'done' && !(options && options.incremental)) {
375
+ for (const scan of scans) {
376
+ await rm(path.join(dir, String(scan.scanId), 'pages.jsonl'), { force: true }).catch(() => {});
377
+ }
378
+ }
379
+
380
+ return { id, dir, manifest, summary };
381
+ }
382
+
383
+ // --- legacy compatibility -------------------------------------------------
384
+ // Older runs (pre-scan) stored files/pages flat at the run root with a single
385
+ // task. Wrap them as one synthetic scan (scanId '' = files live at the root) so
386
+ // they still list and open.
387
+
388
+ function legacyScan({ url, task, pages, files, stats, warnings }) {
389
+ return {
390
+ scanId: '',
391
+ url: url || '',
392
+ task: task || '',
393
+ title: url ? hostOf(url) : '',
394
+ pages: pages || [],
395
+ files: files || [],
396
+ stats: stats || { pages: Array.isArray(pages) ? pages.length : pages || 0 },
397
+ warnings: warnings || [],
398
+ };
399
+ }
400
+
401
+ function normalizeManifest(m) {
402
+ if (!m.status) m = { ...m, status: 'done' }; // pre-#13 runs are complete by construction
403
+ if (Array.isArray(m.scans)) return m;
404
+ const t = (m.targets && m.targets[0]) || {};
405
+ const scan = buildScanManifest(
406
+ legacyScan({
407
+ url: t.url,
408
+ task: t.task || (m.options && m.options.task),
409
+ pages: m.pages,
410
+ files: m.files,
411
+ stats: m.stats,
412
+ warnings: m.warnings,
413
+ }),
414
+ );
415
+ return { ...m, scans: [scan] };
416
+ }
417
+
418
+ function normalizeSummary(s) {
419
+ if (!s.status) s = { ...s, status: 'done' }; // pre-#13 runs are complete by construction
420
+ if (Array.isArray(s.scans)) return s;
421
+ const t = (s.targets && s.targets[0]) || {};
422
+ return {
423
+ ...s,
424
+ scans: [
425
+ {
426
+ scanId: '',
427
+ url: t.url || '',
428
+ task: s.task || t.task || '',
429
+ title: t.url ? hostOf(t.url) : '',
430
+ pages: s.pages || 0,
431
+ files: s.files || [],
432
+ warnings: s.warnings || 0,
433
+ },
434
+ ],
435
+ };
436
+ }
437
+
438
+ /** List cached runs (summaries), newest first. */
439
+ export async function listRuns(opts = {}) {
440
+ const root = cacheRoot(opts);
441
+ let entries;
442
+ try {
443
+ entries = await readdir(root, { withFileTypes: true });
444
+ } catch {
445
+ return [];
446
+ }
447
+ const runs = [];
448
+ for (const e of entries) {
449
+ if (!e.isDirectory()) continue;
450
+ try {
451
+ const raw = await readFile(path.join(root, e.name, 'run.json'), 'utf8');
452
+ runs.push(normalizeSummary(JSON.parse(raw)));
453
+ } catch {
454
+ /* skip incomplete/foreign dirs */
455
+ }
456
+ }
457
+ runs.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
458
+ return runs;
459
+ }
460
+
461
+ /** Load a run's full manifest. */
462
+ export async function getRun(id, opts = {}) {
463
+ const dir = runDir(id, opts);
464
+ const manifest = normalizeManifest(JSON.parse(await readFile(path.join(dir, 'manifest.json'), 'utf8')));
465
+ return { id, dir, manifest };
466
+ }
467
+
468
+ /** Read one output file's raw Markdown from a scan (or the run root for legacy). */
469
+ export async function readRunFile(id, scanId, name, opts = {}) {
470
+ const dir = runDir(id, opts);
471
+ const sid = String(scanId || '');
472
+ if (sid && !ID_RE.test(sid)) throw new Error(`invalid scan id: ${sid}`);
473
+ const base = path.basename(String(name));
474
+ return readFile(sid ? path.join(dir, sid, base) : path.join(dir, base), 'utf8');
475
+ }
476
+
477
+ // --- Phase 2 "reshape" chat: derived files + session, per scan -------------
478
+ // Reshape outputs live UNDER the scan in a `chat/` subfolder, so the crawl's own
479
+ // files stay immutable ("crawl once, reshape many times"). `session.json` records
480
+ // the conversation and a registry of the files produced from it.
481
+
482
+ function scanChatDir(id, scanId, opts = {}) {
483
+ const dir = runDir(id, opts);
484
+ const sid = String(scanId || '');
485
+ if (sid && !ID_RE.test(sid)) throw new Error(`invalid scan id: ${sid}`);
486
+ return sid ? path.join(dir, sid, 'chat') : path.join(dir, 'chat');
487
+ }
488
+
489
+ /** Read a scan's reshape session ({ messages, files }); empty if none yet. */
490
+ export async function getChatSession(id, scanId, opts = {}) {
491
+ try {
492
+ const raw = await readFile(path.join(scanChatDir(id, scanId, opts), 'session.json'), 'utf8');
493
+ const s = JSON.parse(raw);
494
+ return {
495
+ messages: Array.isArray(s.messages) ? s.messages : [],
496
+ files: Array.isArray(s.files) ? s.files : [],
497
+ };
498
+ } catch {
499
+ return { messages: [], files: [] };
500
+ }
501
+ }
502
+
503
+ /** Persist a scan's reshape session. */
504
+ export async function saveChatSession(id, scanId, session, opts = {}) {
505
+ const dir = scanChatDir(id, scanId, opts);
506
+ await mkdir(dir, { recursive: true });
507
+ const body = { messages: session.messages || [], files: session.files || [] };
508
+ await writeFile(path.join(dir, 'session.json'), JSON.stringify(body, null, 2) + '\n', 'utf8');
509
+ return body;
510
+ }
511
+
512
+ /** Write one derived (reshape) file into a scan's chat folder. Returns its name. */
513
+ export async function writeChatFile(id, scanId, name, content, opts = {}) {
514
+ const dir = scanChatDir(id, scanId, opts);
515
+ await mkdir(dir, { recursive: true });
516
+ const base = path.basename(String(name));
517
+ await writeFile(path.join(dir, base), content, 'utf8');
518
+ return base;
519
+ }
520
+
521
+ /** Read one derived (reshape) file from a scan's chat folder. */
522
+ export async function readChatFile(id, scanId, name, opts = {}) {
523
+ const base = path.basename(String(name));
524
+ return readFile(path.join(scanChatDir(id, scanId, opts), base), 'utf8');
525
+ }
526
+
527
+ // --- activity trace: the live exploration timeline, kept for later replay ----
528
+ // The Web UI streams a narration (sites, clicks/navigations, captures) while a
529
+ // crawl runs and renders it as an Activity log + an exploration Tree. Persisting
530
+ // a compact copy lets a finished or reopened run show the same Activity/Tree
531
+ // instead of losing them the moment the crawl ends.
532
+
533
+ /** Persist a run's compact activity trace ([events]) for later replay. */
534
+ export async function saveActivity(id, events, opts = {}) {
535
+ const dir = runDir(id, opts);
536
+ await mkdir(dir, { recursive: true });
537
+ await writeFile(path.join(dir, 'activity.json'), JSON.stringify({ events: events || [] }) + '\n', 'utf8');
538
+ }
539
+
540
+ /** Read a run's saved activity trace ({ events }); empty if none was recorded. */
541
+ export async function readActivity(id, opts = {}) {
542
+ try {
543
+ const raw = await readFile(path.join(runDir(id, opts), 'activity.json'), 'utf8');
544
+ const a = JSON.parse(raw);
545
+ return { events: Array.isArray(a.events) ? a.events : [] };
546
+ } catch {
547
+ return { events: [] };
548
+ }
549
+ }
550
+
551
+ /** Delete one run. */
552
+ export async function deleteRun(id, opts = {}) {
553
+ await rm(runDir(id, opts), { recursive: true, force: true });
554
+ return true;
555
+ }
556
+
557
+ /** Delete every cached run. Returns how many were removed. */
558
+ export async function deleteAllRuns(opts = {}) {
559
+ const runs = await listRuns(opts);
560
+ for (const r of runs) await deleteRun(r.id, opts);
561
+ return runs.length;
562
+ }