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/index.js ADDED
@@ -0,0 +1,706 @@
1
+ /**
2
+ * Session-log indexer for the session-graph seam: turns `~/.dsh/sessions` into the SQLite index
3
+ * `dsh-plugin-sessiongraph-sqlite` reads.
4
+ *
5
+ * Registers **only an indexer**, never a store. Indexing is caller-initiated (`sessiongraph_index`)
6
+ * and never runs from a query: a full machine is hundreds of logs and more than a gigabyte of
7
+ * decompressed JSON, so it has its own timeout budget and the query path stays millisecond-fast.
8
+ *
9
+ * The index root is an opaque directory the caller names; `indexRoot` is where `index.db` lives, and
10
+ * `sessionsRoot` (the harness's own log directory) is a separate, configurable input. That split is
11
+ * what lets a test build a fixture-driven index without a real `~/.dsh`.
12
+ *
13
+ * A run is incremental by default — only logs whose mtime or size moved are re-read — and `force`
14
+ * rebuilds every log into a temporary file that replaces the old index atomically. A single bad log
15
+ * is reported in `failed` and never aborts the run.
16
+ *
17
+ * Every session creation also refreshes an **existing** index in the background (`agent/created`,
18
+ * `autoIndex`): a machine that already has one stays fresh without the model remembering to ask. A
19
+ * missing index is deliberately left alone — the first build reads every log on the machine and is
20
+ * the caller's explicit decision (`sessiongraph_index`), not something to trigger on a session start.
21
+ *
22
+ * Sessions the host still holds in memory (`ctx.sessionQuery`) are indexed as well, in a second pass
23
+ * after the file-path transaction commits: a conversation that is still running has no log on disk
24
+ * yet, and "where did I say X" has to include what was just typed. That pass is optional — a host
25
+ * without the service leaves the file-path result exactly as it was, and one that fails degrades to
26
+ * it instead of failing the run.
27
+ *
28
+ * An explicit run is handed to the host's background-job registry too once the store is large enough
29
+ * (`backgroundIndexThreshold`), because a run that grows past a tool's timeout budget must not hold
30
+ * that call open. The seam types `index()` as returning a report, so a delegated run comes back as a
31
+ * report carrying `jobId` — see {@link isBackgroundIndexReport} for the discrimination a consumer
32
+ * needs, and {@link SessiongraphBackgroundIndexReport} for what its other fields then mean.
33
+ * @module dsh-plugin-sessiongraph-sessions
34
+ */
35
+ import { existsSync, readFileSync } from 'node:fs';
36
+ import { readFile, stat } from 'node:fs/promises';
37
+ import { homedir } from 'node:os';
38
+ import { basename, join } from 'node:path';
39
+ import z from '@deepseek-ai/schemastery';
40
+ import { loadNative } from 'dsh-plugin-sessiongraph-native';
41
+ import { INDEX_RELATIVE_PATH, SessiongraphIndexerId } from 'dsh-plugin-sessiongraph-service';
42
+ import { collectLiveSessions, LIVE_SOURCE_PREFIX } from "./live.js";
43
+ import { parseSessionLog } from "./parse.js";
44
+ import { scanSessions } from "./scan.js";
45
+ import { readIndexState, STATUS_RELATIVE_PATH, writeIndex } from "./write.js";
46
+ export { decompressSessionLog, parseSessionLog } from "./parse.js";
47
+ export { scanSessions } from "./scan.js";
48
+ export { LIVE_SOURCE_PREFIX } from "./live.js";
49
+ export { readIndexState, STATUS_RELATIVE_PATH, writeIndex } from "./write.js";
50
+ /** Cordis plugin name for loader diagnostics. */
51
+ export const name = 'sessiongraph-sessions';
52
+ /** Services required by this plugin. */
53
+ export const inject = ['sessiongraph'];
54
+ /** Default branded identity this indexer reserves on the seam. */
55
+ export const DEFAULT_INDEXER_ID = 'sessiongraph-sessions';
56
+ /** Default harness session directory. */
57
+ export const DEFAULT_SESSIONS_ROOT = join(homedir(), '.dsh', 'sessions');
58
+ /**
59
+ * Default index root, matching the tool's own default so a stock profile's automatic refresh writes
60
+ * where its queries read. A deployment that moves one must move the other.
61
+ */
62
+ export const DEFAULT_INDEX_ROOT = join(homedir(), '.dsh', 'sessiongraph');
63
+ /** Stable job kind of a background index run, used for retention and diagnostics. */
64
+ export const AUTO_INDEX_JOB_KIND = 'sessiongraph-index';
65
+ /** Default number of session logs at which a manual index run becomes a background job. */
66
+ export const DEFAULT_BACKGROUND_INDEX_THRESHOLD = 1_000;
67
+ /**
68
+ * Default cap on live sessions rebuilt per run. A host normally reports one or two; the cap exists
69
+ * because replaying a live log is not incremental and a host could report many.
70
+ */
71
+ export const DEFAULT_MAX_LIVE_SESSIONS = 8;
72
+ /** Default engine selection. */
73
+ export const DEFAULT_ENGINE = 'auto';
74
+ /** Default cap on stored message text, in characters. */
75
+ export const DEFAULT_MAX_ENTRY_CHARS = 4_000;
76
+ /** Default cap on stored tool-invocation text, in characters. */
77
+ export const DEFAULT_TOOL_CALL_CHARS = 2_000;
78
+ /**
79
+ * Default cap on one compressed log file, in bytes. The largest session on a real machine is ~42MB
80
+ * decompressed from ~13MB compressed, so 64MB leaves room for a long-running session without
81
+ * letting a pathological log exhaust memory in one decompress.
82
+ */
83
+ export const DEFAULT_MAX_FILE_BYTES = 64 * 1024 * 1024;
84
+ export const Config = z.object({
85
+ sessionsRoot: z.string().default(DEFAULT_SESSIONS_ROOT),
86
+ indexRoot: z.string().default(DEFAULT_INDEX_ROOT),
87
+ maxEntryChars: z.number().default(DEFAULT_MAX_ENTRY_CHARS),
88
+ toolCallChars: z.number().default(DEFAULT_TOOL_CALL_CHARS),
89
+ maxFileBytes: z.number().default(DEFAULT_MAX_FILE_BYTES),
90
+ autoIndex: z.boolean().default(true),
91
+ backgroundIndexThreshold: z.number().default(DEFAULT_BACKGROUND_INDEX_THRESHOLD),
92
+ maxLiveSessions: z.number().default(DEFAULT_MAX_LIVE_SESSIONS),
93
+ engine: z.union([z.const('auto'), z.const('native'), z.const('typescript')]).default(DEFAULT_ENGINE),
94
+ });
95
+ /**
96
+ * Register the session-log indexer.
97
+ * @param ctx - the plugin context (must inject `sessiongraph`).
98
+ * @param config - the resolved plugin configuration.
99
+ */
100
+ export function apply(ctx, config) {
101
+ const resolved = config;
102
+ assertPositiveInteger('maxEntryChars', resolved.maxEntryChars);
103
+ assertPositiveInteger('toolCallChars', resolved.toolCallChars);
104
+ assertPositiveInteger('maxFileBytes', resolved.maxFileBytes);
105
+ assertPositiveInteger('backgroundIndexThreshold', resolved.backgroundIndexThreshold);
106
+ assertPositiveInteger('maxLiveSessions', resolved.maxLiveSessions);
107
+ if (resolved.indexRoot.trim() === '') {
108
+ throw new Error('sessiongraph-sessions: indexRoot must be a non-empty directory path');
109
+ }
110
+ const indexer = createIndexer(ctx, resolved);
111
+ // Process-local, private to this plugin instance: a root is auto-refreshed at most once per
112
+ // process, so a burst of session creations cannot queue a burst of index runs.
113
+ const autoIndexed = new Set();
114
+ // `agent/created` is typed by the host's agent service; this package reads only the two fields it
115
+ // needs through a structural face rather than depending on that package.
116
+ const agentEvents = ctx;
117
+ ctx.effect(function* () {
118
+ if (resolved.autoIndex) {
119
+ yield agentEvents.on('agent/created', payload => {
120
+ scheduleAutoIndex(ctx, resolved, autoIndexed, payload);
121
+ });
122
+ }
123
+ yield ctx.sessiongraph.registerIndexer(indexer);
124
+ }, name);
125
+ }
126
+ /**
127
+ * Build the indexer object this plugin registers.
128
+ * @param ctx - the plugin context; only its optional `jobs` service is consulted.
129
+ * @param config - the resolved plugin configuration.
130
+ * @returns the seam provider.
131
+ */
132
+ export function createIndexer(ctx, config) {
133
+ return {
134
+ id: SessiongraphIndexerId(DEFAULT_INDEXER_ID),
135
+ async canIndex(indexRoot) {
136
+ try {
137
+ return (await stat(indexRoot)).isDirectory();
138
+ }
139
+ catch (error) {
140
+ // A root that does not exist yet is still ours to create; an unreadable one is not.
141
+ return isMissingPath(error);
142
+ }
143
+ },
144
+ async index(indexRoot, options, signal) {
145
+ const jobId = await startBackgroundIndex(ctx, indexRoot, options, config);
146
+ if (jobId !== undefined)
147
+ return backgroundIndexReport(indexRoot, jobId);
148
+ return runIndex(indexRoot, options, signal, config, ctx);
149
+ },
150
+ };
151
+ }
152
+ /**
153
+ * Whether an index result came from a delegated run rather than a completed one.
154
+ *
155
+ * The seam's return type is the completed report, so a job id can only arrive as an extension of it;
156
+ * every other field of a delegated result describes the index as it stood when the job started.
157
+ * @param report - the value `ctx.sessiongraph.index()` resolved with.
158
+ * @returns true when the run is still going in the host's job registry.
159
+ */
160
+ export function isBackgroundIndexReport(report) {
161
+ return typeof report.jobId === 'string';
162
+ }
163
+ /**
164
+ * Refresh an existing index in the background, once per process, when a session is created.
165
+ *
166
+ * Runs synchronously inside `agent/created` dispatch, which happens during agent registration, so it
167
+ * must never block or throw: a synchronous index here would hold up the session's own creation. It
168
+ * only decides whether a refresh is warranted and hands one to the host's job registry.
169
+ * @param ctx - the plugin context, consulted for the optional job registry.
170
+ * @param config - the resolved plugin configuration.
171
+ * @param autoIndexed - index roots this process has already scheduled a refresh for.
172
+ * @param payload - the `agent/created` dispatch payload.
173
+ */
174
+ function scheduleAutoIndex(ctx, config, autoIndexed, payload) {
175
+ try {
176
+ const agent = payload.agent;
177
+ const workspace = agent?.session?.header?.cwd;
178
+ // A session created without a workspace is not an occasion to touch a machine-wide index.
179
+ if (agent === undefined || workspace === undefined || workspace === '')
180
+ return;
181
+ const indexRoot = expandHome(config.indexRoot);
182
+ // Once per root per process: a burst of session creations must not queue a burst of index runs.
183
+ if (autoIndexed.has(indexRoot))
184
+ return;
185
+ // Only an index that already exists is refreshed: the first build reads every log on the
186
+ // machine, and that is the caller's explicit decision, not something a session start triggers.
187
+ if (!hasExistingIndex(indexRoot))
188
+ return;
189
+ // `ctx.get` rather than an `inject` entry: background jobs are a host composition choice, and
190
+ // requiring the service would hide this whole indexer from a profile that runs none.
191
+ const jobs = ctx.get('jobs');
192
+ if (jobs === undefined)
193
+ return;
194
+ jobs.start({
195
+ // The registry keys a job by its owning session id, not the agent object.
196
+ owner: agent.id,
197
+ kind: AUTO_INDEX_JOB_KIND,
198
+ label: `sessiongraph refresh for ${workspace}`,
199
+ run: handle => runIndexJob(indexRoot, {}, config, handle),
200
+ });
201
+ // Marked only after the registry accepted the job, so a refusal leaves the root eligible for
202
+ // the next session that opens.
203
+ autoIndexed.add(indexRoot);
204
+ }
205
+ catch (error) {
206
+ // A throwing listener would veto publication of the agent. Losing a refresh is a degradation,
207
+ // never a reason to stop a session from starting; report once and carry on.
208
+ console.error(`sessiongraph-sessions: automatic index after session creation failed: ${describeError(error)}`);
209
+ }
210
+ }
211
+ /**
212
+ * Hand one explicit index run to the host's background-job registry, when the run is large enough to
213
+ * outlive a tool call.
214
+ *
215
+ * The indexer itself never sees the calling agent — the seam passes it an index root and switches,
216
+ * not an execution context — so the owning session id arrives as a runtime extension of the options
217
+ * object the tool passes through. Without it this returns immediately and the caller runs in place;
218
+ * a composition that predates this contract keeps its old behaviour instead of failing.
219
+ *
220
+ * Every other reason not to start a job (no registry, no controller for the agent, the owner already
221
+ * at its job cap) also falls back to the synchronous run rather than surfacing an error the model
222
+ * cannot act on.
223
+ * @param ctx - the plugin context, consulted for the optional job registry.
224
+ * @param indexRoot - the index to build.
225
+ * @param options - scope and rebuild switches, plus the caller's owning session id when it is known.
226
+ * @param config - the resolved plugin configuration.
227
+ * @returns the started job's id, or `undefined` when this call must index synchronously.
228
+ */
229
+ async function startBackgroundIndex(ctx, indexRoot, options, config) {
230
+ try {
231
+ const owner = indexJobOwner(options);
232
+ if (owner === undefined)
233
+ return undefined;
234
+ // A scoped run touches one workspace or one session: the round trip through `job_output` would
235
+ // cost more than the run itself.
236
+ if (options.workspace !== undefined || options.sessionId !== undefined)
237
+ return undefined;
238
+ const jobs = ctx.get('jobs');
239
+ if (jobs === undefined)
240
+ return undefined;
241
+ // Asked last, so a composition without a registry never pays for the store scan below. The
242
+ // source count is the run's real size; the sidecar only describes what the last run saw.
243
+ if ((await countSessionFiles(config.sessionsRoot)) < config.backgroundIndexThreshold)
244
+ return undefined;
245
+ return jobs.start({
246
+ owner,
247
+ kind: AUTO_INDEX_JOB_KIND,
248
+ label: `sessiongraph index ${indexRoot}`,
249
+ run: handle => runIndexJob(indexRoot, options, config, handle),
250
+ });
251
+ }
252
+ catch {
253
+ // No controller serves this agent, or the owner already holds its maximum number of jobs: both
254
+ // mean "do the work here instead", never "fail the model's call".
255
+ return undefined;
256
+ }
257
+ }
258
+ /**
259
+ * The report a delegated run resolves with: the index as it stands, plus the job that will replace it.
260
+ *
261
+ * Nothing was written by this call, so every per-run counter is zero; `sessions`, `entries`,
262
+ * `workspaces` and `bytes` are the previous run's totals, read from the sidecar without opening
263
+ * SQLite.
264
+ * @param indexRoot - the index being built.
265
+ * @param jobId - the job that owns the background run.
266
+ * @returns the delegated result.
267
+ */
268
+ function backgroundIndexReport(indexRoot, jobId) {
269
+ const status = readStatusFile(indexRoot);
270
+ return {
271
+ indexPath: join(indexRoot, INDEX_RELATIVE_PATH),
272
+ added: 0,
273
+ updated: 0,
274
+ skipped: 0,
275
+ removed: 0,
276
+ discovered: status?.sessions ?? 0,
277
+ failed: [],
278
+ entries: status?.entries ?? 0,
279
+ sessions: status?.sessions ?? 0,
280
+ workspaces: status?.workspaces ?? 0,
281
+ bytes: status?.bytes ?? 0,
282
+ elapsedMs: 0,
283
+ jobId,
284
+ };
285
+ }
286
+ /**
287
+ * The owning session id a caller attached to an index request, when it attached one.
288
+ * @param options - the seam's index options, possibly extended by the consumer.
289
+ * @returns the session id, or undefined when the caller cannot own a job.
290
+ */
291
+ function indexJobOwner(options) {
292
+ const owner = options.owner;
293
+ return typeof owner === 'string' && owner !== '' ? owner : undefined;
294
+ }
295
+ /**
296
+ * Count the session logs an index run would read.
297
+ * @param sessionsRoot - the harness session store.
298
+ * @returns the number of logs, or zero when the store cannot be listed.
299
+ */
300
+ async function countSessionFiles(sessionsRoot) {
301
+ try {
302
+ return (await scanSessions(sessionsRoot)).length;
303
+ }
304
+ catch {
305
+ return 0;
306
+ }
307
+ }
308
+ /**
309
+ * The producer body of one background index run.
310
+ *
311
+ * Both halves of the host's job contract are mandatory: `cancel` aborts the run, and `done` settles
312
+ * even on failure — a rejected `done` breaks the registry's bookkeeping and leaves the job looking
313
+ * stuck forever.
314
+ * @param indexRoot - the index to build.
315
+ * @param options - scope and rebuild switches.
316
+ * @param config - the resolved plugin configuration.
317
+ * @param handle - the job's producer face, for the job's live log.
318
+ * @returns the job's cancellation hook and settlement promise.
319
+ */
320
+ function runIndexJob(indexRoot, options, config, handle) {
321
+ const controller = new AbortController();
322
+ const done = (async () => {
323
+ try {
324
+ // Deliberately not the caller's signal: a background run outlives the call that started it,
325
+ // so aborting that call must not abort the job. The job's own cancel does.
326
+ const report = await runIndex(indexRoot, options, controller.signal, config);
327
+ handle.append(`Indexed ${indexRoot}: ${report.sessions} sessions, ${report.entries} entries ` +
328
+ `(${report.added} added, ${report.updated} updated, ${report.removed} removed).\n`);
329
+ return { status: 'completed' };
330
+ }
331
+ catch (error) {
332
+ const message = describeError(error);
333
+ handle.append(`sessiongraph index failed for ${indexRoot}: ${message}\n`);
334
+ return { status: 'failed', detail: message };
335
+ }
336
+ })();
337
+ return { cancel: () => controller.abort(), done };
338
+ }
339
+ /**
340
+ * Read the sidecar summary a previous run left beside the index.
341
+ * @param indexRoot - directory to inspect.
342
+ * @returns the snapshot, or undefined when it is absent, unreadable, or not shaped like one.
343
+ */
344
+ function readStatusFile(indexRoot) {
345
+ let parsed;
346
+ try {
347
+ parsed = JSON.parse(readFileSync(join(indexRoot, STATUS_RELATIVE_PATH), 'utf8'));
348
+ }
349
+ catch {
350
+ return undefined;
351
+ }
352
+ if (typeof parsed !== 'object' || parsed === null)
353
+ return undefined;
354
+ const record = parsed;
355
+ const indexedAt = numberField(record, 'indexedAt');
356
+ const sessions = numberField(record, 'sessions');
357
+ const entries = numberField(record, 'entries');
358
+ const workspaces = numberField(record, 'workspaces');
359
+ const bytes = numberField(record, 'bytes');
360
+ if (indexedAt === undefined || sessions === undefined || entries === undefined
361
+ || workspaces === undefined || bytes === undefined)
362
+ return undefined;
363
+ return { indexedAt, sessions, entries, workspaces, bytes };
364
+ }
365
+ /**
366
+ * Read one numeric field of a parsed object.
367
+ * @param record - the parsed object.
368
+ * @param key - field name.
369
+ * @returns the number, or undefined when the field is absent or not finite.
370
+ */
371
+ function numberField(record, key) {
372
+ const value = record[key];
373
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
374
+ }
375
+ /**
376
+ * Whether `indexRoot` holds an index this plugin wrote, judged without opening SQLite.
377
+ *
378
+ * The sidecar proves a run completed; the database file proves the index it describes is still
379
+ * there. Prompt assembly reads the same sidecar, so this is the one file format both sides agree on.
380
+ * @param indexRoot - directory to inspect.
381
+ * @returns true when an existing index is safe to refresh in place.
382
+ */
383
+ function hasExistingIndex(indexRoot) {
384
+ return existsSync(join(indexRoot, INDEX_RELATIVE_PATH)) && readStatusFile(indexRoot) !== undefined;
385
+ }
386
+ /**
387
+ * Expand a leading `~` in a configured path.
388
+ * @param path - configured directory, absolute or home-relative.
389
+ * @returns an absolute path.
390
+ */
391
+ function expandHome(path) {
392
+ if (path === '~')
393
+ return homedir();
394
+ if (path.startsWith('~/'))
395
+ return join(homedir(), path.slice(2));
396
+ return path;
397
+ }
398
+ /**
399
+ * The engine a run should try first, or `undefined` when it must use the TypeScript indexer.
400
+ *
401
+ * `loadNative` promises never to throw — an absent or foreign-architecture binary is a normal state —
402
+ * but the boundary stays: a loader bug must degrade to the TypeScript engine instead of taking the
403
+ * whole indexer down on a machine that would otherwise work.
404
+ * @param setting - the configured engine.
405
+ * @returns the kernel, or undefined when this run must use the TypeScript path.
406
+ */
407
+ function resolveNativeEngine(setting) {
408
+ if (setting === 'typescript')
409
+ return undefined;
410
+ try {
411
+ return loadNative();
412
+ }
413
+ catch {
414
+ return undefined;
415
+ }
416
+ }
417
+ /**
418
+ * Run one index through the native kernel, then apply the live-session pass on top.
419
+ *
420
+ * The kernel is synchronous and covers the file path only — it has no access to the host's
421
+ * `sessionQuery`, so its report always says zero live sessions. The live pass is therefore shared
422
+ * with the TypeScript path and its numbers overwrite the kernel's.
423
+ * @param native - the loaded kernel.
424
+ * @param indexPath - absolute path of the index file.
425
+ * @param options - scope and rebuild switches.
426
+ * @param config - the resolved plugin configuration.
427
+ * @param inScope - whether a session is in the run's scope.
428
+ * @param ctx - the plugin context, consulted for the optional live-session source.
429
+ * @param signal - aborts before the kernel starts.
430
+ * @param startedAt - epoch milliseconds the run started, for `elapsedMs`.
431
+ * @returns the run's report, or undefined when the kernel failed and the caller should fall back.
432
+ */
433
+ async function runNativeEngine(native, indexPath, options, config, inScope, ctx, signal, startedAt) {
434
+ let nativeReport;
435
+ try {
436
+ signal?.throwIfAborted();
437
+ nativeReport = native.indexSessions({
438
+ sessionsRoot: config.sessionsRoot,
439
+ indexPath,
440
+ ...(options.force === undefined ? {} : { force: options.force }),
441
+ ...(options.workspace === undefined ? {} : { workspaceFilter: options.workspace }),
442
+ ...(options.sessionId === undefined ? {} : { sessionFilter: options.sessionId }),
443
+ maxEntryChars: config.maxEntryChars,
444
+ toolCallChars: config.toolCallChars,
445
+ maxFileBytes: config.maxFileBytes,
446
+ });
447
+ }
448
+ catch (error) {
449
+ // A binary built for another ABI, a corrupt previous index the kernel refuses, or a write it
450
+ // could not finish: the TypeScript path below is the fallback, and it starts from whatever the
451
+ // previous index left behind.
452
+ console.error(`sessiongraph-sessions: native index failed, falling back to the TypeScript engine: ${describeError(error)}`);
453
+ return undefined;
454
+ }
455
+ const live = await runLivePass(ctx, indexPath, config, inScope, {
456
+ discovered: nativeReport.discovered,
457
+ failed: nativeReport.failed.length,
458
+ indexedAt: Date.now(),
459
+ });
460
+ return {
461
+ indexPath: nativeReport.indexPath,
462
+ added: nativeReport.added,
463
+ updated: nativeReport.updated,
464
+ skipped: nativeReport.skipped,
465
+ removed: nativeReport.removed,
466
+ liveSessions: live.liveSessions,
467
+ liveSkipped: live.liveSkipped,
468
+ discovered: nativeReport.discovered,
469
+ failed: nativeReport.failed,
470
+ entries: live.totals?.entries ?? nativeReport.entries,
471
+ sessions: live.totals?.sessions ?? nativeReport.sessions,
472
+ workspaces: live.totals?.workspaces ?? nativeReport.workspaces,
473
+ bytes: live.totals?.bytes ?? nativeReport.bytes,
474
+ elapsedMs: Date.now() - startedAt,
475
+ engine: 'native',
476
+ };
477
+ }
478
+ /** A live pass that found nothing, or failed and was skipped. */
479
+ const EMPTY_LIVE_PASS = { liveSessions: 0, liveSkipped: 0, totals: undefined };
480
+ /**
481
+ * Rebuild the host's live sessions on top of an already-committed file-path index.
482
+ *
483
+ * Runs in its own transaction after the file path has committed, because a replay reads a whole
484
+ * in-memory log and can be slow — the file results must already be durable before it starts, and a
485
+ * failure here must not roll them back. Shared by both engines: the native kernel cannot see live
486
+ * sessions, and the TypeScript path must not grow a second copy of this logic.
487
+ * @param ctx - the plugin context, consulted for the optional `sessionQuery` service.
488
+ * @param indexPath - absolute path of the index file.
489
+ * @param config - the resolved plugin configuration.
490
+ * @param inScope - whether a session is in the run's scope.
491
+ * @param provenance - this run's discovery counts, for the `meta` rows the live write refreshes.
492
+ * @returns the live counts and, when rows were written, the index's new totals.
493
+ */
494
+ async function runLivePass(ctx, indexPath, config, inScope, provenance) {
495
+ const sessionQuery = ctx?.get('sessionQuery');
496
+ // `ctx.get` rather than an `inject` entry: a host without the service keeps the file-path index.
497
+ if (sessionQuery === undefined)
498
+ return EMPTY_LIVE_PASS;
499
+ try {
500
+ const live = await collectLiveSessions(sessionQuery, {
501
+ maxLiveSessions: config.maxLiveSessions,
502
+ limits: { maxEntryChars: config.maxEntryChars, toolCallChars: config.toolCallChars },
503
+ inScope,
504
+ });
505
+ if (live.sessions.length === 0) {
506
+ return { liveSessions: 0, liveSkipped: live.skipped, totals: undefined };
507
+ }
508
+ const totals = writeIndex({
509
+ indexPath,
510
+ force: false,
511
+ preserveExisting: false,
512
+ sessions: live.sessions,
513
+ removed: [],
514
+ discovered: provenance.discovered,
515
+ failed: provenance.failed,
516
+ indexedAt: provenance.indexedAt,
517
+ });
518
+ return { liveSessions: live.sessions.length, liveSkipped: live.skipped, totals };
519
+ }
520
+ catch (error) {
521
+ // An older kernel, a failed persistence read, or a write conflict: the file-path index is
522
+ // complete and committed, so the run degrades rather than failing.
523
+ console.error(`sessiongraph-sessions: live session indexing skipped: ${describeError(error)}`);
524
+ return EMPTY_LIVE_PASS;
525
+ }
526
+ }
527
+ /**
528
+ * Build or refresh one index.
529
+ * @param indexRoot - directory to write `index.db` into.
530
+ * @param options - scope and rebuild switches.
531
+ * @param signal - aborts between sessions.
532
+ * @param config - the resolved plugin configuration.
533
+ * @param ctx - the plugin context, consulted for the optional live-session source. Omitted by
534
+ * callers that only exercise the file path, which then behave exactly as an older host would.
535
+ * @returns what the run did.
536
+ */
537
+ export async function runIndex(indexRoot, options, signal, config, ctx) {
538
+ const startedAt = Date.now();
539
+ const indexPath = join(indexRoot, INDEX_RELATIVE_PATH);
540
+ const workspaceFilter = options.workspace;
541
+ const sessionFilter = options.sessionId;
542
+ const scoped = workspaceFilter !== undefined || sessionFilter !== undefined;
543
+ const inScope = (workspace, id) => matchesWorkspace(workspaceFilter, workspace) && matchesSession(sessionFilter, id);
544
+ // `auto` prefers the kernel and falls back silently; `native` asks for it explicitly, so a machine
545
+ // without a usable build is told why it got TypeScript results instead.
546
+ const native = resolveNativeEngine(config.engine);
547
+ if (native !== undefined) {
548
+ const nativeReport = await runNativeEngine(native, indexPath, options, config, inScope, ctx, signal, startedAt);
549
+ if (nativeReport !== undefined)
550
+ return nativeReport;
551
+ }
552
+ else if (config.engine === 'native') {
553
+ console.error('sessiongraph-sessions: the native index engine is unavailable on this machine; ' +
554
+ 'falling back to the TypeScript engine');
555
+ }
556
+ const files = await scanSessions(config.sessionsRoot);
557
+ signal?.throwIfAborted();
558
+ const previous = readIndexState(indexPath);
559
+ // A missing or unusable previous index is rebuilt through the temporary file too, so a corrupt or
560
+ // schema-mismatched file is replaced atomically instead of being written into.
561
+ const force = options.force === true || previous === undefined;
562
+ const failed = [];
563
+ const candidates = [];
564
+ const seen = new Set();
565
+ let added = 0;
566
+ let updated = 0;
567
+ let skipped = 0;
568
+ for (const file of files) {
569
+ seen.add(file.filePath);
570
+ const existing = previous?.get(file.filePath);
571
+ const unchanged = existing !== undefined && existing.mtime === file.mtime && existing.size === file.size;
572
+ if (unchanged && !force) {
573
+ if (scoped && !inScope(existing.workspace, existing.id))
574
+ continue;
575
+ skipped += 1;
576
+ continue;
577
+ }
578
+ if (file.size > config.maxFileBytes) {
579
+ failed.push({
580
+ path: file.filePath,
581
+ reason: `session log is ${file.size} bytes, over maxFileBytes (${config.maxFileBytes})`,
582
+ });
583
+ continue;
584
+ }
585
+ signal?.throwIfAborted();
586
+ let session;
587
+ try {
588
+ const buffer = await readFile(file.filePath);
589
+ session = parseSessionLog(buffer, basename(file.sessionDir), {
590
+ maxEntryChars: config.maxEntryChars,
591
+ toolCallChars: config.toolCallChars,
592
+ });
593
+ }
594
+ catch (error) {
595
+ failed.push({ path: file.filePath, reason: describeError(error) });
596
+ continue;
597
+ }
598
+ if (scoped && !inScope(session.cwd, session.id))
599
+ continue;
600
+ candidates.push({ session, sourcePath: file.filePath, mtime: file.mtime, size: file.size });
601
+ if (existing === undefined)
602
+ added += 1;
603
+ else
604
+ updated += 1;
605
+ }
606
+ // A scoped run only removes what it can see: rows outside the requested workspace or session are
607
+ // kept, because "not in this scan" says nothing about a source the caller excluded on purpose.
608
+ const removed = [];
609
+ for (const [sourcePath, row] of previous ?? []) {
610
+ // Live rows carry a synthetic source path and are rebuilt from the live view every run; a file
611
+ // scan saying nothing about them is not evidence that their session disappeared.
612
+ if (sourcePath.startsWith(LIVE_SOURCE_PREFIX))
613
+ continue;
614
+ if (seen.has(sourcePath))
615
+ continue;
616
+ if (scoped && !inScope(row.workspace, row.id))
617
+ continue;
618
+ removed.push(row.id);
619
+ }
620
+ signal?.throwIfAborted();
621
+ const indexedAt = Date.now();
622
+ const outcome = writeIndex({
623
+ indexPath,
624
+ force,
625
+ preserveExisting: force && scoped && previous !== undefined,
626
+ sessions: candidates,
627
+ removed,
628
+ discovered: files.length,
629
+ failed: failed.length,
630
+ indexedAt,
631
+ });
632
+ const live = await runLivePass(ctx, indexPath, config, inScope, {
633
+ discovered: files.length,
634
+ failed: failed.length,
635
+ indexedAt,
636
+ });
637
+ return {
638
+ indexPath,
639
+ added,
640
+ updated,
641
+ skipped,
642
+ removed: removed.length,
643
+ liveSessions: live.liveSessions,
644
+ liveSkipped: live.liveSkipped,
645
+ discovered: files.length,
646
+ failed,
647
+ entries: live.totals?.entries ?? outcome.entries,
648
+ sessions: live.totals?.sessions ?? outcome.sessions,
649
+ workspaces: live.totals?.workspaces ?? outcome.workspaces,
650
+ bytes: live.totals?.bytes ?? outcome.bytes,
651
+ elapsedMs: Date.now() - startedAt,
652
+ engine: 'typescript',
653
+ };
654
+ }
655
+ /**
656
+ * Whether a session's workspace matches an optional workspace filter.
657
+ *
658
+ * An exact match always counts. A value without a trailing separator is also a path prefix, which is
659
+ * how one indexed root serves the whole `~/www` subtree; a value with the trailing separator is the
660
+ * exact directory only.
661
+ * @param filter - configured workspace filter, or undefined for "every workspace".
662
+ * @param workspace - the session's working directory.
663
+ * @returns whether the session is in scope.
664
+ */
665
+ export function matchesWorkspace(filter, workspace) {
666
+ if (filter === undefined)
667
+ return true;
668
+ if (filter.endsWith('/'))
669
+ return workspace === filter.slice(0, -1);
670
+ return workspace === filter || workspace.startsWith(filter);
671
+ }
672
+ /**
673
+ * Whether a session id matches an optional id filter (full id or any prefix).
674
+ * @param filter - configured session filter, or undefined for "every session".
675
+ * @param id - the session's id.
676
+ * @returns whether the session is in scope.
677
+ */
678
+ export function matchesSession(filter, id) {
679
+ if (filter === undefined)
680
+ return true;
681
+ return id === filter || id.startsWith(filter);
682
+ }
683
+ /**
684
+ * Bound an error to a readable one-line reason for the report.
685
+ * @param error - the caught value.
686
+ * @returns a short message.
687
+ */
688
+ function describeError(error) {
689
+ const message = error instanceof Error ? error.message : String(error);
690
+ return message.length <= 300 ? message : `${message.slice(0, 300)}…`;
691
+ }
692
+ /**
693
+ * Whether a filesystem error means the path does not exist.
694
+ * @param error - the caught value.
695
+ * @returns true for ENOENT.
696
+ */
697
+ function isMissingPath(error) {
698
+ return typeof error === 'object' && error !== null && error.code === 'ENOENT';
699
+ }
700
+ /** Reject a non-positive-integer config value at load, so misconfiguration fails loud. */
701
+ function assertPositiveInteger(field, value) {
702
+ if (!Number.isInteger(value) || value < 1) {
703
+ throw new Error(`sessiongraph-sessions: ${field} must be a positive integer`);
704
+ }
705
+ }
706
+ //# sourceMappingURL=index.js.map