waku-memory 0.2.0 → 0.4.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,15 @@
1
+ {
2
+ "name": "waku",
3
+ "version": "0.4.0",
4
+ "description": "Waku Memory: durable memory for your Codex sessions. memory.recall brings what is known about a project into a session; memory.remember keeps what matters; the plugin's hooks open each session with a brief and send each turn to be remembered.",
5
+ "skills": "./skills/",
6
+ "hooks": "./hooks/hooks.json",
7
+ "interface": {
8
+ "displayName": "Waku Memory",
9
+ "shortDescription": "Memory that follows you across sessions",
10
+ "developerName": "Waku",
11
+ "category": "Developer Tools",
12
+ "capabilities": ["Read", "Write"],
13
+ "websiteURL": "https://www.waku.one/"
14
+ }
15
+ }
package/.mcp.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "mcpServers": {
3
+ "waku": {
4
+ "type": "http",
5
+ "url": "https://api.waku.one/mcp",
6
+ "default_tools_approval_mode": "approve"
7
+ }
8
+ }
9
+ }
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # waku-memory
2
+
3
+ This package is the shim that connects an agent harness to Waku Memory. It
4
+ points Claude Code's or Codex's MCP config at Waku's server, and it can turn
5
+ on capture: hooks that send each turn of a session to the server, so the
6
+ agent remembers a project across sessions without anyone typing the same
7
+ conventions twice.
8
+
9
+ ## Commands
10
+
11
+ - `npx waku-memory setup [--name <name>] [--url <url>]` adds the Waku MCP
12
+ server to Claude Code's config.
13
+ - `npx waku-memory capture enable [flags]` turns on automatic capture: it
14
+ shows a disclosure, asks you to accept it, then installs hooks for every
15
+ harness this machine has.
16
+ - `npx waku-memory login [--url <ingest-base>]` signs in through the
17
+ browser and mints an API key, without you ever seeing it.
18
+ - `npx waku-memory capture disable` removes exactly the hook entries
19
+ `capture enable` added. Your saved key stays where it is.
20
+ - `npx waku-memory capture status` prints whether capture is installed for
21
+ Claude Code and for Codex, and whether a key is saved. It only reads.
22
+
23
+ `capture enable` takes these flags:
24
+
25
+ - `--login` signs in through the browser without asking first.
26
+ - `--key <key>` uses this key without asking first.
27
+ - `--url <ingest-base>` points capture at a server other than the default.
28
+ - `--since <N>d` limits the history scan to the last N days.
29
+ - `--all` scans full history instead of `--since`.
30
+ - `--no-bootstrap` skips the history scan and its question entirely.
31
+
32
+ ## Codex
33
+
34
+ Codex can run Waku's hooks and MCP server through two routes.
35
+
36
+ **The plugin.** Install Waku from a Codex plugin marketplace. The plugin
37
+ needs no Node on your machine; it carries its own hooks and its own MCP
38
+ server, and Codex signs you in with its own OAuth flow when you install it.
39
+ Codex still runs a new hook only after you trust it: open `/hooks` in Codex
40
+ and trust Waku's entries once.
41
+
42
+ **The CLI.** `npx waku-memory capture enable` on a machine with Codex
43
+ installed writes the `[mcp_servers.waku]` table into
44
+ `~/.codex/config.toml` and three entries (`SessionStart`, `Stop`,
45
+ `SessionEnd`) into `~/.codex/hooks.json`. This route runs the compiled
46
+ shim directly, so it needs Node 20 or newer. Codex asks the same trust
47
+ step: open `/hooks` and trust Waku's three entries once.
48
+
49
+ When both routes are present on the same machine, the plugin wins:
50
+ `capture enable` leaves both Codex files untouched and says so, since the
51
+ plugin already carries the hooks and the MCP server.
52
+
53
+ `login` works the same way on Codex as it does on Claude Code: it signs you
54
+ in and stores the key at `~/.waku-memory/config.json`, without touching
55
+ either harness's config.
package/dist/bootstrap.js CHANGED
@@ -2,18 +2,24 @@
2
2
  // Code's own memory files and transcripts, sizing what each transcript
3
3
  // would send, rendering the one numbered list, parsing the one answer
4
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.
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.
9
13
  //
10
14
  // 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.
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.
17
23
  //
18
24
  // Every function below takes plain values (a directory, a Buffer, a Scan)
19
25
  // so bootstrap.test.mjs can build a throwaway ~/.claude -- or, for the
@@ -22,8 +28,8 @@
22
28
  // network.
23
29
  import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, closeSync, unlinkSync, mkdirSync, writeFileSync, appendFileSync, } from 'node:fs';
24
30
  import { basename, dirname, join } from 'node:path';
