dsh-plugin-sessiongraph-sessions 0.0.0-stage → 0.1.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/lib/parse.js ADDED
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Read one harness session log into the normalized shape the indexer writes.
3
+ *
4
+ * The log is a zstd-compressed JSONL file whose first line is a flat session header and whose later
5
+ * lines are events. It is NOT one zstd stream: the harness appends one complete zstd frame per write,
6
+ * so a real log is thousands of concatenated frames. `zstdDecompressSync` stops at the first frame's
7
+ * end, which is why {@link decompressSessionLog} walks the frames itself.
8
+ *
9
+ * What is indexed is the readable conversation, not the transcript feed: a human's messages, the
10
+ * model's visible text (never its reasoning), tool invocations (name and arguments, never results),
11
+ * and the session's final title. Injected context — runtime/skill/time blocks, team and agent
12
+ * messages, plugin notifications — arrives as `user/message` events too, so the `source.kind` check
13
+ * is what keeps them out of the index; on a real machine those injections outnumber real human
14
+ * messages by more than an order of magnitude.
15
+ *
16
+ * Parsing is tolerant per line: a truncated or corrupt line is skipped rather than failing the whole
17
+ * session, because an interrupted append must cost one event, not the conversation.
18
+ * @module dsh-plugin-sessiongraph-sessions/parse
19
+ */
20
+ import { zstdDecompressSync } from 'node:zlib';
21
+ /** zstd frame magic in numeric form, as read by `Buffer.readUInt32LE`. */
22
+ const ZSTD_FRAME_MAGIC = 0xfd2fb528;
23
+ /** The same magic as bytes, for the fallback scanner. */
24
+ const ZSTD_MAGIC_BYTES = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
25
+ /** Mask and value identifying a zstd skippable frame (0x184D2A50 … 0x184D2A5F). */
26
+ const ZSTD_SKIPPABLE_MASK = 0xfffffff0;
27
+ const ZSTD_SKIPPABLE_MAGIC = 0x184d2a50;
28
+ /** Frame-content-size field widths in bytes, indexed by the 2-bit flag minus one. */
29
+ const CONTENT_SIZE_BYTES = [2, 4, 8];
30
+ /** Dictionary-id field widths in bytes, indexed by the 2-bit flag. */
31
+ const DICTIONARY_ID_BYTES = [0, 1, 2, 4];
32
+ /** Byte widths of a zstd block header and of the checksum trailer. */
33
+ const BLOCK_HEADER_BYTES = 3;
34
+ const CHECKSUM_BYTES = 4;
35
+ /**
36
+ * Decompress a session log, one zstd frame at a time.
37
+ *
38
+ * Node's `zstdDecompressSync` decodes the first frame of its input and stops, so a log written by
39
+ * repeated appends must be split into frames first. The frame structure is parsed exactly (RFC 8878
40
+ * header and block headers), which is why a magic-byte sequence inside a compressed block cannot
41
+ * split a frame incorrectly; the magic scan remains as a fallback for a frame layout this parser
42
+ * cannot describe.
43
+ * @param buffer - the raw log file contents.
44
+ * @returns the concatenated decompressed bytes of every frame.
45
+ */
46
+ export function decompressSessionLog(buffer) {
47
+ if (buffer.length >= 4 && buffer.readUInt32LE(0) === ZSTD_FRAME_MAGIC) {
48
+ try {
49
+ return decompressByWalkingFrames(buffer);
50
+ }
51
+ catch {
52
+ // Fall through: the magic scan understands a few layouts the walker refuses, and a readable
53
+ // index is worth more than a stricter parser.
54
+ }
55
+ }
56
+ return decompressByMagic(buffer);
57
+ }
58
+ /**
59
+ * Walk every zstd frame in `buffer` and decompress each one.
60
+ * @param buffer - the raw log file contents, starting at a frame boundary.
61
+ * @returns the concatenated decompressed bytes.
62
+ */
63
+ function decompressByWalkingFrames(buffer) {
64
+ const parts = [];
65
+ let at = 0;
66
+ while (at < buffer.length) {
67
+ const magic = buffer.readUInt32LE(at);
68
+ const end = frameEnd(buffer, at);
69
+ if (end <= at)
70
+ throw new Error(`zstd frame at byte ${at} does not advance`);
71
+ if ((magic & ZSTD_SKIPPABLE_MASK) !== ZSTD_SKIPPABLE_MAGIC)
72
+ parts.push(zstdDecompressSync(buffer.subarray(at, end)));
73
+ at = end;
74
+ }
75
+ return Buffer.concat(parts);
76
+ }
77
+ /**
78
+ * Decompress by scanning for frame magics and decoding each slice.
79
+ * @param buffer - the raw log file contents.
80
+ * @returns the concatenated decompressed bytes.
81
+ */
82
+ function decompressByMagic(buffer) {
83
+ const offsets = [];
84
+ let at = buffer.indexOf(ZSTD_MAGIC_BYTES);
85
+ while (at !== -1) {
86
+ offsets.push(at);
87
+ at = buffer.indexOf(ZSTD_MAGIC_BYTES, at + ZSTD_MAGIC_BYTES.length);
88
+ }
89
+ if (offsets.length === 0)
90
+ throw new Error('session log is not a zstd stream');
91
+ const parts = [];
92
+ for (let index = 0; index < offsets.length; index += 1) {
93
+ const start = offsets[index];
94
+ const end = index + 1 < offsets.length ? offsets[index + 1] : buffer.length;
95
+ parts.push(zstdDecompressSync(buffer.subarray(start, end)));
96
+ }
97
+ return Buffer.concat(parts);
98
+ }
99
+ /**
100
+ * Compute where the zstd frame beginning at `start` ends, from its header and block headers alone.
101
+ * @param buffer - the raw log file contents.
102
+ * @param start - byte offset of the frame magic.
103
+ * @returns the exclusive end offset of the frame.
104
+ */
105
+ function frameEnd(buffer, start) {
106
+ const magic = buffer.readUInt32LE(start);
107
+ if ((magic & ZSTD_SKIPPABLE_MASK) === ZSTD_SKIPPABLE_MAGIC) {
108
+ return start + 8 + buffer.readUInt32LE(start + 4);
109
+ }
110
+ if (magic !== ZSTD_FRAME_MAGIC)
111
+ throw new Error(`unexpected zstd magic at byte ${start}`);
112
+ let at = start + 4;
113
+ const descriptor = readUInt8(buffer, at);
114
+ at += 1;
115
+ const contentSizeFlag = descriptor >>> 6;
116
+ const singleSegment = ((descriptor >>> 5) & 1) === 1;
117
+ const checksum = ((descriptor >>> 2) & 1) === 1;
118
+ const dictionaryFlag = descriptor & 0x03;
119
+ if (!singleSegment)
120
+ at += 1;
121
+ at += DICTIONARY_ID_BYTES[dictionaryFlag];
122
+ at += contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : CONTENT_SIZE_BYTES[contentSizeFlag - 1];
123
+ for (;;) {
124
+ const header = readUIntLE(buffer, at, BLOCK_HEADER_BYTES);
125
+ at += BLOCK_HEADER_BYTES;
126
+ const lastBlock = (header & 0x01) === 1;
127
+ const blockType = (header >>> 1) & 0x03;
128
+ const blockSize = header >>> 3;
129
+ if (blockType === 0 || blockType === 2)
130
+ at += blockSize;
131
+ else if (blockType === 1)
132
+ at += 1;
133
+ else
134
+ throw new Error('reserved zstd block type');
135
+ if (lastBlock)
136
+ break;
137
+ }
138
+ if (checksum)
139
+ at += CHECKSUM_BYTES;
140
+ if (at > buffer.length)
141
+ throw new Error('truncated zstd frame');
142
+ return at;
143
+ }
144
+ /**
145
+ * Parse one session log.
146
+ * @param buffer - the raw log file contents.
147
+ * @param fallbackId - id to use when the header is missing or has none (the session directory name).
148
+ * @param limits - bounds applied to stored entry text.
149
+ * @returns the normalized session.
150
+ */
151
+ export function parseSessionLog(buffer, fallbackId, limits) {
152
+ return parseSessionLines(decompressSessionLog(buffer).toString('utf8').split('\n'), fallbackId, limits);
153
+ }
154
+ /**
155
+ * Parse an already-split JSONL log, keeping the harness's first-line header convention.
156
+ * @param lines - the decompressed log split on newline characters.
157
+ * @param fallbackId - id to use when the header is missing or has none.
158
+ * @param limits - bounds applied to stored entry text.
159
+ * @returns the normalized session.
160
+ */
161
+ export function parseSessionLines(lines, fallbackId, limits) {
162
+ const firstLine = firstNonEmptyLine(lines);
163
+ let header;
164
+ let start = 0;
165
+ if (firstLine !== undefined) {
166
+ const record = parseRecord(lines[firstLine]);
167
+ if (record?.type === 'session') {
168
+ header = record;
169
+ start = firstLine + 1;
170
+ }
171
+ else {
172
+ start = firstLine;
173
+ }
174
+ }
175
+ const records = [];
176
+ for (let index = start; index < lines.length; index += 1) {
177
+ const line = lines[index].trim();
178
+ records.push(line === '' ? undefined : parseRecord(line));
179
+ }
180
+ return normalizeSession(header, records, fallbackId, limits);
181
+ }
182
+ /**
183
+ * Parse a session the host already holds in memory.
184
+ *
185
+ * The live view carries the header and the raw events as two values, which is the same information a
186
+ * log's first line and remaining lines carry. Both paths normalize through {@link normalizeSession},
187
+ * so a live session and its later on-disk copy cannot disagree about what is searchable.
188
+ * @param header - the session header object, as the host reports it.
189
+ * @param events - raw event objects, shaped like the JSONL lines that follow the header.
190
+ * @param fallbackId - id to use when the header has none.
191
+ * @param limits - bounds applied to stored entry text.
192
+ * @returns the normalized session.
193
+ */
194
+ export function parseSessionEvents(header, events, fallbackId, limits) {
195
+ return normalizeSession(asRecord(header), events.map(event => asRecord(event)), fallbackId, limits);
196
+ }
197
+ /**
198
+ * Normalize a session header and its event stream into the shape the index writes.
199
+ * @param header - the parsed session header, or undefined when the log carries none.
200
+ * @param records - parsed events, where `undefined` stands for a line that failed to parse and is
201
+ * skipped without failing the session.
202
+ * @param fallbackId - id to use when the header has none.
203
+ * @param limits - bounds applied to stored entry text.
204
+ * @returns the normalized session.
205
+ */
206
+ function normalizeSession(header, records, fallbackId, limits) {
207
+ const id = stringField(header, 'id') ?? fallbackId;
208
+ let createdAt = numberField(header, 'createdAt') ?? 0;
209
+ const parentSession = stringField(header, 'parentSession');
210
+ const origin = stringField(header, 'origin');
211
+ const agentPreset = stringField(header, 'agentPreset');
212
+ const headerCwd = stringField(header, 'cwd') ?? '';
213
+ const entries = [];
214
+ let title;
215
+ let titleSeq = 0;
216
+ let titleTime = 0;
217
+ let firstEventTime;
218
+ let lastAt = createdAt;
219
+ let turns = 0;
220
+ for (const record of records) {
221
+ if (record === undefined)
222
+ continue;
223
+ const seq = numberField(record, 'seq') ?? 0;
224
+ const time = numberField(record, 'time');
225
+ if (time !== undefined) {
226
+ firstEventTime ??= time;
227
+ if (time > lastAt)
228
+ lastAt = time;
229
+ }
230
+ const eventTime = time ?? lastAt;
231
+ const data = asRecord(record.data);
232
+ switch (record.type) {
233
+ case 'turn/start':
234
+ turns += 1;
235
+ break;
236
+ case 'session/title': {
237
+ const next = stringField(data, 'title');
238
+ if (next !== undefined && next.trim() !== '') {
239
+ title = next;
240
+ titleSeq = seq;
241
+ titleTime = eventTime;
242
+ }
243
+ break;
244
+ }
245
+ case 'user/message': {
246
+ // Injected context travels as user messages too; only a human's own input is indexed.
247
+ if (stringField(asRecord(data?.source), 'kind') !== 'user')
248
+ break;
249
+ const text = textBlocks(data?.content);
250
+ if (text !== '')
251
+ pushEntry(entries, 'user', text, seq, eventTime, numberField(data, 'turn'), limits.maxEntryChars);
252
+ break;
253
+ }
254
+ case 'assistant/message': {
255
+ // One entry per visible text block: a reasoning block and a tool-call block are not prose.
256
+ const message = asRecord(data?.message);
257
+ const blocks = message?.content;
258
+ if (!Array.isArray(blocks))
259
+ break;
260
+ for (const block of blocks) {
261
+ const record = asRecord(block);
262
+ if (stringField(record, 'type') !== 'text')
263
+ continue;
264
+ const text = stringField(record, 'text');
265
+ if (text === undefined || text.trim() === '')
266
+ continue;
267
+ pushEntry(entries, 'assistant', text, seq, eventTime, numberField(data, 'turn'), limits.maxEntryChars);
268
+ }
269
+ break;
270
+ }
271
+ case 'tool/call': {
272
+ const name = stringField(data, 'name') ?? '';
273
+ const args = argumentText(data?.arguments);
274
+ const text = args === '' ? name : `${name} ${args}`;
275
+ if (text.trim() === '')
276
+ break;
277
+ pushEntry(entries, 'tool', text, seq, eventTime, numberField(data, 'turn'), limits.toolCallChars);
278
+ break;
279
+ }
280
+ default:
281
+ break;
282
+ }
283
+ }
284
+ if (createdAt === 0 && firstEventTime !== undefined)
285
+ createdAt = firstEventTime;
286
+ if (lastAt < createdAt)
287
+ lastAt = createdAt;
288
+ if (title !== undefined) {
289
+ const clipped = clipText(title, limits.maxEntryChars);
290
+ entries.push({ seq: titleSeq, time: titleTime, turn: undefined, kind: 'title', text: clipped.text, clipped: clipped.clipped });
291
+ }
292
+ return {
293
+ id,
294
+ cwd: headerCwd,
295
+ createdAt,
296
+ lastAt,
297
+ title,
298
+ parentSession,
299
+ origin,
300
+ agentPreset,
301
+ turns,
302
+ entries,
303
+ };
304
+ }
305
+ /**
306
+ * Append one bounded entry, unless its text is blank.
307
+ * @param entries - destination list.
308
+ * @param kind - entry kind.
309
+ * @param text - raw entry text.
310
+ * @param seq - event sequence number.
311
+ * @param time - event timestamp.
312
+ * @param turn - turn number, when known.
313
+ * @param maxChars - longest text stored whole.
314
+ */
315
+ function pushEntry(entries, kind, text, seq, time, turn, maxChars) {
316
+ if (text.trim() === '')
317
+ return;
318
+ const clipped = clipText(text, maxChars);
319
+ entries.push({ seq, time, turn, kind, text: clipped.text, clipped: clipped.clipped });
320
+ }
321
+ /**
322
+ * Bound one text to the configured character cap.
323
+ * @param text - raw text.
324
+ * @param maxChars - longest text kept whole.
325
+ * @returns the stored text and whether it was clipped.
326
+ */
327
+ function clipText(text, maxChars) {
328
+ if (text.length <= maxChars)
329
+ return { text, clipped: false };
330
+ return { text: text.slice(0, maxChars), clipped: true };
331
+ }
332
+ /**
333
+ * Concatenate the text blocks of a message's content array.
334
+ * @param content - the event's `content` value.
335
+ * @returns the joined text, or an empty string when there is none.
336
+ */
337
+ function textBlocks(content) {
338
+ if (!Array.isArray(content))
339
+ return '';
340
+ const parts = [];
341
+ for (const block of content) {
342
+ const record = asRecord(block);
343
+ if (stringField(record, 'type') !== 'text')
344
+ continue;
345
+ const text = stringField(record, 'text');
346
+ if (text !== undefined && text !== '')
347
+ parts.push(text);
348
+ }
349
+ return parts.join('\n');
350
+ }
351
+ /**
352
+ * Render a tool call's arguments for indexing.
353
+ * @param value - the event's `arguments` value, normally a JSON string.
354
+ * @returns the arguments text, or an empty string when absent.
355
+ */
356
+ function argumentText(value) {
357
+ if (typeof value === 'string')
358
+ return value;
359
+ if (value === undefined || value === null)
360
+ return '';
361
+ try {
362
+ return JSON.stringify(value) ?? '';
363
+ }
364
+ catch {
365
+ return '';
366
+ }
367
+ }
368
+ /**
369
+ * Index of the first non-blank line.
370
+ * @param lines - split log lines.
371
+ * @returns the index, or undefined for an empty log.
372
+ */
373
+ function firstNonEmptyLine(lines) {
374
+ for (let index = 0; index < lines.length; index += 1) {
375
+ if (lines[index].trim() !== '')
376
+ return index;
377
+ }
378
+ return undefined;
379
+ }
380
+ /**
381
+ * Parse one JSONL line into an object, tolerating a corrupt line.
382
+ * @param line - one trimmed, non-empty line.
383
+ * @returns the record, or undefined when the line is not a JSON object.
384
+ */
385
+ function parseRecord(line) {
386
+ try {
387
+ const value = JSON.parse(line);
388
+ return asRecord(value);
389
+ }
390
+ catch {
391
+ return undefined;
392
+ }
393
+ }
394
+ /**
395
+ * Narrow a value to a plain object.
396
+ * @param value - candidate.
397
+ * @returns the object, or undefined.
398
+ */
399
+ function asRecord(value) {
400
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : undefined;
401
+ }
402
+ /**
403
+ * Read a string field from a record.
404
+ * @param record - the record, possibly absent.
405
+ * @param key - field name.
406
+ * @returns the string, or undefined when the field is absent or not a string.
407
+ */
408
+ function stringField(record, key) {
409
+ const value = record?.[key];
410
+ return typeof value === 'string' ? value : undefined;
411
+ }
412
+ /**
413
+ * Read a finite number field from a record.
414
+ * @param record - the record, possibly absent.
415
+ * @param key - field name.
416
+ * @returns the number, or undefined when the field is absent or not a finite number.
417
+ */
418
+ function numberField(record, key) {
419
+ const value = record?.[key];
420
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
421
+ }
422
+ /**
423
+ * Read one byte, failing with a readable message at a truncated frame.
424
+ * @param buffer - the raw log file contents.
425
+ * @param at - byte offset.
426
+ * @returns the byte value.
427
+ */
428
+ function readUInt8(buffer, at) {
429
+ if (at + 1 > buffer.length)
430
+ throw new Error('truncated zstd frame header');
431
+ return buffer.readUInt8(at);
432
+ }
433
+ /**
434
+ * Read an unsigned little-endian integer of 1–6 bytes.
435
+ * @param buffer - the raw log file contents.
436
+ * @param at - byte offset.
437
+ * @param width - field width in bytes.
438
+ * @returns the value.
439
+ */
440
+ function readUIntLE(buffer, at, width) {
441
+ if (at + width > buffer.length)
442
+ throw new Error('truncated zstd frame body');
443
+ return buffer.readUIntLE(at, width);
444
+ }
445
+ //# sourceMappingURL=parse.js.map
package/lib/scan.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Discover the harness session logs under `sessionsRoot`.
3
+ *
4
+ * The harness lays sessions out as `<sessionsRoot>/<escaped-cwd>/<session-dir>/session.v<N>.jsonl.zstd`,
5
+ * so the walk is exactly two directory levels deep followed by a filename match — it never recurses
6
+ * into unrelated files (a `.DS_Store` sits at the root of a real `~/.dsh/sessions`) and never reads
7
+ * anything it will not index.
8
+ *
9
+ * Both `v3` and `v4` logs are indexed: the harness has shipped both, they carry the same event shape,
10
+ * and a machine that upgraded keeps its older `v3` files. Within one session directory the highest
11
+ * version wins, so a half-finished v3 → v4 rewrite contributes one session rather than two.
12
+ * @module dsh-plugin-sessiongraph-sessions/scan
13
+ */
14
+ /** A session log file the indexer can read. */
15
+ export interface SessionFile {
16
+ /** Absolute path of the directory holding the log (the harness session directory). */
17
+ readonly sessionDir: string;
18
+ /** Absolute path of the log file itself. */
19
+ readonly filePath: string;
20
+ /** The workspace directory name as the harness escaped it; a fallback when the header has no `cwd`. */
21
+ readonly workspaceDir: string;
22
+ /** Modification time in epoch milliseconds, truncated to a whole millisecond for integer storage. */
23
+ readonly mtime: number;
24
+ /** File size in bytes, the other half of the incremental change key. */
25
+ readonly size: number;
26
+ }
27
+ /**
28
+ * List every session log under `sessionsRoot`, one entry per session directory.
29
+ * @param sessionsRoot - absolute path of the harness session store.
30
+ * @returns the logs found, ordered by workspace then session directory so runs are reproducible.
31
+ */
32
+ export declare function scanSessions(sessionsRoot: string): Promise<SessionFile[]>;
33
+ //# sourceMappingURL=scan.d.ts.map
package/lib/scan.js ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Discover the harness session logs under `sessionsRoot`.
3
+ *
4
+ * The harness lays sessions out as `<sessionsRoot>/<escaped-cwd>/<session-dir>/session.v<N>.jsonl.zstd`,
5
+ * so the walk is exactly two directory levels deep followed by a filename match — it never recurses
6
+ * into unrelated files (a `.DS_Store` sits at the root of a real `~/.dsh/sessions`) and never reads
7
+ * anything it will not index.
8
+ *
9
+ * Both `v3` and `v4` logs are indexed: the harness has shipped both, they carry the same event shape,
10
+ * and a machine that upgraded keeps its older `v3` files. Within one session directory the highest
11
+ * version wins, so a half-finished v3 → v4 rewrite contributes one session rather than two.
12
+ * @module dsh-plugin-sessiongraph-sessions/scan
13
+ */
14
+ import { readdir, stat } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ /** `session.v4.jsonl.zstd`, `session.v3.jsonl.zstd`, … — the version is captured for the pick below. */
17
+ const SESSION_LOG = /^session\.v(\d+)\.jsonl\.zstd$/;
18
+ /**
19
+ * List every session log under `sessionsRoot`, one entry per session directory.
20
+ * @param sessionsRoot - absolute path of the harness session store.
21
+ * @returns the logs found, ordered by workspace then session directory so runs are reproducible.
22
+ */
23
+ export async function scanSessions(sessionsRoot) {
24
+ const found = [];
25
+ let workspaces;
26
+ try {
27
+ workspaces = await readdir(sessionsRoot, { withFileTypes: true });
28
+ }
29
+ catch (error) {
30
+ // A machine that has never run a session has no store yet; that is an empty index, not a failure.
31
+ if (isMissing(error))
32
+ return [];
33
+ throw error;
34
+ }
35
+ for (const workspace of workspaces) {
36
+ if (!workspace.isDirectory())
37
+ continue;
38
+ const workspacePath = join(sessionsRoot, workspace.name);
39
+ const sessions = await listDirectories(workspacePath);
40
+ for (const session of sessions) {
41
+ const sessionDir = join(workspacePath, session);
42
+ const log = await pickLogFile(sessionDir);
43
+ if (log === undefined)
44
+ continue;
45
+ try {
46
+ const stats = await stat(log.path);
47
+ found.push({
48
+ sessionDir,
49
+ filePath: log.path,
50
+ workspaceDir: workspace.name,
51
+ mtime: Math.trunc(stats.mtimeMs),
52
+ size: stats.size,
53
+ });
54
+ }
55
+ catch (error) {
56
+ // Vanished between the listing and the stat: the next run would drop it anyway.
57
+ if (!isMissing(error))
58
+ throw error;
59
+ }
60
+ }
61
+ }
62
+ found.sort((left, right) => left.workspaceDir.localeCompare(right.workspaceDir) || left.sessionDir.localeCompare(right.sessionDir));
63
+ return found;
64
+ }
65
+ /**
66
+ * List the session directories inside one workspace directory.
67
+ * @param workspacePath - absolute path of one escaped workspace directory.
68
+ * @returns directory names, or none when the directory vanished mid-scan.
69
+ */
70
+ async function listDirectories(workspacePath) {
71
+ try {
72
+ const entries = await readdir(workspacePath, { withFileTypes: true });
73
+ return entries.filter(entry => entry.isDirectory()).map(entry => entry.name);
74
+ }
75
+ catch (error) {
76
+ // Only a directory that vanished mid-scan is ignorable. Failing to read one for any other reason
77
+ // must abort the run: a silently short scan would look like "sources disappeared" and delete the
78
+ // rows of every session it could not list.
79
+ if (isMissing(error))
80
+ return [];
81
+ throw error;
82
+ }
83
+ }
84
+ /**
85
+ * Pick the newest-version log inside one session directory.
86
+ * @param sessionDir - absolute path of the session directory.
87
+ * @returns the chosen log path with its version, or undefined when the directory holds none.
88
+ */
89
+ async function pickLogFile(sessionDir) {
90
+ let entries;
91
+ try {
92
+ entries = await readdir(sessionDir, { withFileTypes: true });
93
+ }
94
+ catch (error) {
95
+ if (isMissing(error))
96
+ return undefined;
97
+ throw error;
98
+ }
99
+ let chosen;
100
+ for (const entry of entries) {
101
+ if (!entry.isFile())
102
+ continue;
103
+ const match = SESSION_LOG.exec(entry.name);
104
+ if (match === null)
105
+ continue;
106
+ const version = Number.parseInt(match[1] ?? '0', 10);
107
+ if (chosen === undefined || version > chosen.version) {
108
+ chosen = { path: join(sessionDir, entry.name), version };
109
+ }
110
+ }
111
+ return chosen;
112
+ }
113
+ /**
114
+ * Whether an error is a missing path rather than a real I/O failure.
115
+ * @param error - the caught value.
116
+ * @returns true for ENOENT.
117
+ */
118
+ function isMissing(error) {
119
+ return typeof error === 'object' && error !== null && error.code === 'ENOENT';
120
+ }
121
+ //# sourceMappingURL=scan.js.map
package/lib/write.d.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Write the session index with `node:sqlite`.
3
+ *
4
+ * Two modes, deliberately different on disk:
5
+ *
6
+ * - **Rebuild** (`force`) writes a complete index to `index.db.tmp` and renames it over `index.db`
7
+ * only after the transaction commits, so a reader never observes a half-built index and a run that
8
+ * dies leaves the previous one untouched. When the run is also scoped (a workspace or single
9
+ * session was requested), the previous index is attached and copied in first, because a scoped
10
+ * rebuild must not delete the sessions it was not asked about.
11
+ * - **Incremental** opens the live index and rewrites only the sessions whose source changed, inside
12
+ * one transaction.
13
+ *
14
+ * `entry_fts.rowid` is aligned with `entry.id`: every written entry row is followed by exactly one
15
+ * FTS row carrying the tokenizer's output. Deletion therefore goes FTS-first — the entry rows are
16
+ * what names the rowids, so dropping them first would strand the FTS rows.
17
+ * @module dsh-plugin-sessiongraph-sessions/write
18
+ */
19
+ import type { ParsedSession } from './parse.ts';
20
+ /** One parsed session and the source it is recorded as coming from. */
21
+ export interface IndexedSession {
22
+ readonly session: ParsedSession;
23
+ /**
24
+ * Absolute path of the log it came from, or the synthetic `live://<id>` marker for a session the
25
+ * host still holds in memory and that has no stable file version yet.
26
+ */
27
+ readonly sourcePath: string;
28
+ /** Source modification time, or 0 for a live session (there is no version to compare). */
29
+ readonly mtime: number;
30
+ /** Source size in bytes, or 0 for a live session. */
31
+ readonly size: number;
32
+ }
33
+ /**
34
+ * Sidecar summary an indexer leaves beside `index.db`.
35
+ *
36
+ * Prompt assembly must never open SQLite, so the same post-run totals the report carries are also
37
+ * persisted as a small JSON file the tool can read synchronously. It is a derived copy: the `meta`
38
+ * table stays authoritative for the store, and a missing sidecar only degrades the prompt line.
39
+ */
40
+ export declare const STATUS_RELATIVE_PATH = "status.json";
41
+ /** What a previous index already holds for one source file. */
42
+ export interface ExistingSession {
43
+ readonly id: string;
44
+ readonly workspace: string;
45
+ /** Absolute path of the session log this row was built from. */
46
+ readonly sourcePath: string;
47
+ readonly mtime: number;
48
+ readonly size: number;
49
+ }
50
+ /** One index write, already scoped by the caller. */
51
+ export interface IndexWritePlan {
52
+ /** Absolute path of `index.db`. */
53
+ readonly indexPath: string;
54
+ /** Rebuild from scratch in `index.db.tmp` and rename it into place. */
55
+ readonly force: boolean;
56
+ /** With `force`: seed the rebuild from the previous index so out-of-scope rows survive. */
57
+ readonly preserveExisting: boolean;
58
+ /** Sessions to (re)write. */
59
+ readonly sessions: readonly IndexedSession[];
60
+ /** Session ids whose source file disappeared. */
61
+ readonly removed: readonly string[];
62
+ /** Sessions the scan found, for `meta.discovered`. */
63
+ readonly discovered: number;
64
+ /** Sessions that failed to parse, for `meta.failed`. */
65
+ readonly failed: number;
66
+ /** Epoch milliseconds recorded as the run's completion time in `meta` and the sidecar. */
67
+ readonly indexedAt: number;
68
+ }
69
+ /** What one index write produced. */
70
+ export interface IndexWriteResult {
71
+ /** Searchable entries in the index after the write. */
72
+ readonly entries: number;
73
+ /** Sessions in the index after the write. */
74
+ readonly sessions: number;
75
+ /** Distinct workspaces in the index after the write. */
76
+ readonly workspaces: number;
77
+ /** Bytes the index file occupies after the write. */
78
+ readonly bytes: number;
79
+ }
80
+ /**
81
+ * Read the state a previous index recorded, when it is usable.
82
+ *
83
+ * An index written by another schema version is refused rather than reused: silently mixing two
84
+ * shapes would hand the store rows it cannot interpret.
85
+ * @param indexPath - absolute path of `index.db`.
86
+ * @returns rows keyed by source path, or undefined when there is no usable index.
87
+ */
88
+ export declare function readIndexState(indexPath: string): ReadonlyMap<string, ExistingSession> | undefined;
89
+ /**
90
+ * Write one index update.
91
+ * @param plan - sessions to write, sessions to drop, and how to place the result.
92
+ * @returns what the index holds after the write.
93
+ */
94
+ export declare function writeIndex(plan: IndexWritePlan): IndexWriteResult;
95
+ //# sourceMappingURL=write.d.ts.map