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