@1agents/session-reader 0.1.1

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.
Files changed (56) hide show
  1. package/README.md +347 -0
  2. package/dist/bin/1session.d.ts +2 -0
  3. package/dist/bin/1session.js +408 -0
  4. package/dist/src/aggregator.d.ts +12 -0
  5. package/dist/src/aggregator.js +136 -0
  6. package/dist/src/classify.d.ts +2 -0
  7. package/dist/src/classify.js +11 -0
  8. package/dist/src/distiller.d.ts +7 -0
  9. package/dist/src/distiller.js +122 -0
  10. package/dist/src/index.d.ts +19 -0
  11. package/dist/src/index.js +17 -0
  12. package/dist/src/ledger.d.ts +19 -0
  13. package/dist/src/ledger.js +192 -0
  14. package/dist/src/overview.d.ts +6 -0
  15. package/dist/src/overview.js +324 -0
  16. package/dist/src/parsers/antigravity.d.ts +4 -0
  17. package/dist/src/parsers/antigravity.js +297 -0
  18. package/dist/src/parsers/claude.d.ts +2 -0
  19. package/dist/src/parsers/claude.js +214 -0
  20. package/dist/src/parsers/codex.d.ts +2 -0
  21. package/dist/src/parsers/codex.js +316 -0
  22. package/dist/src/parsers/provider.d.ts +24 -0
  23. package/dist/src/parsers/provider.js +5 -0
  24. package/dist/src/resolver.d.ts +42 -0
  25. package/dist/src/resolver.js +145 -0
  26. package/dist/src/search.d.ts +59 -0
  27. package/dist/src/search.js +257 -0
  28. package/dist/src/store/db.d.ts +10 -0
  29. package/dist/src/store/db.js +89 -0
  30. package/dist/src/store/edges.d.ts +46 -0
  31. package/dist/src/store/edges.js +153 -0
  32. package/dist/src/store/facts.d.ts +8 -0
  33. package/dist/src/store/facts.js +39 -0
  34. package/dist/src/store/indexer.d.ts +40 -0
  35. package/dist/src/store/indexer.js +62 -0
  36. package/dist/src/store/read.d.ts +29 -0
  37. package/dist/src/store/read.js +70 -0
  38. package/dist/src/store/rows.d.ts +35 -0
  39. package/dist/src/store/rows.js +55 -0
  40. package/dist/src/store/schema.d.ts +19 -0
  41. package/dist/src/store/schema.js +142 -0
  42. package/dist/src/store/write.d.ts +22 -0
  43. package/dist/src/store/write.js +72 -0
  44. package/dist/src/turns.d.ts +14 -0
  45. package/dist/src/turns.js +156 -0
  46. package/dist/src/types.d.ts +294 -0
  47. package/dist/src/types.js +13 -0
  48. package/dist/src/util/jsonl.d.ts +6 -0
  49. package/dist/src/util/jsonl.js +32 -0
  50. package/dist/src/util/paths.d.ts +15 -0
  51. package/dist/src/util/paths.js +69 -0
  52. package/dist/src/util/text.d.ts +7 -0
  53. package/dist/src/util/text.js +48 -0
  54. package/dist/src/writes.d.ts +35 -0
  55. package/dist/src/writes.js +204 -0
  56. package/package.json +58 -0
