waku-memory 0.1.0 → 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.
@@ -0,0 +1,757 @@
1
+ // Spec 011 §8: "bootstrap at enable" -- scanning the machine for Claude
2
+ // Code's own memory files and transcripts, sizing what each transcript
3
+ // would send, rendering the one numbered list, parsing the one answer
4
+ // (task 12), and sending whatever the answer did not exclude to
5
+ // POST /imports and POST /ingest/session (task 13). capture.ts's enable()
6
+ // is the caller: it runs the scan, asks the one question, and calls
7
+ // runBootstrap below with the answer (task 13); task 14 adds the
8
+ // --since/--all/--no-bootstrap flags that change what enable() asks for.
9
+ //
10
+ // Zero dependencies, per the brief: node:fs and node:path, plus the sibling
11
+ // dialogue.ts (the cc-dialogue-v1 formatter and CONTENT_FORMAT, spec 011
12
+ // §1/§3), hook.ts (readWatermark/writeWatermark/droppedMarkerPath -- the
13
+ // same rules the live hook uses to read and advance its own bookkeeping
14
+ // files, not reimplemented here) and project.ts (resolveProject, spec 011
15
+ // §7). hook.ts's own postJson is private, so the sending half below writes
16
+ // its own small `post` rather than importing it.
17
+ //
18
+ // Every function below takes plain values (a directory, a Buffer, a Scan)
19
+ // so bootstrap.test.mjs can build a throwaway ~/.claude -- or, for the
20
+ // sending tests, a hand-built Scan over a throwaway state/transcript
21
+ // directory -- under a temp dir and never touch a real one or a real
22
+ // network.
23
+ import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, closeSync, unlinkSync, mkdirSync, writeFileSync, appendFileSync, } from 'node:fs';
24
+ import { basename, dirname, join } from 'node:path';
25
+ import { CONTENT_FORMAT, formatDelta } from "./dialogue.js";
26
+ import { droppedMarkerPath, readWatermark, writeWatermark } from "./hook.js";
27
+ import { resolveProject } from "./project.js";
28
+ export const DEFAULT_WINDOW_DAYS = 180;
29
+ export const LIVE_WINDOW_MS = 10 * 60 * 1000;
30
+ // Constant on purpose (spec 011 §8): `capture enable` can be re-run, and a
31
+ // fixed session_id lets the ingest inbox's (user_id, session_id,
32
+ // content_hash) key recognise an unchanged memory file before the import
33
+ // classifier ever runs. Task 13 is the caller; this module only exports it.
34
+ export const MEMORY_SESSION_ID = 'bootstrap:claude-code-memory';
35
+ // ---- small filesystem helpers, each with its own do-nothing-on-error rule -
36
+ // Directory names directly under a given directory (projects/*, or a
37
+ // project's slug directory itself when listing transcripts). A missing
38
+ // parent (a fresh machine with no ~/.claude at all, or a slug with no
39
+ // memory/ subdirectory) is the same as "nothing here" -- never a throw.
40
+ function listSubdirs(dir) {
41
+ try {
42
+ return readdirSync(dir, { withFileTypes: true })
43
+ .filter((d) => d.isDirectory())
44
+ .map((d) => d.name)
45
+ .sort();
46
+ }
47
+ catch {
48
+ return [];
49
+ }
50
+ }
51
+ // Top-level files only (never recurses): `projects/<slug>/subagents/` and
52
+ // `projects/<slug>/tool-results/` are directories, so they never match
53
+ // `isFile()` here and are never read, per the brief.
54
+ function listFiles(dir, ext) {
55
+ try {
56
+ return readdirSync(dir, { withFileTypes: true })
57
+ .filter((d) => d.isFile() && d.name.endsWith(ext))
58
+ .map((d) => d.name)
59
+ .sort();
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ }
65
+ // ---- bounded head read for finding cwd without reading the whole file -----
66
+ // Read up to maxBytes from the start of a file, returning the buffer of
67
+ // bytes actually read (may be less than maxBytes if the file is smaller).
68
+ // Used to find a transcript's cwd without buffering the entire file into
69
+ // memory -- the first cwd sits on line 3–6 of every transcript on a
70
+ // reference machine; 256 KiB is hundreds of lines.
71
+ export function readHead(path, maxBytes = 256 * 1024) {
72
+ const fd = openSync(path, 'r');
73
+ const buf = Buffer.alloc(maxBytes);
74
+ let bytesRead;
75
+ try {
76
+ bytesRead = readSync(fd, buf, 0, maxBytes, 0);
77
+ }
78
+ finally {
79
+ closeSync(fd);
80
+ }
81
+ return buf.subarray(0, bytesRead);
82
+ }
83
+ // ---- reading one transcript's header for cwd -------------------------------
84
+ // The first record that carries a string `cwd`, scanning line by line and
85
+ // parsing each with JSON.parse in its own try/catch -- a transcript's first
86
+ // lines are queue-operation/last-prompt/bridge-session records with none.
87
+ // Split on raw 0x0a bytes, the same boundary formatDelta uses, so a line's
88
+ // trailing \r (a CRLF transcript) rides along into JSON.parse, which
89
+ // tolerates it as trailing whitespace.
90
+ function findCwd(buf) {
91
+ let start = 0;
92
+ for (;;) {
93
+ const nl = buf.indexOf(0x0a, start);
94
+ const line = nl === -1 ? buf.subarray(start) : buf.subarray(start, nl);
95
+ if (line.length > 0) {
96
+ try {
97
+ const rec = JSON.parse(line.toString('utf8'));
98
+ if (rec !== null && typeof rec === 'object' && typeof rec.cwd === 'string') {
99
+ return rec.cwd;
100
+ }
101
+ }
102
+ catch {
103
+ // Not JSON (or not an object): not this record's job to carry cwd.
104
+ }
105
+ }
106
+ if (nl === -1)
107
+ return null;
108
+ start = nl + 1;
109
+ }
110
+ }
111
+ // ---- the .dropped marker: one "start-end" span per line --------------------
112
+ // Mirrors the shape hook.ts's postDelta appends
113
+ // (`${previousEnd}-${piece.end}\n`, spec 011 §3). A missing marker is "no
114
+ // spans", not an error -- most sessions never 413.
115
+ function readDroppedSpans(markerPath) {
116
+ let raw;
117
+ try {
118
+ raw = readFileSync(markerPath, 'utf8');
119
+ }
120
+ catch {
121
+ return [];
122
+ }
123
+ const spans = [];
124
+ for (const line of raw.split('\n')) {
125
+ const m = /^(\d+)-(\d+)$/.exec(line.trim());
126
+ if (m)
127
+ spans.push({ start: Number(m[1]), end: Number(m[2]) });
128
+ }
129
+ return spans;
130
+ }
131
+ // The watermark/marker rule, read the same way the live hook writes it:
132
+ // no watermark file -> from 0; a watermark below the file's size -> a tail;
133
+ // a .dropped marker -> spans, regardless of where the watermark sits; a
134
+ // watermark at or past the size with no marker -> not listed. `--all`
135
+ // short-circuits all of it: every session in the window is listed from
136
+ // byte 0, watermarks and markers alike ignored.
137
+ function determineListing(watermarkPath, markerPath, fileSize, all) {
138
+ if (all)
139
+ return { listed: true, from: 0, spans: [] };
140
+ const watermarkExists = existsSync(watermarkPath);
141
+ const watermark = watermarkExists ? readWatermark(watermarkPath) : 0;
142
+ const markerExists = existsSync(markerPath);
143
+ const spans = markerExists ? readDroppedSpans(markerPath) : [];
144
+ const listed = !watermarkExists || watermark < fileSize || markerExists;
145
+ return { listed, from: watermark, spans };
146
+ }
147
+ // ---- sizing one session -----------------------------------------------------
148
+ function sumPieceBytes(pieces) {
149
+ return pieces.reduce((sum, p) => sum + Buffer.byteLength(p.text, 'utf8'), 0);
150
+ }
151
+ // formatDelta(buf.subarray(from)) plus formatDelta(buf.subarray(start, end))
152
+ // per span (spec 011 §8) -- the same formatter the live hook posts with, so
153
+ // the size shown is the size that would actually be sent. Offsets are
154
+ // clamped defensively: a hand-edited or stale watermark/marker must size to
155
+ // *something* rather than throw out of a pure scan.
156
+ function sizeSession(buf, from, spans) {
157
+ const clampedFrom = Math.min(Math.max(from, 0), buf.length);
158
+ const tail = formatDelta(buf.subarray(clampedFrom));
159
+ let dialogueBytes = sumPieceBytes(tail.pieces);
160
+ let pieces = tail.pieces.length;
161
+ for (const span of spans) {
162
+ const start = Math.min(Math.max(span.start, 0), buf.length);
163
+ const end = Math.min(Math.max(span.end, start), buf.length);
164
+ const result = formatDelta(buf.subarray(start, end));
165
+ dialogueBytes += sumPieceBytes(result.pieces);
166
+ pieces += result.pieces.length;
167
+ }
168
+ return { dialogueBytes, pieces };
169
+ }
170
+ // ---- grouping ---------------------------------------------------------------
171
+ // The map key sessions and memory files are grouped under: the resolved
172
+ // project name when there is one (so two slugs -- a checkout and its
173
+ // worktree -- can merge into one entry, spec 011 §7), else the slug itself,
174
+ // so two different no-cwd slugs never accidentally merge into one `null`
175
+ // bucket (spec 011 §8's "a session with no cwd goes under the slug").
176
+ function groupKey(project, slug) {
177
+ return project !== null ? `name:${project}` : `slug:${slug}`;
178
+ }
179
+ function compareProjectEntries(a, b) {
180
+ const aMemoryOnly = a.sessions.length === 0;
181
+ const bMemoryOnly = b.sessions.length === 0;
182
+ if (aMemoryOnly !== bMemoryOnly)
183
+ return aMemoryOnly ? 1 : -1;
184
+ if (aMemoryOnly)
185
+ return a.slug.localeCompare(b.slug); // deterministic among themselves; all read 0 bytes
186
+ if (b.dialogueBytes !== a.dialogueBytes)
187
+ return b.dialogueBytes - a.dialogueBytes;
188
+ return (a.name ?? a.slug).localeCompare(b.name ?? b.slug); // tie-break, e.g. two empty sessions
189
+ }
190
+ // ---- the user-level CLAUDE.md ----------------------------------------------
191
+ function readUserClaudeMd(claudeDir) {
192
+ const path = join(claudeDir, 'CLAUDE.md');
193
+ try {
194
+ const content = readFileSync(path, 'utf8');
195
+ return content.trim() === '' ? null : { path, content };
196
+ }
197
+ catch {
198
+ return null;
199
+ }
200
+ }
201
+ // ---- the scan ---------------------------------------------------------------
202
+ export function scanClaudeCode(claudeDir, stateDir, opts) {
203
+ const projectsDir = join(claudeDir, 'projects');
204
+ const entries = new Map();
205
+ // Which group a slug's own sessions landed in, so that slug's memory
206
+ // files (read after its sessions, below) attach to the same entry
207
+ // instead of accidentally starting a second one.
208
+ const slugGroup = new Map();
209
+ for (const slug of listSubdirs(projectsDir)) {
210
+ const slugDir = join(projectsDir, slug);
211
+ // The slug's resolved project name, from *any* in-window, non-live
212
+ // transcript of this slug -- listed or not (task 12 review finding).
213
+ // Without this, a slug whose only in-window session was already
214
+ // captured whole (watermark at the file's exact size, no marker: not
215
+ // listed, below) never had its cwd read at all, so on a second
216
+ // `capture enable` -- the scenario MEMORY_SESSION_ID's stable id
217
+ // exists for -- its memory files would lose the project name the
218
+ // moment the live hook finished capturing its sessions. Spec 011 §8's
219
+ // memory-only fallback trigger is "no transcript in the window", not
220
+ // "no *listed* transcript", and those differ for exactly this slug.
221
+ // Resolved lazily: once a name is found, a later unlisted transcript
222
+ // of the same slug is not opened just to look for one.
223
+ let slugProjectName = null;
224
+ for (const file of listFiles(slugDir, '.jsonl')) {
225
+ const path = join(slugDir, file);
226
+ let stat;
227
+ try {
228
+ stat = statSync(path);
229
+ }
230
+ catch {
231
+ continue; // disappeared between readdir and stat; nothing to report
232
+ }
233
+ const mtimeMs = stat.mtimeMs;
234
+ if (mtimeMs < opts.sinceMs)
235
+ continue; // outside the window, --all included: --all is about watermarks, not the window
236
+ if (opts.nowMs - mtimeMs < opts.liveWindowMs)
237
+ continue; // its own Stop hook is still capturing it
238
+ const sessionId = file.slice(0, -'.jsonl'.length);
239
+ const watermarkPath = join(stateDir, sessionId);
240
+ const markerPath = droppedMarkerPath(watermarkPath);
241
+ const listing = determineListing(watermarkPath, markerPath, stat.size, opts.all);
242
+ if (!listing.listed) {
243
+ // Captured whole already, no holes: no SessionEntry (unchanged),
244
+ // but this transcript's cwd still counts toward the slug's
245
+ // resolved name so its memory files keep the project name instead
246
+ // of falling back to the slug (see slugProjectName above).
247
+ if (slugProjectName === null) {
248
+ try {
249
+ const cwd = findCwd(readHead(path));
250
+ if (cwd !== null)
251
+ slugProjectName = resolveProject(cwd);
252
+ }
253
+ catch {
254
+ // Unreadable: leave the name unresolved, same as "no cwd".
255
+ }
256
+ }
257
+ continue;
258
+ }
259
+ let buf;
260
+ try {
261
+ buf = readFileSync(path);
262
+ }
263
+ catch {
264
+ continue;
265
+ }
266
+ const cwd = findCwd(buf);
267
+ const project = cwd !== null ? resolveProject(cwd) : null;
268
+ if (slugProjectName === null && project !== null)
269
+ slugProjectName = project;
270
+ const { dialogueBytes, pieces } = sizeSession(buf, listing.from, listing.spans);
271
+ const session = {
272
+ sessionId,
273
+ path,
274
+ project,
275
+ from: listing.from,
276
+ spans: listing.spans,
277
+ dialogueBytes,
278
+ pieces,
279
+ mtimeMs,
280
+ };
281
+ const key = groupKey(project, slug);
282
+ let entry = entries.get(key);
283
+ if (!entry) {
284
+ entry = { name: project, slug, sessions: [], memoryFiles: [], dialogueBytes: 0 };
285
+ entries.set(key, entry);
286
+ }
287
+ entry.sessions.push(session);
288
+ entry.dialogueBytes += dialogueBytes;
289
+ if (!slugGroup.has(slug))
290
+ slugGroup.set(slug, key);
291
+ }
292
+ const memoryFiles = listFiles(join(slugDir, 'memory'), '.md').map((f) => join(slugDir, 'memory', f));
293
+ if (memoryFiles.length > 0) {
294
+ // Attach to the entry this slug's own listed sessions landed in, if
295
+ // any; otherwise to the entry for its resolved project name -- a
296
+ // slug whose in-window sessions are all already captured but still
297
+ // resolves a cwd keeps that name here, with sessions: [] (task 12
298
+ // review); otherwise this slug has no in-window transcript with a
299
+ // cwd at all and becomes its own memory-only entry (name: null,
300
+ // shown by its own slug).
301
+ const key = slugGroup.get(slug) ?? groupKey(slugProjectName, slug);
302
+ let entry = entries.get(key);
303
+ if (!entry) {
304
+ entry = { name: slugProjectName, slug, sessions: [], memoryFiles: [], dialogueBytes: 0 };
305
+ entries.set(key, entry);
306
+ }
307
+ entry.memoryFiles.push(...memoryFiles);
308
+ }
309
+ }
310
+ const projects = [...entries.values()].sort(compareProjectEntries);
311
+ return { projects, userClaudeMd: readUserClaudeMd(claudeDir) };
312
+ }
313
+ // ---- rendering the one list (spec 011 §8) ----------------------------------
314
+ function formatSize(bytes) {
315
+ if (bytes < 1024 * 1024)
316
+ return `${Math.round(bytes / 1024)} KB`;
317
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
318
+ }
319
+ function plural(n, word) {
320
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
321
+ }
322
+ function displayLabel(entry) {
323
+ return entry.name ?? entry.slug;
324
+ }
325
+ // The label for an entry with no listed sessions: its resolved name alone
326
+ // when it has one -- every in-window session is already fully captured,
327
+ // but it still has memory files (task 12 review) -- or its slug marked
328
+ // "(memory only)" when it has no resolved name at all. "0 sessions" is
329
+ // never shown for either shape.
330
+ function noSessionsLabel(entry) {
331
+ return entry.name ?? `${entry.slug} (memory only)`;
332
+ }
333
+ function renderSessionRow(entry, label) {
334
+ let stats = `${plural(entry.sessions.length, 'session')}, ${formatSize(entry.dialogueBytes)} of dialogue`;
335
+ if (entry.memoryFiles.length > 0)
336
+ stats += `, ${plural(entry.memoryFiles.length, 'memory file')}`;
337
+ return `${label} ${stats}`;
338
+ }
339
+ function renderNoSessionsRow(entry, label) {
340
+ return `${label} ${plural(entry.memoryFiles.length, 'memory file')}`;
341
+ }
342
+ export function renderBootstrapList(scan, windowDays) {
343
+ const { projects, userClaudeMd } = scan;
344
+ if (projects.length === 0 && userClaudeMd === null) {
345
+ return 'Nothing from Claude Code to import on this machine.';
346
+ }
347
+ const numbered = projects.map((entry, i) => ({ entry, n: i + 1 }));
348
+ const withSessions = numbered.filter((r) => r.entry.sessions.length > 0);
349
+ const noSessions = numbered.filter((r) => r.entry.sessions.length === 0);
350
+ // Two independent alignment columns, not one shared across the whole
351
+ // list: a long memory-only slug (the D--Code-... shape spec 011 §8 shows)
352
+ // would otherwise push every session row's stats out just to line up
353
+ // with it.
354
+ const sessionLabelWidth = withSessions.reduce((w, r) => Math.max(w, displayLabel(r.entry).length), 0);
355
+ const noSessionsLabelWidth = noSessions.reduce((w, r) => Math.max(w, noSessionsLabel(r.entry).length), 0);
356
+ // Fixed at 2 extra columns beyond the widest index, so "1." through
357
+ // "99." (the common case) right-justify with room to spare; a fourth
358
+ // slug's worth of digits just narrows that margin rather than breaking.
359
+ const numWidth = String(projects.length).length + 2;
360
+ const lines = [];
361
+ const windowPhrase = windowDays === null ? 'all transcripts' : `last ${windowDays} days`;
362
+ lines.push(`Found memory and history from Claude Code on this machine (${windowPhrase}):`);
363
+ for (const { entry, n } of numbered) {
364
+ const prefix = String(n).padStart(numWidth) + '. ';
365
+ lines.push(entry.sessions.length > 0
366
+ ? prefix + renderSessionRow(entry, displayLabel(entry).padEnd(sessionLabelWidth))
367
+ : prefix + renderNoSessionsRow(entry, noSessionsLabel(entry).padEnd(noSessionsLabelWidth)));
368
+ }
369
+ const plusPrefix = '+'.padStart(numWidth) + ' ';
370
+ lines.push(userClaudeMd === null
371
+ ? `${plusPrefix}~/.claude/CLAUDE.md (empty, skipped)`
372
+ : `${plusPrefix}~/.claude/CLAUDE.md (${formatSize(Buffer.byteLength(userClaudeMd.content, 'utf8'))})`);
373
+ // --all resends every listed session from byte 0 regardless of what the
374
+ // live hook already captured -- the only way to recover a span a 0.1.x
375
+ // hook dropped without leaving a marker (spec 011 §8) -- so anything the
376
+ // live hook already sent is extracted a second time. Said once, here,
377
+ // rather than discovered later as duplicate facts.
378
+ if (windowDays === null) {
379
+ lines.push('Sessions captured live will be extracted a second time.');
380
+ }
381
+ lines.push('Everything listed is sent to our servers and to Anthropic for extraction.');
382
+ lines.push('Enter imports all of it; type numbers to leave projects out (e.g. 1 3); n skips.');
383
+ return lines.join('\n');
384
+ }
385
+ // Enter (blank) imports everything; n/N skips everything; a whitespace-
386
+ // separated list of integers, each within 1..count, leaves those projects
387
+ // out; anything else is invalid, and the caller (capture.ts's
388
+ // askBootstrapSelection, task 13) re-asks once before treating a second
389
+ // invalid answer as skip.
390
+ export function parseBootstrapAnswer(answer, count) {
391
+ const trimmed = answer.trim();
392
+ if (trimmed === '')
393
+ return { kind: 'all' };
394
+ if (trimmed === 'n' || trimmed === 'N')
395
+ return { kind: 'skip' };
396
+ const numbers = [];
397
+ for (const token of trimmed.split(/\s+/)) {
398
+ if (!/^\d+$/.test(token))
399
+ return { kind: 'invalid' };
400
+ const n = Number.parseInt(token, 10);
401
+ if (n < 1 || n > count)
402
+ return { kind: 'invalid' };
403
+ numbers.push(n);
404
+ }
405
+ const unique = [...new Set(numbers)].sort((a, b) => a - b);
406
+ return { kind: 'exclude', numbers: unique };
407
+ }
408
+ const HARNESS = 'claude_code'; // hook.ts's own HARNESS is private; duplicated rather than exported for one constant
409
+ // ImportRequest's bound (spec 011 §8): a project's memory files are sent in
410
+ // requests of at most this many files each, since `project` is one value
411
+ // per request and files from two different projects can never share one.
412
+ const MAX_IMPORT_FILES = 50;
413
+ // The ingest cap (spec 011 §8's "Memory files" paragraph): a memory file (or
414
+ // the user's CLAUDE.md) over this is skipped and counted as failed, never
415
+ // sent -- POST /imports would 413 on it anyway, and this saves the request.
416
+ const MAX_IMPORT_FILE_BYTES = 256 * 1024;
417
+ // ImportFile.source's bound (spec 011 §8). Cutting from the *left* of the
418
+ // label -- never the `/<file>` suffix -- is what keeps the file name legible
419
+ // in the imports list even when the project name or slug alone would have
420
+ // overflowed it.
421
+ const MAX_SOURCE_LENGTH = 200;
422
+ // 'claude-code-memory:<name-or-slug>/<file>' (spec 011 §8): `name` is the
423
+ // project a transcript under this slug resolved, or null for a memory-only
424
+ // slug (renderBootstrapList's "(memory only)" row) -- the same value
425
+ // scanClaudeCode attaches to the ProjectEntry, never decoded from the slug
426
+ // itself (§8 explains why: a hyphenated slug is ambiguous). A label so long
427
+ // the source would exceed 200 characters is cut from its own left end until
428
+ // it fits, so "claude-code-memory:.../notes.md" always still names the file.
429
+ export function memoryFileSource(name, slug, file) {
430
+ const label = name ?? slug;
431
+ const prefix = 'claude-code-memory:';
432
+ const suffix = `/${file}`;
433
+ const overflow = prefix.length + label.length + suffix.length - MAX_SOURCE_LENGTH;
434
+ const trimmedLabel = overflow > 0 ? label.slice(overflow) : label;
435
+ return prefix + trimmedLabel + suffix;
436
+ }
437
+ // Mirrors hook.ts's endpointUrl/postJson exactly (the brief: hook.ts's own
438
+ // postJson is private, so this file writes its own small `post` with "the
439
+ // same shape"). Kept local rather than exported from hook.ts so the two
440
+ // senders -- the live hook and this one-shot backfill -- stay independent:
441
+ // neither one's change can silently move the other's request shape.
442
+ function endpointUrl(baseUrl, path) {
443
+ return baseUrl.replace(/\/+$/, '') + path;
444
+ }
445
+ // final review M2: a black-holed connection (a dead proxy -- this machine
446
+ // runs Clash on 127.0.0.1:7897) otherwise hangs `capture enable` forever at
447
+ // the counts line, since Node's fetch has no default timeout of its own.
448
+ // 30 s per request, not per run: sendSession below awaits one piece at a
449
+ // time, so a many-piece backfill is meant to take a while, and this only
450
+ // has to bound each individual request the way hook.ts's postJson callers
451
+ // already bound theirs (postBrief, postDelta's per-piece controller).
452
+ export const BACKFILL_TIMEOUT_MS = 30_000;
453
+ async function post(deps, path, body) {
454
+ const controller = new AbortController();
455
+ const timer = setTimeout(() => controller.abort(), deps.backfillTimeoutMs ?? BACKFILL_TIMEOUT_MS);
456
+ try {
457
+ return await deps.fetchImpl(endpointUrl(deps.url, path), {
458
+ method: 'POST',
459
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${deps.key}` },
460
+ body: JSON.stringify(body),
461
+ signal: controller.signal,
462
+ });
463
+ }
464
+ finally {
465
+ clearTimeout(timer);
466
+ }
467
+ }
468
+ // true on a 2xx response, false on any other status or a thrown fetch --
469
+ // the one shape every /imports request below needs, since a memory file
470
+ // (unlike a session's pieces) never distinguishes 413 from any other
471
+ // failure: an oversized file is filtered out before this is ever called, so
472
+ // a 413 here would mean the door disagrees with MAX_IMPORT_FILE_BYTES, and
473
+ // that is exactly as fatal to this batch as a 500 would be.
474
+ async function postOk(deps, path, body) {
475
+ try {
476
+ const res = await post(deps, path, body);
477
+ return res.ok;
478
+ }
479
+ catch {
480
+ return false;
481
+ }
482
+ }
483
+ // One project's memory files, batched at MAX_IMPORT_FILES, plus the user's
484
+ // CLAUDE.md (its own request, no project) when the scan found one -- spec
485
+ // 011 §8's "Memory files" paragraph, in full. `projects` is already the
486
+ // selection-filtered list (runBootstrap below); this function does not
487
+ // know about `exclude` or `skip` at all. Returns files sent in a 2xx
488
+ // request (`sent`) and files that were never sent -- oversized, or sent in
489
+ // a request that did not answer 2xx (`failed`) -- one count per file either
490
+ // way, matching how runBootstrap tallies sessions.
491
+ async function sendMemoryFiles(projects, userClaudeMd, deps) {
492
+ let sent = 0;
493
+ let failed = 0;
494
+ for (const entry of projects) {
495
+ if (entry.memoryFiles.length === 0)
496
+ continue;
497
+ const items = [];
498
+ for (const file of entry.memoryFiles) {
499
+ let content;
500
+ try {
501
+ content = readFileSync(file, 'utf8');
502
+ }
503
+ catch {
504
+ failed++; // disappeared between the scan and the send -- never sent, same as oversized
505
+ continue;
506
+ }
507
+ if (Buffer.byteLength(content, 'utf8') > MAX_IMPORT_FILE_BYTES) {
508
+ failed++;
509
+ continue;
510
+ }
511
+ items.push({ source: memoryFileSource(entry.name, entry.slug, basename(file)), content });
512
+ }
513
+ for (let i = 0; i < items.length; i += MAX_IMPORT_FILES) {
514
+ const batch = items.slice(i, i + MAX_IMPORT_FILES);
515
+ const body = { session_id: MEMORY_SESSION_ID, files: batch };
516
+ if (entry.name !== null)
517
+ body.project = entry.name;
518
+ if (await postOk(deps, '/imports', body))
519
+ sent += batch.length;
520
+ else
521
+ failed += batch.length;
522
+ }
523
+ }
524
+ if (userClaudeMd !== null) {
525
+ if (Buffer.byteLength(userClaudeMd.content, 'utf8') > MAX_IMPORT_FILE_BYTES) {
526
+ failed++;
527
+ }
528
+ else {
529
+ const body = {
530
+ session_id: MEMORY_SESSION_ID,
531
+ files: [{ source: 'claude-code-user:CLAUDE.md', content: userClaudeMd.content }],
532
+ };
533
+ if (await postOk(deps, '/imports', body))
534
+ sent++;
535
+ else
536
+ failed++;
537
+ }
538
+ }
539
+ return { sent, failed };
540
+ }
541
+ function sessionBody(session, content) {
542
+ return {
543
+ harness: HARNESS,
544
+ session_id: session.sessionId,
545
+ source: 'backfill',
546
+ content,
547
+ content_format: CONTENT_FORMAT,
548
+ ...(session.project !== null ? { project: session.project } : {}),
549
+ };
550
+ }
551
+ // One session, spec 011 §8's "Which sessions"/"Transcripts"/watermark
552
+ // paragraphs: the tail first (from `from`, or from byte 0 under --all,
553
+ // through the end of the file -- watermark writes, the same rule the live
554
+ // hook applies), then every span in order (never a watermark write). Any
555
+ // non-2xx other than 413, or a thrown fetch, stops the whole session right
556
+ // there -- no more pieces, no more spans, the watermark stays at whatever
557
+ // the last piece left it. The marker on disk is left exactly as it stands
558
+ // at that moment: whatever it held when this run started, plus any
559
+ // tail-413 lines this run has already committed to it (see below) --
560
+ // never rewritten, never erased (task 13 re-review, R13). That can
561
+ // over-list a span this run would otherwise have cleared (a later
562
+ // `enable` just re-sends it, at the cost of duplicate facts), but it can
563
+ // never lose a hole -- which matters because this session's true state
564
+ // past the failure point is otherwise unknown.
565
+ //
566
+ // A 413 -- on a tail piece or a span's piece -- never stops the session,
567
+ // unlike any other failure: it means this one piece can never succeed
568
+ // (§3), not that the connection or the server is failing, so the loop
569
+ // counts it and moves on to the next piece. A tail 413 commits its
570
+ // absolute byte range, `${previousEnd}-${pieceEnd}`, to the marker file
571
+ // the moment it happens -- `mkdirSync` + `appendFileSync`, the same
572
+ // durability the live hook's own 413 branch uses (hook.ts's postDelta) --
573
+ // *before* the watermark advances past it, and is also pushed onto
574
+ // `holeLines` for the end-of-run rewrite further down. Committing only to
575
+ // `holeLines` and writing the marker solely at that end-of-run rewrite was
576
+ // the bug the re-review found (R13): a later fatal failure or thrown fetch
577
+ // elsewhere in the same run sets `stopped`, which skips the rewrite
578
+ // entirely and used to discard the hole silently even though the
579
+ // watermark had already advanced past it. A span 413, by contrast, only
580
+ // ever touches `holeLines` -- keeping that whole span's *original*
581
+ // `start-end` (not a piece-level range: a span can itself be several
582
+ // pieces, and the ones that did succeed are still a hole until the whole
583
+ // span is clean) -- because the marker already holds that span's line
584
+ // from a previous run, so leaving it untouched until the rewrite is
585
+ // correct whether this run stops or completes; the span's own remaining
586
+ // pieces are still attempted regardless, since a partially-sent span is no
587
+ // better tracked than a wholly-unsent one.
588
+ //
589
+ // `holeLines` collects this run's tail-413 lines (already durable on disk,
590
+ // per above) and every failed span's original line, in order, but is only
591
+ // turned into a marker *rewrite* once, at the very end, and only when
592
+ // nothing stopped the session early: empty means unlink the marker (every
593
+ // tail piece and every span this run attempted came back 2xx, so a stale
594
+ // marker on disk is now wrong); non-empty means overwrite it with exactly
595
+ // those lines -- replacing both the pre-run span lines and this run's own
596
+ // provisional tail-413 appends with one final, correct account -- so a
597
+ // later `enable` resends precisely what is still missing and nothing this
598
+ // run already covered. That full-replace rule is what makes a --all run
599
+ // correct despite `session.spans` always being `[]` there (the scan clears
600
+ // it under --all, spec 011 §8's --all paragraph): a clean full resend from
601
+ // byte 0 clears a stale marker the same as any other session when it
602
+ // completes, and a 413 during that resend leaves the marker holding only
603
+ // *this run's* hole, not the old one the from-0 send already covered --
604
+ // while a --all run that gets stopped instead leaves the pre-existing
605
+ // marker plus this run's own appended tail lines in place, same as any
606
+ // other stopped session.
607
+ async function sendSession(session, deps) {
608
+ const watermarkPath = join(deps.stateDir, session.sessionId);
609
+ const markerPath = droppedMarkerPath(watermarkPath);
610
+ let buf;
611
+ try {
612
+ buf = readFileSync(session.path);
613
+ }
614
+ catch {
615
+ return { ok: false, pieces: 0 }; // the transcript disappeared between the scan and the send
616
+ }
617
+ let piecesSent = 0;
618
+ let ok = true;
619
+ let stopped = false;
620
+ // Every hole this run knows about, as marker-line text (`start-end`, no
621
+ // trailing newline yet) -- see the function comment for how it is built
622
+ // and why it replaces rather than amends whatever the marker held before.
623
+ const holeLines = [];
624
+ // ---- the tail: from `from` (or 0 under --all) to the end of the file ----
625
+ const from = deps.all ? 0 : Math.min(Math.max(session.from, 0), buf.length);
626
+ const tail = formatDelta(buf.subarray(from));
627
+ let previousEnd = from;
628
+ for (const piece of tail.pieces) {
629
+ let res;
630
+ try {
631
+ res = await post(deps, '/ingest/session', sessionBody(session, piece.text));
632
+ }
633
+ catch {
634
+ ok = false;
635
+ stopped = true;
636
+ break;
637
+ }
638
+ if (res.status === 413) {
639
+ // spec 011 §3/§8, task 13 re-review (R13): commit the hole to the
640
+ // marker file the moment it happens -- mkdirSync + appendFileSync,
641
+ // the same durability the live hook's own 413 branch uses (hook.ts's
642
+ // postDelta) -- *before* advancing the watermark past it. Keeping
643
+ // this only in `holeLines` until the end-of-run rewrite further down
644
+ // was the bug the re-review found: a later fatal failure or thrown
645
+ // fetch elsewhere in this same run sets `stopped`, which skips that
646
+ // rewrite entirely (see below) and would silently discard this hole
647
+ // even though the watermark has already moved past it. `holeLines`
648
+ // still gets the line too, so a run that finishes clean folds it into
649
+ // that single end-of-run rewrite rather than leaving this immediate
650
+ // append as the marker's last word.
651
+ const line = `${previousEnd}-${from + piece.end}`;
652
+ mkdirSync(dirname(markerPath), { recursive: true });
653
+ appendFileSync(markerPath, `${line}\n`);
654
+ holeLines.push(line);
655
+ ok = false;
656
+ writeWatermark(watermarkPath, from + piece.end);
657
+ previousEnd = from + piece.end;
658
+ continue;
659
+ }
660
+ if (!res.ok) {
661
+ ok = false;
662
+ stopped = true;
663
+ break;
664
+ }
665
+ writeWatermark(watermarkPath, from + piece.end);
666
+ previousEnd = from + piece.end;
667
+ piecesSent++;
668
+ }
669
+ if (!stopped) {
670
+ // formatDelta's own contract (spec 011 §2/§3, mirrored from hook.ts's
671
+ // postDelta): consume to the formatter's overall end even when that is
672
+ // past the last piece actually sent, so a trailing run of
673
+ // bookkeeping-only lines is never re-read on the next enable.
674
+ const absoluteEnd = from + tail.end;
675
+ if (tail.pieces.length === 0 || absoluteEnd > previousEnd)
676
+ writeWatermark(watermarkPath, absoluteEnd);
677
+ // ---- the spans: never a watermark write -----------------------------
678
+ for (const span of session.spans) {
679
+ const start = Math.min(Math.max(span.start, 0), buf.length);
680
+ const end = Math.min(Math.max(span.end, start), buf.length);
681
+ const spanDelta = formatDelta(buf.subarray(start, end));
682
+ let spanOk = true;
683
+ for (const piece of spanDelta.pieces) {
684
+ let res;
685
+ try {
686
+ res = await post(deps, '/ingest/session', sessionBody(session, piece.text));
687
+ }
688
+ catch {
689
+ ok = false;
690
+ stopped = true;
691
+ break;
692
+ }
693
+ if (res.status === 413) {
694
+ ok = false;
695
+ spanOk = false;
696
+ continue;
697
+ }
698
+ if (!res.ok) {
699
+ ok = false;
700
+ stopped = true;
701
+ break;
702
+ }
703
+ piecesSent++;
704
+ }
705
+ if (stopped)
706
+ break;
707
+ // The span's original range, not a piece-level one -- see the
708
+ // function comment: a partially-sent span is still, as a whole, a
709
+ // hole to record.
710
+ if (!spanOk)
711
+ holeLines.push(`${span.start}-${span.end}`);
712
+ }
713
+ if (!stopped) {
714
+ if (holeLines.length === 0) {
715
+ try {
716
+ unlinkSync(markerPath);
717
+ }
718
+ catch {
719
+ // No marker to remove -- the common case (most sessions never 413).
720
+ }
721
+ }
722
+ else {
723
+ mkdirSync(dirname(watermarkPath), { recursive: true }); // mirrors hook.ts's own 413 branch
724
+ writeFileSync(markerPath, holeLines.map((line) => `${line}\n`).join(''));
725
+ }
726
+ }
727
+ }
728
+ return { ok, pieces: piecesSent };
729
+ }
730
+ // The whole selection, spec 011 §8's "Order" paragraph: memory files first
731
+ // (projects in scan.projects order), then transcripts newest first across
732
+ // every selected project, all enqueued and none awaited for extraction.
733
+ // `skip` sends nothing and returns zeros before touching the network at
734
+ // all; `exclude` drops the numbered projects (1-based, scan.projects order)
735
+ // from both the memory-file pass and the session pass; `all` sends
736
+ // everything the scan found. Nothing here decides *what* enable() prints --
737
+ // see capture.ts, which turns this result into the one counts line.
738
+ export async function runBootstrap(scan, selection, deps) {
739
+ if (selection.kind === 'skip')
740
+ return { memoryFiles: 0, sessions: 0, pieces: 0, failed: 0 };
741
+ const excluded = selection.kind === 'exclude' ? new Set(selection.numbers) : null;
742
+ const projects = excluded === null ? scan.projects : scan.projects.filter((_, i) => !excluded.has(i + 1));
743
+ const { sent: memoryFiles, failed: memoryFailed } = await sendMemoryFiles(projects, scan.userClaudeMd, deps);
744
+ const sessions = projects.flatMap((p) => p.sessions).sort((a, b) => b.mtimeMs - a.mtimeMs);
745
+ let sessionsOk = 0;
746
+ let pieces = 0;
747
+ let failed = memoryFailed;
748
+ for (const session of sessions) {
749
+ const result = await sendSession(session, deps);
750
+ pieces += result.pieces;
751
+ if (result.ok)
752
+ sessionsOk++;
753
+ else
754
+ failed++;
755
+ }
756
+ return { memoryFiles, sessions: sessionsOk, pieces, failed };
757
+ }