25
- import { CONTENT_FORMAT, formatDelta } from "./dialogue.js";
26
31
  import { droppedMarkerPath, readWatermark, writeWatermark } from "./hook.js";
32
+ import { PROFILES } from "./harnesses.js";
27
33
  import { resolveProject } from "./project.js";
28
34
  export const DEFAULT_WINDOW_DAYS = 180;
29
35
  export const LIVE_WINDOW_MS = 10 * 60 * 1000;
@@ -148,25 +154,57 @@ function determineListing(watermarkPath, markerPath, fileSize, all) {
148
154
  function sumPieceBytes(pieces) {
149
155
  return pieces.reduce((sum, p) => sum + Buffer.byteLength(p.text, 'utf8'), 0);
150
156
  }
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
+ // 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) {
157
163
  const clampedFrom = Math.min(Math.max(from, 0), buf.length);
158
- const tail = formatDelta(buf.subarray(clampedFrom));
164
+ const tail = formatDeltaFn(buf.subarray(clampedFrom));
159
165
  let dialogueBytes = sumPieceBytes(tail.pieces);
160
166
  let pieces = tail.pieces.length;
161
167
  for (const span of spans) {
162
168
  const start = Math.min(Math.max(span.start, 0), buf.length);
163
169
  const end = Math.min(Math.max(span.end, start), buf.length);
164
- const result = formatDelta(buf.subarray(start, end));
170
+ const result = formatDeltaFn(buf.subarray(start, end));
165
171
  dialogueBytes += sumPieceBytes(result.pieces);
166
172
  pieces += result.pieces.length;
167
173
  }
168
174
  return { dialogueBytes, pieces };
169
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
+ }
170
208
  // ---- grouping ---------------------------------------------------------------
171
209
  // The map key sessions and memory files are grouped under: the resolved
172
210
  // project name when there is one (so two slugs -- a checkout and its
@@ -231,61 +269,41 @@ export function scanClaudeCode(claudeDir, stateDir, opts) {
231
269
  continue; // disappeared between readdir and stat; nothing to report
232
270
  }
233
271
  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
272
  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) {
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) {
243
277
  // Captured whole already, no holes: no SessionEntry (unchanged),
244
278
  // but this transcript's cwd still counts toward the slug's
245
279
  // resolved name so its memory files keep the project name instead
246
280
  // 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
- }
281
+ if (slugProjectName === null && scanned.cwd !== null)
282
+ slugProjectName = resolveProject(scanned.cwd);
257
283
  continue;
258
284
  }
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;
285
+ const project = scanned.cwd !== null ? resolveProject(scanned.cwd) : null;
268
286
  if (slugProjectName === null && project !== null)
269
287
  slugProjectName = project;
270
- const { dialogueBytes, pieces } = sizeSession(buf, listing.from, listing.spans);
271
288
  const session = {
272
289
  sessionId,
273
290
  path,
274
291
  project,
275
- from: listing.from,
276
- spans: listing.spans,
277
- dialogueBytes,
278
- pieces,
292
+ from: scanned.from,
293
+ spans: scanned.spans,
294
+ dialogueBytes: scanned.dialogueBytes,
295
+ pieces: scanned.pieces,
279
296
  mtimeMs,
297
+ harness: 'claude_code',
280
298
  };
281
299
  const key = groupKey(project, slug);
282
300
  let entry = entries.get(key);
283
301
  if (!entry) {
284
- entry = { name: project, slug, sessions: [], memoryFiles: [], dialogueBytes: 0 };
302
+ entry = { name: project, slug, sessions: [], memoryFiles: [], dialogueBytes: 0, harness: 'claude_code' };
285
303
  entries.set(key, entry);
286
304
  }
287
305
  entry.sessions.push(session);
288
- entry.dialogueBytes += dialogueBytes;
306
+ entry.dialogueBytes += scanned.dialogueBytes;
289
307
  if (!slugGroup.has(slug))
290
308
  slugGroup.set(slug, key);
291
309
  }
@@ -301,14 +319,164 @@ export function scanClaudeCode(claudeDir, stateDir, opts) {
301
319
  const key = slugGroup.get(slug) ?? groupKey(slugProjectName, slug);
302
320
  let entry = entries.get(key);
303
321
  if (!entry) {
304
- entry = { name: slugProjectName, slug, sessions: [], memoryFiles: [], dialogueBytes: 0 };
322
+ entry = { name: slugProjectName, slug, sessions: [], memoryFiles: [], dialogueBytes: 0, harness: 'claude_code' };
305
323
  entries.set(key, entry);
306
324
  }
307
325
  entry.memoryFiles.push(...memoryFiles);
308
326
  }
309
327
  }
310
328
  const projects = [...entries.values()].sort(compareProjectEntries);
311
- return { projects, userClaudeMd: readUserClaudeMd(claudeDir) };
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 };
312
480
  }
