waku-memory 0.2.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.
- package/.codex-plugin/plugin.json +15 -0
- package/.mcp.json +9 -0
- package/README.md +55 -0
- package/dist/bootstrap.js +256 -64
- package/dist/capture.js +660 -112
- package/dist/cli.js +351 -64
- package/dist/codex-config.js +321 -0
- package/dist/dialogue-codex.js +63 -0
- package/dist/dialogue.js +2 -2
- package/dist/harnesses.js +57 -22
- package/dist/hook.js +103 -74
- package/dist/login.js +312 -0
- package/hooks/hooks.json +63 -0
- package/package.json +2 -2
- package/skills/waku/SKILL.md +23 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "waku",
|
|
3
|
+
"version": "0.3.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
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).
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
//
|
|
152
|
-
// per span (spec 011 §8) -- the same formatter the live hook posts
|
|
153
|
-
// the size shown is the size that would actually be sent. Offsets
|
|
154
|
-
// clamped defensively: a hand-edited or stale watermark/marker must
|
|
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 =
|
|
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 =
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
276
|
-
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
731
|
+
harness: session.harness,
|
|
544
732
|
session_id: session.sessionId,
|
|
545
733
|
source: 'backfill',
|
|
546
734
|
content,
|
|
547
|
-
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 =
|
|
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 =
|
|
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;
|