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