@@ -0,0 +1,145 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { antigravityAdapter } from './parsers/antigravity.js';
4
+ import { claudeAdapter } from './parsers/claude.js';
5
+ import { codexAdapter } from './parsers/codex.js';
6
+ import { canonicalizePath, isInside, slugifyWorkspace } from './util/paths.js';
7
+ export const adapters = [antigravityAdapter, claudeAdapter, codexAdapter];
8
+ /** How many files we are willing to open when nothing narrows the search. */
9
+ const DEFAULT_SCAN = 60;
10
+ /** A workspace filter rejects most candidates, so it needs a wider net. */
11
+ const WORKSPACE_SCAN = 600;
12
+ /** Accepts `24h`, `90m`, `7d`, `2026-09-01` or a Date. */
13
+ export function parseSince(since) {
14
+ if (!since)
15
+ return undefined;
16
+ if (since instanceof Date)
17
+ return since.getTime();
18
+ const relative = /^(\d+)\s*([hdmw])$/i.exec(since.trim());
19
+ if (relative) {
20
+ const amount = Number(relative[1]);
21
+ const unit = relative[2].toLowerCase();
22
+ const ms = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[unit] ?? 0;
23
+ return Date.now() - amount * ms;
24
+ }
25
+ const parsed = Date.parse(since);
26
+ return Number.isNaN(parsed) ? undefined : parsed;
27
+ }
28
+ function matchesWorkspace(ref, workspace) {
29
+ if (!ref.workspace)
30
+ return false;
31
+ return isInside(workspace, ref.workspace);
32
+ }
33
+ /**
34
+ * Claude stores sessions under a slugified cwd, so the directory name alone
35
+ * tells us whether a file can possibly belong to the workspace.
36
+ */
37
+ function couldBelong(candidate, provider, workspace) {
38
+ if (provider !== 'claude')
39
+ return true;
40
+ const slug = slugifyWorkspace(workspace);
41
+ const dir = path.basename(path.dirname(candidate.path));
42
+ return dir === slug || dir.startsWith(`${slug}-`);
43
+ }
44
+ /** Discovery that keeps the adapter handle, so callers can parse without a second scan. */
45
+ export async function listResolvedSessions(options = {}) {
46
+ const limit = options.limit ?? 20;
47
+ const workspace = options.workspace ? canonicalizePath(options.workspace) : undefined;
48
+ const sinceMs = parseSince(options.since);
49
+ const found = [];
50
+ for (const adapter of adapters) {
51
+ if (options.provider && adapter.provider !== options.provider)
52
+ continue;
53
+ const candidates = await adapter.listCandidates();
54
+ let scanned = 0;
55
+ const budget = options.scan ?? (workspace ? WORKSPACE_SCAN : DEFAULT_SCAN);
56
+ for (const candidate of candidates) {
57
+ if (sinceMs && candidate.mtimeMs < sinceMs)
58
+ break; // candidates are newest first
59
+ if (scanned >= budget)
60
+ break;
61
+ if (workspace && !couldBelong(candidate, adapter.provider, workspace))
62
+ continue;
63
+ scanned++;
64
+ const ref = await adapter.scanRef(candidate).catch(() => undefined);
65
+ if (!ref)
66
+ continue;
67
+ if (workspace && !matchesWorkspace(ref, workspace))
68
+ continue;
69
+ found.push({ ref, adapter, candidate });
70
+ }
71
+ }
72
+ return found
73
+ .sort((a, b) => Date.parse(b.ref.updatedAt ?? '') - Date.parse(a.ref.updatedAt ?? ''))
74
+ .slice(0, limit);
75
+ }
76
+ export async function listRecentSessions(options = {}) {
77
+ return (await listResolvedSessions(options)).map((session) => session.ref);
78
+ }
79
+ export async function findSessionsByWorkspace(workspacePath, options = {}) {
80
+ return listRecentSessions({ ...options, limit: options.limit ?? 50, workspace: workspacePath });
81
+ }
82
+ export async function findResolvedByWorkspace(workspacePath, options = {}) {
83
+ return listResolvedSessions({ ...options, limit: options.limit ?? 50, workspace: workspacePath });
84
+ }
85
+ /** Locates a session by full id, id prefix, or native file path. */
86
+ export async function resolveSession(sessionId) {
87
+ const asPath = sessionId.includes('/') ? canonicalizePath(sessionId) : undefined;
88
+ const needle = sessionId.toLowerCase();
89
+ let prefixHit;
90
+ for (const adapter of adapters) {
91
+ for (const candidate of await adapter.listCandidates()) {
92
+ if (asPath ? candidate.path === asPath : candidate.id.toLowerCase() === needle) {
93
+ return { ref: await adapter.scanRef(candidate), adapter, candidate };
94
+ }
95
+ if (!asPath && !prefixHit && needle.length >= 6 && candidate.id.toLowerCase().startsWith(needle)) {
96
+ prefixHit = { adapter, candidate };
97
+ }
98
+ }
99
+ }
100
+ if (!prefixHit)
101
+ return undefined;
102
+ return { ...prefixHit, ref: await prefixHit.adapter.scanRef(prefixHit.candidate) };
103
+ }
104
+ export async function parseSession(sessionId) {
105
+ const resolved = await resolveSession(sessionId);
106
+ if (!resolved)
107
+ throw new Error(`session not found: ${sessionId}`);
108
+ return resolved.adapter.parse(resolved.candidate);
109
+ }
110
+ /**
111
+ * The one seam every command loads sessions through. Whether the events come
112
+ * from the index or straight off disk, the object handed back is the same, so
113
+ * `buildOverview` / `fileLedger` / `summarizeTurns` never learn about caching.
114
+ */
115
+ export async function loadSession(sessionId, options = {}) {
116
+ if (options.useIndex === false || process.env.SESSION_READER_NO_INDEX === '1') {
117
+ return parseSession(sessionId);
118
+ }
119
+ const { openStore } = await import('./store/db.js');
120
+ const { findSessionRow } = await import('./store/read.js');
121
+ const { indexSession } = await import('./store/indexer.js');
122
+ const db = await openStore();
123
+ // A session the index already knows can be re-checked with a single stat,
124
+ // instead of walking every provider directory again.
125
+ const row = findSessionRow(db, sessionId);
126
+ const handle = (row ? await handleFromPath(row.provider, row.native_id, row.source_path) : undefined)
127
+ ?? (await resolveSession(sessionId));
128
+ if (!handle)
129
+ throw new Error(`session not found: ${sessionId}`);
130
+ const result = await indexSession(db, handle, { ...(options.force ? { force: true } : {}) });
131
+ return result.session;
132
+ }
133
+ /** Rebuilds an adapter handle from an indexed row without a directory scan. */
134
+ async function handleFromPath(provider, nativeId, sourcePath) {
135
+ const adapter = adapters.find((item) => item.provider === provider);
136
+ if (!adapter)
137
+ return undefined;
138
+ const stat = await fs.stat(sourcePath).catch(() => undefined);
139
+ if (!stat)
140
+ return undefined;
141
+ return {
142
+ adapter,
143
+ candidate: { id: nativeId, path: sourcePath, mtimeMs: stat.mtimeMs, sizeBytes: stat.size },
144
+ };
145
+ }
@@ -0,0 +1,59 @@
1
+ import { type ListOptions } from './resolver.js';
2
+ import type { SessionRef, TurnKind } from './types.js';
3
+ export interface SearchOptions extends ListOptions {
4
+ /** Bypass the index and parse every candidate from disk. */
5
+ useIndex?: boolean;
6
+ /** Restrict to these turn kinds; defaults to all of them. */
7
+ kinds?: TurnKind[];
8
+ /** Treat the query as a regular expression instead of a literal. */
9
+ regex?: boolean;
10
+ caseSensitive?: boolean;
11
+ /** Characters of context kept around each match. */
12
+ context?: number;
13
+ /** Matches recorded per session before scanning moves on. */
14
+ maxPerSession?: number;
15
+ }
16
+ export interface SearchMatch {
17
+ index: number;
18
+ kind: TurnKind;
19
+ toolName?: string;
20
+ timestamp?: string;
21
+ excerpt: string;
22
+ }
23
+ export interface SearchHit {
24
+ session: SessionRef;
25
+ matches: SearchMatch[];
26
+ totalMatches: number;
27
+ }
28
+ /**
29
+ * A substring that every string matching `source` must contain, or `undefined`
30
+ * when no such substring can be proven.
31
+ *
32
+ * Deliberately timid: alternation or groups anywhere and it gives up. A
33
+ * prefilter that is merely usually right would hand back "searched everything"
34
+ * answers that quietly missed rows — the exact failure this store exists to
35
+ * remove — so "no literal" is always the safe reply.
36
+ */
37
+ export declare function mandatoryLiteral(source: string): string | undefined;
38
+ export interface QueryPlan {
39
+ literal?: string;
40
+ fold?: boolean;
41
+ }
42
+ /**
43
+ * Turns a query into an optional SQL prefilter.
44
+ *
45
+ * Case folding has to agree with what the matcher does. A `gi` regex without
46
+ * the `u` flag folds ASCII only — exactly what SQLite's `lower()` does — so an
47
+ * ASCII literal can be folded on both sides. Anything else falls back to the
48
+ * longest run of characters that have no case at all, where folding is a no-op
49
+ * either way.
50
+ */
51
+ export declare function planQuery(query: string, options?: SearchOptions): QueryPlan;
52
+ /**
53
+ * Full-text search across sessions.
54
+ *
55
+ * Scope is never silently trimmed: every session passing `workspace` /
56
+ * `since` / `provider` is searched, and `limit` only caps how many of the
57
+ * resulting hits come back.
58
+ */
59
+ export declare function searchSessions(query: string, options?: SearchOptions): Promise<SearchHit[]>;
@@ -0,0 +1,257 @@
1
+ import { adapters, listResolvedSessions, parseSince } from './resolver.js';
2
+ import { canonicalizePath, isInside } from './util/paths.js';
3
+ const DEFAULT_CONTEXT = 100;
4
+ const DEFAULT_MAX_PER_SESSION = 5;
5
+ const HARD_CAP = 200;
6
+ function buildPattern(query, options) {
7
+ const source = options.regex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8
+ return new RegExp(source, options.caseSensitive ? 'g' : 'gi');
9
+ }
10
+ /** Everything in a turn that is worth matching against, as one flat string. */
11
+ function haystack(turn) {
12
+ const args = turn.toolArgs ? JSON.stringify(turn.toolArgs) : '';
13
+ return [turn.text, turn.toolResult, args].filter(Boolean).join('\n');
14
+ }
15
+ /** Slices around the match first, then collapses whitespace, so offsets stay valid. */
16
+ function excerpt(body, at, length, context) {
17
+ const start = Math.max(0, at - context);
18
+ const end = Math.min(body.length, at + length + context);
19
+ const slice = body.slice(start, end).replace(/\s+/g, ' ').trim();
20
+ return `${start > 0 ? '…' : ''}${slice}${end < body.length ? '…' : ''}`;
21
+ }
22
+ // ---------------------------------------------------------------------------
23
+ // Query planning
24
+ // ---------------------------------------------------------------------------
25
+ /** Escapes that stand for a class of characters rather than one literal one. */
26
+ const CLASS_ESCAPES = new Set('dDwWsSbBnrtfv0xucpPk'.split(''));
27
+ /**
28
+ * A substring that every string matching `source` must contain, or `undefined`
29
+ * when no such substring can be proven.
30
+ *
31
+ * Deliberately timid: alternation or groups anywhere and it gives up. A
32
+ * prefilter that is merely usually right would hand back "searched everything"
33
+ * answers that quietly missed rows — the exact failure this store exists to
34
+ * remove — so "no literal" is always the safe reply.
35
+ */
36
+ export function mandatoryLiteral(source) {
37
+ if (/[|()]/.test(source))
38
+ return undefined;
39
+ let best = '';
40
+ let run = '';
41
+ const flush = () => {
42
+ if (run.length > best.length)
43
+ best = run;
44
+ run = '';
45
+ };
46
+ for (let i = 0; i < source.length; i++) {
47
+ const ch = source[i];
48
+ if (ch === '\\') {
49
+ const next = source[++i];
50
+ if (next === undefined)
51
+ break;
52
+ if (CLASS_ESCAPES.has(next))
53
+ flush();
54
+ else
55
+ run += next; // an escaped literal character
56
+ continue;
57
+ }
58
+ if (ch === '[') {
59
+ flush();
60
+ while (i < source.length && source[i] !== ']')
61
+ i += source[i] === '\\' ? 2 : 1;
62
+ continue;
63
+ }
64
+ // `?`, `*` and `{…}` can make the preceding character disappear, so it
65
+ // stops being mandatory. `+` keeps it (one occurrence at least).
66
+ if (ch === '?' || ch === '*') {
67
+ run = run.slice(0, -1);
68
+ flush();
69
+ continue;
70
+ }
71
+ if (ch === '{') {
72
+ run = run.slice(0, -1);
73
+ flush();
74
+ while (i < source.length && source[i] !== '}')
75
+ i++;
76
+ continue;
77
+ }
78
+ if (ch === '+' || ch === '.' || ch === '^' || ch === '$') {
79
+ flush();
80
+ continue;
81
+ }
82
+ run += ch;
83
+ }
84
+ flush();
85
+ return best.length >= 2 ? best : undefined;
86
+ }
87
+ /** True when a character is unaffected by case folding (CJK, digits, punctuation). */
88
+ function caseless(ch) {
89
+ return ch.toLowerCase() === ch && ch.toUpperCase() === ch;
90
+ }
91
+ function longestCaselessRun(value) {
92
+ let best = '';
93
+ let run = '';
94
+ for (const ch of value) {
95
+ if (caseless(ch)) {
96
+ run += ch;
97
+ if (run.length > best.length)
98
+ best = run;
99
+ }
100
+ else {
101
+ run = '';
102
+ }
103
+ }
104
+ return best;
105
+ }
106
+ /**
107
+ * Turns a query into an optional SQL prefilter.
108
+ *
109
+ * Case folding has to agree with what the matcher does. A `gi` regex without
110
+ * the `u` flag folds ASCII only — exactly what SQLite's `lower()` does — so an
111
+ * ASCII literal can be folded on both sides. Anything else falls back to the
112
+ * longest run of characters that have no case at all, where folding is a no-op
113
+ * either way.
114
+ */
115
+ export function planQuery(query, options = {}) {
116
+ const raw = options.regex ? mandatoryLiteral(query) : query;
117
+ // A literal spanning a newline could straddle two columns, which the
118
+ // per-column prefilter would miss.
119
+ if (!raw || raw.length < 2 || raw.includes('\n'))
120
+ return {};
121
+ if (options.caseSensitive)
122
+ return { literal: raw };
123
+ // eslint-disable-next-line no-control-regex
124
+ if (/^[\x00-\x7F]*$/.test(raw))
125
+ return { literal: raw.toLowerCase(), fold: true };
126
+ const run = longestCaselessRun(raw);
127
+ return run.length >= 2 ? { literal: run } : {};
128
+ }
129
+ function accumulate(hits, sessionId, candidate, pattern, context, maxPerSession) {
130
+ if (!candidate.body)
131
+ return;
132
+ pattern.lastIndex = 0;
133
+ const found = pattern.exec(candidate.body);
134
+ if (!found)
135
+ return;
136
+ const bucket = hits.get(sessionId) ?? { matches: [], totalMatches: 0 };
137
+ bucket.totalMatches++;
138
+ if (bucket.matches.length < Math.min(maxPerSession, HARD_CAP)) {
139
+ bucket.matches.push({
140
+ index: candidate.index,
141
+ kind: candidate.kind,
142
+ ...(candidate.toolName ? { toolName: candidate.toolName } : {}),
143
+ ...(candidate.timestamp ? { timestamp: candidate.timestamp } : {}),
144
+ excerpt: excerpt(candidate.body, found.index, found[0].length, context),
145
+ });
146
+ }
147
+ hits.set(sessionId, bucket);
148
+ }
149
+ /**
150
+ * Full-text search across sessions.
151
+ *
152
+ * Scope is never silently trimmed: every session passing `workspace` /
153
+ * `since` / `provider` is searched, and `limit` only caps how many of the
154
+ * resulting hits come back.
155
+ */
156
+ export async function searchSessions(query, options = {}) {
157
+ if (!query.trim())
158
+ throw new Error('search query must not be empty');
159
+ const pattern = buildPattern(query, options);
160
+ const context = options.context ?? DEFAULT_CONTEXT;
161
+ const maxPerSession = options.maxPerSession ?? DEFAULT_MAX_PER_SESSION;
162
+ const direct = options.useIndex === false || process.env.SESSION_READER_NO_INDEX === '1';
163
+ const hits = direct
164
+ ? await searchByParsing(query, options, pattern, context, maxPerSession)
165
+ : await searchByIndex(query, options, pattern, context, maxPerSession);
166
+ return hits
167
+ .sort((a, b) => Date.parse(b.session.updatedAt ?? '') - Date.parse(a.session.updatedAt ?? ''))
168
+ .slice(0, options.limit ?? hits.length);
169
+ }
170
+ /** The oracle: parses every candidate from disk, touching no stored state. */
171
+ async function searchByParsing(_query, options, pattern, context, maxPerSession) {
172
+ const kinds = options.kinds?.length ? new Set(options.kinds) : undefined;
173
+ const hits = [];
174
+ const handles = await listResolvedSessions({
175
+ ...options,
176
+ limit: Number.POSITIVE_INFINITY,
177
+ scan: Number.POSITIVE_INFINITY,
178
+ });
179
+ for (const handle of handles) {
180
+ const session = await handle.adapter.parse(handle.candidate).catch(() => undefined);
181
+ if (!session)
182
+ continue;
183
+ const bucket = new Map();
184
+ for (const turn of session.turns) {
185
+ if (kinds && !kinds.has(turn.kind))
186
+ continue;
187
+ accumulate(bucket, session.ref.id, {
188
+ index: turn.index,
189
+ kind: turn.kind,
190
+ ...(turn.toolName ? { toolName: turn.toolName } : {}),
191
+ ...(turn.timestamp ? { timestamp: turn.timestamp } : {}),
192
+ body: haystack(turn),
193
+ }, pattern, context, maxPerSession);
194
+ }
195
+ const found = bucket.get(session.ref.id);
196
+ if (found)
197
+ hits.push({ session: session.ref, ...found });
198
+ }
199
+ return hits;
200
+ }
201
+ /**
202
+ * Index path: candidate rows come from SQL, the verdict stays with the regex.
203
+ *
204
+ * Sessions are never materialized — rebuilding 622 of them into objects cost
205
+ * more than every other part of a search put together.
206
+ */
207
+ async function searchByIndex(query, options, pattern, context, maxPerSession) {
208
+ const { openStore } = await import('./store/db.js');
209
+ const { refreshSession } = await import('./store/indexer.js');
210
+ const { searchRows } = await import('./store/rows.js');
211
+ const { refOf } = await import('./store/read.js');
212
+ const db = await openStore();
213
+ const sinceMs = parseSince(options.since);
214
+ const ids = [];
215
+ for (const adapter of adapters) {
216
+ if (options.provider && adapter.provider !== options.provider)
217
+ continue;
218
+ for (const candidate of await adapter.listCandidates()) {
219
+ if (sinceMs && candidate.mtimeMs < sinceMs)
220
+ break; // newest first
221
+ const result = await refreshSession(db, { adapter, candidate }, { edges: false }).catch(() => undefined);
222
+ if (result)
223
+ ids.push(result.id);
224
+ }
225
+ }
226
+ const plan = planQuery(query, options);
227
+ const workspace = options.workspace ? canonicalizePath(options.workspace) : undefined;
228
+ const buckets = new Map();
229
+ for (const row of searchRows(db, {
230
+ ids,
231
+ ...(workspace ? { workspace } : {}),
232
+ ...(options.kinds?.length ? { kinds: options.kinds } : {}),
233
+ ...(plan.literal ? { literal: plan.literal } : {}),
234
+ ...(plan.fold ? { fold: true } : {}),
235
+ })) {
236
+ const args = row.tool_args_json ?? '';
237
+ accumulate(buckets, row.session_id, {
238
+ index: row.idx,
239
+ kind: row.kind,
240
+ ...(row.tool_name ? { toolName: row.tool_name } : {}),
241
+ ...(row.ts ? { timestamp: row.ts } : {}),
242
+ // Rebuilt exactly as `haystack` does, so an excerpt cannot shift.
243
+ body: [row.text ?? undefined, row.tool_result ?? undefined, args].filter(Boolean).join('\n'),
244
+ }, pattern, context, maxPerSession);
245
+ }
246
+ const hits = [];
247
+ for (const [id, bucket] of buckets) {
248
+ const row = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
249
+ if (!row)
250
+ continue;
251
+ const ref = refOf(row);
252
+ if (workspace && !isInside(workspace, ref.workspace ?? ''))
253
+ continue;
254
+ hits.push({ session: ref, ...bucket });
255
+ }
256
+ return hits;
257
+ }
@@ -0,0 +1,10 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ /** Where the index lives. `SESSION_READER_DB` overrides it (tests, CI). */
3
+ export declare function defaultDbPath(): string;
4
+ /**
5
+ * Opens (and migrates) the index. The handle is cached per path so a single
6
+ * CLI run never opens the file twice.
7
+ */
8
+ export declare function openStore(dbPath?: string): Promise<DatabaseSync>;
9
+ /** Test hook — forgets the cached handle so a new path can be opened. */
10
+ export declare function resetStoreCache(): void;
@@ -0,0 +1,89 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { DDL, SCHEMA_VERSION } from './schema.js';
5
+ /** Where the index lives. `SESSION_READER_DB` overrides it (tests, CI). */
6
+ export function defaultDbPath() {
7
+ const override = process.env.SESSION_READER_DB;
8
+ if (override)
9
+ return override;
10
+ return path.join(os.homedir(), '.1agents', 'session-reader', 'index.db');
11
+ }
12
+ /**
13
+ * `node:sqlite` is still flagged experimental and prints a warning on load.
14
+ * Filtering just that one message keeps every other warning intact — far less
15
+ * rude than `removeAllListeners('warning')`.
16
+ */
17
+ async function importSqlite() {
18
+ const original = process.emitWarning;
19
+ process.emitWarning = ((warning, ...rest) => {
20
+ const text = typeof warning === 'string' ? warning : (warning?.message ?? '');
21
+ if (text.includes('SQLite is an experimental feature'))
22
+ return;
23
+ return original.call(process, warning, ...rest);
24
+ });
25
+ try {
26
+ return await import('node:sqlite');
27
+ }
28
+ finally {
29
+ process.emitWarning = original;
30
+ }
31
+ }
32
+ let cached;
33
+ let cachedPath;
34
+ /**
35
+ * Opens (and migrates) the index. The handle is cached per path so a single
36
+ * CLI run never opens the file twice.
37
+ */
38
+ export async function openStore(dbPath = defaultDbPath()) {
39
+ if (cached && cachedPath === dbPath)
40
+ return cached;
41
+ const { DatabaseSync: Sqlite } = await importSqlite();
42
+ if (dbPath !== ':memory:')
43
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
44
+ let db = new Sqlite(dbPath);
45
+ db.exec('PRAGMA journal_mode = WAL');
46
+ db.exec('PRAGMA synchronous = NORMAL');
47
+ db.exec('PRAGMA busy_timeout = 5000');
48
+ if (schemaVersionOf(db) !== SCHEMA_VERSION) {
49
+ db = rebuild(db, Sqlite, dbPath);
50
+ }
51
+ db.exec(DDL);
52
+ db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('schema_version', String(SCHEMA_VERSION));
53
+ cached = db;
54
+ cachedPath = dbPath;
55
+ return db;
56
+ }
57
+ function schemaVersionOf(db) {
58
+ try {
59
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
60
+ return row?.value ? Number(row.value) : undefined;
61
+ }
62
+ catch {
63
+ return undefined; // no meta table yet — a fresh file
64
+ }
65
+ }
66
+ /**
67
+ * A DDL change invalidates every derived row, and L0 can rebuild all of it, so
68
+ * starting from an empty file beats writing migration code for a cache.
69
+ */
70
+ function rebuild(db, Sqlite, dbPath) {
71
+ const hadTables = db.prepare("SELECT count(*) AS c FROM sqlite_master WHERE type = 'table'").get().c > 0;
72
+ if (!hadTables)
73
+ return db;
74
+ db.close();
75
+ if (dbPath !== ':memory:') {
76
+ for (const suffix of ['', '-wal', '-shm'])
77
+ fs.rmSync(`${dbPath}${suffix}`, { force: true });
78
+ }
79
+ const fresh = new Sqlite(dbPath);
80
+ fresh.exec('PRAGMA journal_mode = WAL');
81
+ fresh.exec('PRAGMA synchronous = NORMAL');
82
+ fresh.exec('PRAGMA busy_timeout = 5000');
83
+ return fresh;
84
+ }
85
+ /** Test hook — forgets the cached handle so a new path can be opened. */
86
+ export function resetStoreCache() {
87
+ cached = undefined;
88
+ cachedPath = undefined;
89
+ }
@@ -0,0 +1,46 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ import type { NormalizedSession, TurnEvent } from '../types.js';
3
+ /**
4
+ * How one session relates to another. Only the two we can prove today are
5
+ * emitted; the rest are reserved so the vocabulary does not get invented
6
+ * twice, and are never guessed from coincidence.
7
+ */
8
+ export type EdgeRelation = 'references' | 'handoff_from' | 'forked_from' | 'resumed_from' | 'sends_to';
9
+ export interface EdgeCandidate {
10
+ relation: EdgeRelation;
11
+ target: string;
12
+ eventIndex: number;
13
+ operation: string;
14
+ command: string;
15
+ timestamp?: string;
16
+ }
17
+ /** Reads the session-reader invocations out of one tool call. */
18
+ export declare function invocationsOf(event: TurnEvent): EdgeCandidate[];
19
+ /**
20
+ * Derives L3 for one session from its stored events. Targets that do not
21
+ * resolve to an indexed session are dropped — a dangling edge is worse than
22
+ * no edge.
23
+ */
24
+ export declare function deriveEdges(db: DatabaseSync, id: string, session: NormalizedSession): number;
25
+ /**
26
+ * Records an edge at the moment it happens, when the caller session is known.
27
+ * Injected by a hook or the ACP context as `SESSION_READER_CALLER_SESSION`;
28
+ * absent everywhere else, in which case nothing is written.
29
+ */
30
+ export declare function captureRuntimeEdge(db: DatabaseSync, verb: string, target: string): void;
31
+ export interface EdgeView {
32
+ from: string;
33
+ to: string;
34
+ relation: EdgeRelation;
35
+ evidenceCount: number;
36
+ firstSeenAt?: string;
37
+ lastSeenAt?: string;
38
+ direction: 'out' | 'in';
39
+ }
40
+ export declare function edgesOf(db: DatabaseSync, id: string): EdgeView[];
41
+ export declare function edgeEvidence(db: DatabaseSync, from: string, to: string, relation: string): {
42
+ eventIndex: number;
43
+ operation: string;
44
+ ts?: string;
45
+ extractor: string;
46
+ }[];