313
481
  // ---- rendering the one list (spec 011 §8) ----------------------------------
314
482
  function formatSize(bytes) {
@@ -319,8 +487,13 @@ function formatSize(bytes) {
319
487
  function plural(n, word) {
320
488
  return `${n} ${word}${n === 1 ? '' : 's'}`;
321
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).
322
494
  function displayLabel(entry) {
323
- return entry.name ?? entry.slug;
495
+ const label = entry.name ?? entry.slug;
496
+ return entry.harness === 'codex' ? `${label} (Codex)` : label;
324
497
  }
325
498
  // The label for an entry with no listed sessions: its resolved name alone
326
499
  // when it has one -- every in-window session is already fully captured,
@@ -339,10 +512,21 @@ function renderSessionRow(entry, label) {
339
512
  function renderNoSessionsRow(entry, label) {
340
513
  return `${label} ${plural(entry.memoryFiles.length, 'memory file')}`;
341
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
+ }
342
525
  export function renderBootstrapList(scan, windowDays) {
343
- const { projects, userClaudeMd } = scan;
526
+ const { projects, userClaudeMd, harnesses } = scan;
527
+ const phrase = harnessPhrase(harnesses);
344
528
  if (projects.length === 0 && userClaudeMd === null) {
345
- return 'Nothing from Claude Code to import on this machine.';
529
+ return phrase === '' ? 'Nothing to import on this machine.' : `Nothing from ${phrase} to import on this machine.`;
346
530
  }
347
531
  const numbered = projects.map((entry, i) => ({ entry, n: i + 1 }));
348
532
  const withSessions = numbered.filter((r) => r.entry.sessions.length > 0);
@@ -359,7 +543,7 @@ export function renderBootstrapList(scan, windowDays) {
359
543
  const numWidth = String(projects.length).length + 2;
360
544
  const lines = [];
361
545
  const windowPhrase = windowDays === null ? 'all transcripts' : `last ${windowDays} days`;
362
- lines.push(`Found memory and history from Claude Code on this machine (${windowPhrase}):`);
546
+ lines.push(`Found memory and history from ${phrase} on this machine (${windowPhrase}):`);
363
547
  for (const { entry, n } of numbered) {
364
548
  const prefix = String(n).padStart(numWidth) + '. ';
365
549
  lines.push(entry.sessions.length > 0
@@ -405,7 +589,6 @@ export function parseBootstrapAnswer(answer, count) {
405
589
  const unique = [...new Set(numbers)].sort((a, b) => a - b);
406
590
  return { kind: 'exclude', numbers: unique };
407
591
  }
408
- const HARNESS = 'claude_code'; // hook.ts's own HARNESS is private; duplicated rather than exported for one constant
409
592
  // ImportRequest's bound (spec 011 §8): a project's memory files are sent in
410
593
  // requests of at most this many files each, since `project` is one value
411
594
  // per request and files from two different projects can never share one.
@@ -538,13 +721,18 @@ async function sendMemoryFiles(projects, userClaudeMd, deps) {
538
721
  }
539
722
  return { sent, failed };
540
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.
541
729
  function sessionBody(session, content) {
542
730
  return {
543
- harness: HARNESS,
731
+ harness: session.harness,
544
732
  session_id: session.sessionId,
545
733
  source: 'backfill',
546
734
  content,
547
- content_format: CONTENT_FORMAT,
735
+ content_format: PROFILES[session.harness].contentFormat,
548
736
  ...(session.project !== null ? { project: session.project } : {}),
549
737
  };
550
738
  }
@@ -621,9 +809,13 @@ async function sendSession(session, deps) {
621
809
  // trailing newline yet) -- see the function comment for how it is built
622
810
  // and why it replaces rather than amends whatever the marker held before.
623
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;
624
816
  // ---- the tail: from `from` (or 0 under --all) to the end of the file ----
625
817
  const from = deps.all ? 0 : Math.min(Math.max(session.from, 0), buf.length);
626
- const tail = formatDelta(buf.subarray(from));
818
+ const tail = formatDeltaFn(buf.subarray(from));
627
819
  let previousEnd = from;
628
820
  for (const piece of tail.pieces) {
629
821
  let res;
@@ -678,7 +870,7 @@ async function sendSession(session, deps) {
678
870
  for (const span of session.spans) {
679
871
  const start = Math.min(Math.max(span.start, 0), buf.length);
680
872
  const end = Math.min(Math.max(span.end, start), buf.length);
681
- const spanDelta = formatDelta(buf.subarray(start, end));
873
+ const spanDelta = formatDeltaFn(buf.subarray(start, end));
682
874
  let spanOk = true;
683
875
  for (const piece of spanDelta.pieces) {
684
876
  let res;