ai-hist 0.6.0 → 0.8.2

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 (58) hide show
  1. package/README.md +34 -254
  2. package/dist/cli.d.ts +3 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +248 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/cloud-client.d.ts +0 -1
  7. package/dist/cloud-client.d.ts.map +1 -1
  8. package/dist/cloud-client.js +0 -2
  9. package/dist/cloud-client.js.map +1 -1
  10. package/dist/index.d.ts +138 -309
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +258 -1191
  13. package/dist/index.js.map +1 -1
  14. package/dist/mcp-server.d.ts +0 -9
  15. package/dist/mcp-server.d.ts.map +1 -1
  16. package/dist/mcp-server.js +52 -790
  17. package/dist/mcp-server.js.map +1 -1
  18. package/package.json +14 -8
  19. package/dist/cloud-push.d.ts +0 -51
  20. package/dist/cloud-push.d.ts.map +0 -1
  21. package/dist/cloud-push.js +0 -113
  22. package/dist/cloud-push.js.map +0 -1
  23. package/dist/cloud-push.test.d.ts +0 -2
  24. package/dist/cloud-push.test.d.ts.map +0 -1
  25. package/dist/cloud-push.test.js +0 -82
  26. package/dist/cloud-push.test.js.map +0 -1
  27. package/dist/jsonl-sources.d.ts +0 -33
  28. package/dist/jsonl-sources.d.ts.map +0 -1
  29. package/dist/jsonl-sources.js +0 -351
  30. package/dist/jsonl-sources.js.map +0 -1
  31. package/dist/mcp-smoke.test.d.ts +0 -2
  32. package/dist/mcp-smoke.test.d.ts.map +0 -1
  33. package/dist/mcp-smoke.test.js +0 -574
  34. package/dist/mcp-smoke.test.js.map +0 -1
  35. package/dist/pair-client.d.ts +0 -41
  36. package/dist/pair-client.d.ts.map +0 -1
  37. package/dist/pair-client.js +0 -60
  38. package/dist/pair-client.js.map +0 -1
  39. package/dist/pair-client.test.d.ts +0 -2
  40. package/dist/pair-client.test.d.ts.map +0 -1
  41. package/dist/pair-client.test.js +0 -118
  42. package/dist/pair-client.test.js.map +0 -1
  43. package/dist/session-catalog.d.ts +0 -257
  44. package/dist/session-catalog.d.ts.map +0 -1
  45. package/dist/session-catalog.js +0 -340
  46. package/dist/session-catalog.js.map +0 -1
  47. package/dist/session-catalog.test.d.ts +0 -2
  48. package/dist/session-catalog.test.d.ts.map +0 -1
  49. package/dist/session-catalog.test.js +0 -710
  50. package/dist/session-catalog.test.js.map +0 -1
  51. package/dist/session-events.test.d.ts +0 -2
  52. package/dist/session-events.test.d.ts.map +0 -1
  53. package/dist/session-events.test.js +0 -177
  54. package/dist/session-events.test.js.map +0 -1
  55. package/dist/trajectory-sources.d.ts +0 -36
  56. package/dist/trajectory-sources.d.ts.map +0 -1
  57. package/dist/trajectory-sources.js +0 -201
  58. package/dist/trajectory-sources.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,1249 +1,316 @@
1
1
  /**
2
- * TypeScript SDK for reading the ai-hist SQLite database.
2
+ * RelayHistory's public TypeScript API.
3
3
  *
4
- * Backed by sql.js (WASM SQLite) so the package has zero native build
5
- * requirements — works in Electron, Node, and browser contexts without
6
- * needing electron-rebuild. The SDK reads the same ai-hist SQLite database
7
- * that `ai-hist sync` writes (default
8
- * `~/.local/share/ai-hist/ai-history.db`, or `$AI_HIST_DB`).
9
- *
10
- * Trade-off vs better-sqlite3: sql.js loads the whole DB file into
11
- * memory. Fine for the ai-hist scale (tens of thousands of rows, MBs
12
- * of data); revisit if anyone hits millions.
4
+ * Every production operation crosses one mandatory Node-API boundary into the
5
+ * Rust engine. This module owns only input defaults, object normalization,
6
+ * pagination ergonomics, and stable JavaScript errors.
13
7
  */
14
- import initSqlJs from 'sql.js';
15
- import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
16
- import { writeFileSync } from 'node:fs';
17
- import { execFile } from 'node:child_process';
18
8
  import { homedir } from 'node:os';
19
9
  import { join } from 'node:path';
20
- import { tmpdir } from 'node:os';
21
- import { promisify } from 'node:util';
22
- import { scanLocalSources, LOCAL_SOURCE_PATHS } from './jsonl-sources.js';
23
- import { scanLocalTrajectories, trajectoryRootDescription, } from './trajectory-sources.js';
24
- import { catalogSessionFromJson, } from './session-catalog.js';
25
- export { SESSION_CATALOG_CONTRACT_VERSION, DiscoveryError, discoverSessions, } from './session-catalog.js';
26
- const execFileAsync = promisify(execFile);
27
- /** Resolve the SQLite path that ai-hist writes to. */
28
- export function defaultDbPath() {
29
- const fromEnv = process.env.AI_HIST_DB;
30
- if (fromEnv && fromEnv.trim().length > 0)
31
- return fromEnv;
32
- return join(homedir(), '.local', 'share', 'ai-hist', 'ai-history.db');
33
- }
34
- function defaultOpenCodeDbPath() {
35
- return process.env.OPENCODE_DB && process.env.OPENCODE_DB.trim().length > 0
36
- ? process.env.OPENCODE_DB
37
- : join(homedir(), '.local', 'share', 'opencode', 'opencode.db');
38
- }
39
- let _sqlPromise = null;
40
- function getSqlJs() {
41
- if (!_sqlPromise) {
42
- _sqlPromise = initSqlJs();
10
+ export const NATIVE_CONTRACT_VERSION = 2;
11
+ export const SESSION_CATALOG_CONTRACT_VERSION = 1;
12
+ export class RelayHistoryError extends Error {
13
+ code;
14
+ constructor(message, code, options) {
15
+ super(message, options);
16
+ this.code = code;
17
+ this.name = new.target.name;
43
18
  }
44
- return _sqlPromise;
45
19
  }
46
- /**
47
- * Every non-key column of the session catalog, in the order the native schema
48
- * declares them. Used both to create the table and to backfill it column by
49
- * column on a database snapshot written before the catalog existed.
50
- */
51
- const SESSION_CATALOG_COLUMNS = [
52
- ['cwd', 'TEXT'],
53
- ['git_branch', 'TEXT'],
54
- ['first_activity_ms', 'INTEGER'],
55
- ['last_activity_ms', 'INTEGER'],
56
- ['last_assistant_text', 'TEXT'],
57
- ['raw_path', 'TEXT'],
58
- ['parser_version', 'INTEGER NOT NULL DEFAULT 1'],
59
- ['first_prompt', 'TEXT'],
60
- ['models_json', 'TEXT'],
61
- ['originator', 'TEXT'],
62
- ['agent_version', 'TEXT'],
63
- ['repo_url', 'TEXT'],
64
- ['initial_commit', 'TEXT'],
65
- ['workspace_roots_json', 'TEXT'],
66
- ['source_stamp', 'TEXT'],
67
- ['discovery_state', 'TEXT'],
68
- ];
69
- /** Run a statement whose only expected failure is "already there". */
70
- function tryRun(db, sql) {
71
- try {
72
- db.run(sql);
73
- }
74
- catch {
75
- /* column/index already exists, or the table shape rules it out */
76
- }
20
+ export class UnsupportedPlatformError extends RelayHistoryError {
77
21
  }
78
- /**
79
- * Ensure the in-memory copy has the current `sessions` shape.
80
- *
81
- * The catalog columns (`first_prompt`, `models_json`, `discovery_state`, …)
82
- * were added after 0.5.0, so a database file written by an older CLI has the
83
- * old nine-column table. `CREATE TABLE IF NOT EXISTS` would leave that table
84
- * untouched and every catalog read would fail with `no such column`, so each
85
- * column is also added best-effort with `ALTER TABLE` — the same trick used for
86
- * `history.git_branch` in `openAiHist`. The writes land only in the in-memory
87
- * copy sql.js holds; the file on disk is untouched and the missing columns
88
- * simply read as `NULL`.
89
- */
90
- function ensureSessionsSchema(db) {
91
- db.run(`CREATE TABLE IF NOT EXISTS sessions (
92
- session_id TEXT NOT NULL,
93
- source TEXT NOT NULL,
94
- cwd TEXT,
95
- git_branch TEXT,
96
- first_activity_ms INTEGER,
97
- last_activity_ms INTEGER,
98
- last_assistant_text TEXT,
99
- raw_path TEXT,
100
- parser_version INTEGER NOT NULL DEFAULT 1,
101
- first_prompt TEXT,
102
- models_json TEXT,
103
- originator TEXT,
104
- agent_version TEXT,
105
- repo_url TEXT,
106
- initial_commit TEXT,
107
- workspace_roots_json TEXT,
108
- source_stamp TEXT,
109
- discovery_state TEXT,
110
- PRIMARY KEY (session_id, source)
111
- )`);
112
- for (const [name, decl] of SESSION_CATALOG_COLUMNS) {
113
- tryRun(db, `ALTER TABLE sessions ADD COLUMN ${name} ${decl}`);
114
- }
115
- tryRun(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd)');
116
- tryRun(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_branch ON sessions(git_branch)');
117
- tryRun(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_last ON sessions(last_activity_ms DESC)');
118
- tryRun(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_source_last ON sessions(source, last_activity_ms DESC)');
119
- // The catalog's total order is (last_activity_ms DESC, source, session_id) —
120
- // recency alone ties constantly, since one discovery pass can stamp many
121
- // sessions with the same mtime-derived millisecond. These two carry the whole
122
- // ORDER BY, so the listing is answered without a sort here as it is natively.
123
- tryRun(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_recency ON sessions(last_activity_ms DESC, source, session_id)');
124
- tryRun(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_source_recency ON sessions(source, last_activity_ms DESC, session_id)');
125
- tryRun(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_raw_path ON sessions(source, raw_path)');
22
+ export class NativePackageMissingError extends RelayHistoryError {
126
23
  }
127
- /**
128
- * Mirror the discovery bookkeeping table.
129
- *
130
- * Shallow discovery records here that a given source was examined at a given
131
- * stamp and found not to be a session (a subagent sidecar, say), so a rescan
132
- * costs a primary-key lookup instead of a re-read. The SDK never reads it; the
133
- * table is created so an in-memory copy has the same shape as the file the
134
- * native tool writes.
135
- */
136
- function ensureDiscoverySkipsSchema(db) {
137
- db.run(`CREATE TABLE IF NOT EXISTS discovery_skips (
138
- source TEXT NOT NULL,
139
- locator TEXT NOT NULL,
140
- stamp TEXT NOT NULL,
141
- reason TEXT,
142
- updated_ms INTEGER,
143
- PRIMARY KEY (source, locator)
144
- )`);
24
+ export class NativeLoadError extends RelayHistoryError {
145
25
  }
146
- function ensureTrajectorySchema(db) {
147
- db.run(`CREATE TABLE IF NOT EXISTS trajectories (
148
- id TEXT PRIMARY KEY,
149
- version INTEGER,
150
- persona_id TEXT,
151
- project_id TEXT,
152
- task_title TEXT,
153
- task_description TEXT,
154
- status TEXT,
155
- started_at TEXT,
156
- completed_at TEXT,
157
- decisions_json TEXT NOT NULL,
158
- retrospective_json TEXT NOT NULL,
159
- search_text TEXT NOT NULL,
160
- path TEXT,
161
- updated_ms INTEGER NOT NULL,
162
- timestamp_ms INTEGER NOT NULL
163
- )`);
164
- db.run('CREATE INDEX IF NOT EXISTS idx_trajectories_timestamp ON trajectories(timestamp_ms DESC)');
165
- db.run('CREATE INDEX IF NOT EXISTS idx_trajectories_project ON trajectories(project_id)');
26
+ export class NativeContractMismatchError extends RelayHistoryError {
166
27
  }
167
- function ensureTagSchema(db) {
168
- db.run(`CREATE TABLE IF NOT EXISTS tags (
169
- id INTEGER PRIMARY KEY AUTOINCREMENT,
170
- name TEXT NOT NULL UNIQUE,
171
- display_name TEXT NOT NULL,
172
- color TEXT,
173
- created_ms INTEGER NOT NULL,
174
- updated_ms INTEGER NOT NULL
175
- )`);
176
- db.run(`CREATE TABLE IF NOT EXISTS session_tags (
177
- id INTEGER PRIMARY KEY AUTOINCREMENT,
178
- source TEXT NOT NULL,
179
- session_id TEXT NOT NULL,
180
- tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
181
- created_ms INTEGER NOT NULL,
182
- UNIQUE(source, session_id, tag_id)
183
- )`);
184
- db.run('CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name)');
185
- db.run('CREATE INDEX IF NOT EXISTS idx_session_tags_session ON session_tags(source, session_id)');
186
- db.run('CREATE INDEX IF NOT EXISTS idx_session_tags_tag ON session_tags(tag_id)');
28
+ export class DatabaseOpenError extends RelayHistoryError {
187
29
  }
188
- /**
189
- * Open an `AiHist` reader. Async because sql.js initializes its WASM
190
- * runtime lazily and the DB file is read asynchronously so the host
191
- * process's event loop isn't blocked.
192
- *
193
- * Each call snapshots the data; to pick up later writes, call `reload()`
194
- * (or open a fresh instance).
195
- */
196
- export async function openAiHist(opts = {}) {
197
- const dbPath = opts.dbPath ?? defaultDbPath();
198
- const fallback = opts.fallback ?? 'jsonl';
199
- const SQL = await getSqlJs();
200
- // Fast path: SQLite written by `ai-hist sync`.
201
- if (await pathExists(dbPath)) {
202
- const fileBuffer = await readFile(dbPath);
203
- const db = new SQL.Database(fileBuffer);
204
- ensureTrajectorySchema(db);
205
- ensureTagSchema(db);
206
- ensureSessionsSchema(db);
207
- ensureDiscoverySkipsSchema(db);
208
- // Add git_branch to history if missing (pre-handoff DBs lack this column).
209
- try {
210
- db.run('ALTER TABLE history ADD COLUMN git_branch TEXT');
211
- }
212
- catch { /* already exists */ }
213
- // Older database schemas don't contain `idx_history_session` or
214
- // `idx_history_timestamp`. Without them, listSessions degrades to
215
- // an O(sessions × rows) full table scan and freezes the WASM
216
- // single-threaded JS engine for tens of seconds on real-sized DBs.
217
- // The DB is opened read-only over a buffer, but sql.js still lets
218
- // us run `CREATE INDEX` against the in-memory copy — the index
219
- // lives only for this session's lifetime and is rebuilt each
220
- // `openAiHist` call. Fast (~30ms on 35K rows).
221
- db.run('CREATE INDEX IF NOT EXISTS idx_history_session ON history(session_id)');
222
- db.run('CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp_ms DESC)');
223
- return new AiHist(db, { kind: 'sqlite', path: dbPath }, { projectScope: opts.projectScope });
224
- }
225
- if (fallback === 'error') {
226
- throw new Error(`ai-hist database not found at ${dbPath}. Run \`ai-hist sync\` first ` +
227
- `(see https://github.com/AgentWorkforce/ai-hist).`);
228
- }
229
- // Fallback: scan local source files (Claude/Codex/Cursor/Grok) directly.
230
- // Uses TypeScript implementations of the source parsers. Yields control to
231
- // the event loop between sources so a
232
- // large local history doesn't freeze the host.
233
- const db = new SQL.Database();
234
- db.run(`CREATE TABLE history (
235
- id INTEGER PRIMARY KEY AUTOINCREMENT,
236
- source TEXT NOT NULL,
237
- session_id TEXT,
238
- project TEXT,
239
- prompt TEXT NOT NULL,
240
- timestamp_ms INTEGER NOT NULL,
241
- git_branch TEXT,
242
- UNIQUE(source, timestamp_ms, prompt)
243
- )`);
244
- db.run('CREATE INDEX idx_history_timestamp ON history (timestamp_ms DESC)');
245
- db.run('CREATE INDEX idx_history_session ON history (session_id)');
246
- ensureTrajectorySchema(db);
247
- ensureTagSchema(db);
248
- ensureSessionsSchema(db);
249
- ensureDiscoverySkipsSchema(db);
250
- // scanLocalSources is async with yields between sources so the event
251
- // loop stays responsive while we scan many MB of JSONL.
252
- const rows = await scanLocalSources();
253
- const openCodeRows = await scanOpenCode(SQL);
254
- const trajectories = await scanLocalTrajectories();
255
- const insert = db.prepare('INSERT OR IGNORE INTO history (source, session_id, project, prompt, timestamp_ms, git_branch) VALUES (?, ?, ?, ?, ?, ?)');
256
- const insertTrajectory = db.prepare(`INSERT OR REPLACE INTO trajectories
257
- (id, version, persona_id, project_id, task_title, task_description, status,
258
- started_at, completed_at, decisions_json, retrospective_json, search_text,
259
- path, updated_ms, timestamp_ms)
260
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
261
- try {
262
- db.exec('BEGIN');
263
- for (const row of rows) {
264
- insert.run([row.source, row.sessionId, row.project, row.prompt, row.timestampMs, row.gitBranch]);
265
- }
266
- for (const row of openCodeRows) {
267
- insert.run(['opencode', row.sessionId, row.project, row.prompt, row.timestampMs, null]);
268
- }
269
- for (const trajectory of trajectories) {
270
- insertTrajectory.run([
271
- trajectory.id,
272
- trajectory.version,
273
- trajectory.personaId,
274
- trajectory.projectId,
275
- trajectory.task.title,
276
- trajectory.task.description,
277
- trajectory.status,
278
- trajectory.startedAt,
279
- trajectory.completedAt,
280
- JSON.stringify(trajectory.decisions),
281
- JSON.stringify(trajectory.retrospective),
282
- trajectory.searchText,
283
- trajectory.path,
284
- trajectory.updatedMs,
285
- trajectory.timestampMs,
286
- ]);
287
- insert.run([
288
- 'trajectory',
289
- trajectory.id,
290
- trajectory.projectId,
291
- trajectory.searchText,
292
- trajectory.timestampMs,
293
- null,
294
- ]);
295
- }
296
- db.exec('COMMIT');
297
- }
298
- finally {
299
- insert.free();
300
- insertTrajectory.free();
301
- }
302
- const scannedPaths = `${LOCAL_SOURCE_PATHS.claude}, ${LOCAL_SOURCE_PATHS.codex}, ${LOCAL_SOURCE_PATHS.cursorRoot}, ${LOCAL_SOURCE_PATHS.grokSessionsRoot}, ${trajectoryRootDescription()}`;
303
- return new AiHist(db, { kind: 'jsonl', path: scannedPaths }, { projectScope: opts.projectScope });
30
+ export class InvalidArgumentError extends RelayHistoryError {
304
31
  }
305
- async function pathExists(p) {
306
- try {
307
- await stat(p);
308
- return true;
309
- }
310
- catch {
311
- return false;
312
- }
32
+ const SUPPORTED_PLATFORMS = new Set([
33
+ 'darwin-arm64', 'darwin-x64',
34
+ 'linux-arm64-gnu', 'linux-arm64-musl',
35
+ 'linux-x64-gnu', 'linux-x64-musl',
36
+ 'win32-x64-msvc',
37
+ ]);
38
+ function linuxLibc() {
39
+ const report = process.report?.getReport();
40
+ return report?.header?.glibcVersionRuntime ? 'gnu' : 'musl';
313
41
  }
314
- async function readSqliteSnapshot(dbPath) {
315
- const hasWal = await pathExists(`${dbPath}-wal`);
316
- const hasShm = await pathExists(`${dbPath}-shm`);
317
- if (!hasWal && !hasShm) {
318
- return readFile(dbPath);
319
- }
320
- const dir = await mkdtemp(join(tmpdir(), 'ai-hist-sqlite-snapshot-'));
321
- const snapshot = join(dir, 'snapshot.db');
322
- try {
323
- await execFileAsync('sqlite3', [dbPath, `.backup '${snapshot.replace(/'/g, "''")}'`], {
324
- timeout: 30_000,
325
- maxBuffer: 1024 * 1024,
326
- });
327
- return await readFile(snapshot);
328
- }
329
- finally {
330
- await rm(dir, { recursive: true, force: true });
42
+ export function runtimePlatform() {
43
+ if (process.platform === 'linux')
44
+ return `linux-${process.arch}-${linuxLibc()}`;
45
+ if (process.platform === 'win32')
46
+ return `win32-${process.arch}-msvc`;
47
+ return `${process.platform}-${process.arch}`;
48
+ }
49
+ let nativePromise = null;
50
+ export function validateNativeContract(actual) {
51
+ if (actual !== NATIVE_CONTRACT_VERSION) {
52
+ throw new NativeContractMismatchError(`ai-hist requires native contract ${NATIVE_CONTRACT_VERSION}, but ai-hist-native provides ${actual}. Reinstall matching versions.`, 'NATIVE_CONTRACT_MISMATCH');
331
53
  }
332
54
  }
333
- async function scanOpenCode(SQL) {
334
- const dbPath = defaultOpenCodeDbPath();
335
- if (!(await pathExists(dbPath)))
336
- return [];
337
- try {
338
- const fileBuffer = await readSqliteSnapshot(dbPath);
339
- const db = new SQL.Database(fileBuffer);
55
+ async function loadNative() {
56
+ if (nativePromise)
57
+ return nativePromise;
58
+ nativePromise = (async () => {
59
+ const platform = runtimePlatform();
60
+ if (!SUPPORTED_PLATFORMS.has(platform)) {
61
+ throw new UnsupportedPlatformError(`RelayHistory has no native build for ${platform}. Supported platforms: ${[...SUPPORTED_PLATFORMS].join(', ')}.`, 'UNSUPPORTED_PLATFORM');
62
+ }
63
+ let loaded;
340
64
  try {
341
- const rows = runQuery(db, `SELECT s.id AS session_id, s.directory AS project, p.data,
342
- COALESCE(p.time_created, m.time_created, s.time_created) AS timestamp_ms
343
- FROM part p
344
- JOIN message m ON m.id = p.message_id
345
- JOIN session s ON s.id = p.session_id
346
- WHERE json_extract(m.data, '$.role') = 'user'
347
- AND json_extract(p.data, '$.type') = 'text'
348
- ORDER BY p.time_created ASC`, []);
349
- const scanned = [];
350
- for (const row of rows) {
351
- const data = parseJson(row.data, {});
352
- const prompt = typeof data.text === 'string' ? data.text.trim() : '';
353
- if (!prompt)
354
- continue;
355
- scanned.push({
356
- sessionId: row.session_id,
357
- project: row.project,
358
- prompt,
359
- timestampMs: row.timestamp_ms ?? 0,
360
- });
65
+ // Kept as a variable so TypeScript does not require native build-time
66
+ // declarations; npm installs this mandatory production dependency.
67
+ const packageName = 'ai-hist-native';
68
+ loaded = await import(packageName);
69
+ }
70
+ catch (cause) {
71
+ const error = cause;
72
+ if (error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'MODULE_NOT_FOUND') {
73
+ throw new NativePackageMissingError(`RelayHistory supports ${platform}, but its native package is missing. Reinstall ai-hist with optional dependencies enabled.`, 'NATIVE_PACKAGE_MISSING', { cause });
361
74
  }
362
- return scanned;
75
+ throw new NativeLoadError(`RelayHistory's native package for ${platform} failed to load: ${error.message}`, 'NATIVE_LOAD_FAILED', { cause });
76
+ }
77
+ const binding = (loaded.default ?? loaded);
78
+ if (typeof binding.nativeContractVersion !== 'function') {
79
+ throw new NativeContractMismatchError('The installed ai-hist-native package does not expose a contract version. Reinstall matching ai-hist packages.', 'NATIVE_CONTRACT_MISMATCH');
80
+ }
81
+ validateNativeContract(binding.nativeContractVersion());
82
+ return binding;
83
+ })();
84
+ return nativePromise.catch((error) => {
85
+ nativePromise = null;
86
+ throw error;
87
+ });
88
+ }
89
+ function nativeMessage(error) {
90
+ const message = error instanceof Error ? error.message : String(error);
91
+ const match = message.match(/RELAYHISTORY_NATIVE::([A-Z_]+)::([\s\S]*)/);
92
+ return match ? { code: match[1], message: match[2] } : null;
93
+ }
94
+ async function nativeCall(call) {
95
+ try {
96
+ return await call(await loadNative());
97
+ }
98
+ catch (error) {
99
+ if (error instanceof RelayHistoryError)
100
+ throw error;
101
+ const native = nativeMessage(error);
102
+ if (!native)
103
+ throw new NativeLoadError(String(error), 'NATIVE_CALL_FAILED', { cause: error });
104
+ if (native.code === 'DATABASE_OPEN_FAILED') {
105
+ throw new DatabaseOpenError(native.message, native.code, { cause: error });
363
106
  }
364
- finally {
365
- db.close();
107
+ if (native.code === 'INVALID_ARGUMENT') {
108
+ throw new InvalidArgumentError(native.message, native.code, { cause: error });
366
109
  }
367
- }
368
- catch {
369
- return [];
110
+ throw new RelayHistoryError(native.message, native.code, { cause: error });
370
111
  }
371
112
  }
372
- function rowToEntry(row) {
373
- return {
374
- id: row.id,
375
- source: row.source,
376
- sessionId: row.session_id,
377
- project: row.project,
378
- prompt: row.prompt,
379
- timestampMs: row.timestamp_ms,
380
- gitBranch: row.git_branch ?? null,
381
- };
382
- }
383
- function parseJson(raw, fallback) {
384
- try {
385
- return JSON.parse(raw);
386
- }
387
- catch {
388
- return fallback;
389
- }
113
+ function nullableString(value) {
114
+ return typeof value === 'string' ? value : null;
390
115
  }
391
- function rowToTrajectory(row) {
116
+ function historyEntry(value) {
392
117
  return {
393
- id: row.id,
394
- version: row.version,
395
- personaId: row.persona_id,
396
- projectId: row.project_id,
397
- task: {
398
- title: row.task_title,
399
- description: row.task_description,
400
- },
401
- status: row.status,
402
- startedAt: row.started_at,
403
- completedAt: row.completed_at,
404
- decisions: parseJson(row.decisions_json, []),
405
- retrospective: parseJson(row.retrospective_json, {
406
- summary: null,
407
- approach: null,
408
- learnings: [],
409
- confidence: null,
410
- }),
411
- searchText: row.search_text,
412
- path: row.path,
413
- updatedMs: row.updated_ms,
414
- timestampMs: row.timestamp_ms,
118
+ id: Number(value.id),
119
+ source: String(value.source),
120
+ sessionId: nullableString(value.sessionId),
121
+ project: nullableString(value.project),
122
+ prompt: String(value.prompt),
123
+ timestampMs: Number(value.timestampMs),
415
124
  };
416
125
  }
417
- function normalizeProjectScope(project) {
418
- const trimmed = project?.trim();
419
- if (!trimmed)
420
- return undefined;
421
- return trimmed.replace(/[\\/]+$/, '') || trimmed;
422
- }
423
- function escapeLike(value) {
424
- return value.replace(/\|/g, '||').replace(/%/g, '|%').replace(/_/g, '|_');
425
- }
426
- function scopedPathClause(column, project) {
427
- const normalized = normalizeProjectScope(project) ?? project;
428
- const escaped = escapeLike(normalized);
429
- const slashChildPattern = normalized === '/' ? '/%' : `${escaped}/%`;
430
- const backslashChildPattern = normalized === '\\' ? '\\%' : `${escaped}\\%`;
126
+ function catalogCursor(value) {
127
+ if (!value || typeof value !== 'object')
128
+ return null;
129
+ const row = value;
431
130
  return {
432
- sql: `(${column} = ? OR ${column} LIKE ? ESCAPE '|' OR ${column} LIKE ? ESCAPE '|')`,
433
- params: [normalized, slashChildPattern, backslashChildPattern],
131
+ lastActivityMs: typeof row.lastActivityMs === 'number' ? row.lastActivityMs : null,
132
+ source: String(row.source),
133
+ sessionId: String(row.sessionId),
434
134
  };
435
135
  }
436
- function scopedTrajectoryClause(project) {
437
- const normalized = normalizeProjectScope(project) ?? project;
438
- const pathScope = scopedPathClause('path', normalized);
136
+ function catalogSession(value) {
439
137
  return {
440
- sql: `(project_id = ? OR ${pathScope.sql})`,
441
- params: [normalized, ...pathScope.params],
138
+ source: String(value.source),
139
+ sessionId: String(value.sessionId),
140
+ cwd: nullableString(value.cwd),
141
+ gitBranch: nullableString(value.gitBranch),
142
+ firstActivityMs: typeof value.firstActivityMs === 'number' ? value.firstActivityMs : null,
143
+ lastActivityMs: typeof value.lastActivityMs === 'number' ? value.lastActivityMs : null,
144
+ firstPrompt: nullableString(value.firstPrompt),
145
+ lastAssistantText: nullableString(value.lastAssistantText),
146
+ models: Array.isArray(value.models) ? value.models.map(String) : [],
147
+ originator: nullableString(value.originator),
148
+ agentVersion: nullableString(value.agentVersion),
149
+ repoUrl: nullableString(value.repoUrl),
150
+ initialCommit: nullableString(value.initialCommit),
151
+ workspaceRoots: Array.isArray(value.workspaceRoots) ? value.workspaceRoots.map(String) : [],
152
+ rawPath: nullableString(value.rawPath),
153
+ sourceStamp: nullableString(value.sourceStamp),
154
+ discoveryState: value.discoveryState === 'shallow' ? 'shallow' : 'full',
155
+ fromCache: value.fromCache === true,
442
156
  };
443
157
  }
444
- function appendProjectFilter(clauses, params, project, projectScope) {
445
- if (projectScope) {
446
- const scope = scopedPathClause('project', projectScope);
447
- clauses.push(scope.sql);
448
- params.push(...scope.params);
449
- }
450
- if (project) {
451
- clauses.push('project = ?');
452
- params.push(project);
453
- }
454
- }
455
- function normalizeTagName(tag) {
456
- return tag.trim().toLowerCase().replace(/\s+/g, ' ');
457
- }
458
- function appendTagFilter(clauses, params, tag, alias = 'history') {
459
- const normalized = tag ? normalizeTagName(tag) : '';
460
- if (!normalized)
461
- return;
462
- clauses.push(`EXISTS (
463
- SELECT 1 FROM session_tags st
464
- JOIN tags t ON t.id = st.tag_id
465
- WHERE st.source = ${alias}.source
466
- AND st.session_id = ${alias}.session_id
467
- AND t.name = ?
468
- )`);
469
- params.push(normalized);
470
- }
471
- function buildFilters(opts, projectScope) {
472
- const clauses = [];
473
- const params = [];
474
- if (opts.source) {
475
- clauses.push('source = ?');
476
- params.push(opts.source);
158
+ function assertCatalogContract(value) {
159
+ if (value !== SESSION_CATALOG_CONTRACT_VERSION) {
160
+ throw new NativeContractMismatchError(`ai-hist expects catalog contract ${SESSION_CATALOG_CONTRACT_VERSION}, but native returned ${value}.`, 'CATALOG_CONTRACT_MISMATCH');
477
161
  }
478
- appendProjectFilter(clauses, params, opts.project, projectScope);
479
- appendTagFilter(clauses, params, opts.tag, 'history');
480
- if (typeof opts.beforeMs === 'number') {
481
- clauses.push('timestamp_ms < ?');
482
- params.push(opts.beforeMs);
483
- }
484
- return {
485
- sql: clauses.length > 0 ? ` AND ${clauses.join(' AND ')}` : '',
486
- params,
487
- };
488
162
  }
489
- function tableExists(db, name) {
490
- return (runQuery(db, "SELECT COUNT(*) AS c FROM sqlite_master WHERE type = 'table' AND name = ?", [name])[0]?.c > 0);
491
- }
492
- function parseTokenJson(raw) {
493
- if (!raw)
163
+ function tokenUsage(raw) {
164
+ if (typeof raw !== 'string')
494
165
  return null;
495
166
  try {
496
167
  const parsed = JSON.parse(raw);
497
- return typeof parsed === 'object' && parsed !== null ? parsed : null;
168
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
498
169
  }
499
170
  catch {
500
171
  return null;
501
172
  }
502
173
  }
503
- function runQuery(db, sql, params) {
504
- const stmt = db.prepare(sql);
505
- try {
506
- stmt.bind(params);
507
- const rows = [];
508
- while (stmt.step()) {
509
- rows.push(stmt.getAsObject());
510
- }
511
- return rows;
512
- }
513
- finally {
514
- stmt.free();
515
- }
174
+ function sessionEvent(value) {
175
+ return {
176
+ id: Number(value.id), source: String(value.source), sessionId: String(value.sessionId),
177
+ project: nullableString(value.project), cwd: nullableString(value.cwd), gitBranch: nullableString(value.gitBranch),
178
+ messageId: nullableString(value.messageId), parentId: nullableString(value.parentId), tsMs: Number(value.tsMs),
179
+ role: String(value.role), kind: String(value.kind),
180
+ text: nullableString(value.text), model: nullableString(value.model), tokenUsage: tokenUsage(value.tokenJson),
181
+ eventUid: String(value.eventUid),
182
+ };
516
183
  }
517
- export class AiHist {
518
- db;
519
- _source;
520
- _projectScope;
521
- closed = false;
522
- /** @internal — use `openAiHist(...)` to construct. */
523
- constructor(db, source, opts = {}) {
524
- this.db = db;
525
- this._source = source;
526
- this._projectScope = normalizeProjectScope(opts.projectScope);
527
- }
528
- /**
529
- * Path the data came from. SQLite mode: the .db path. JSONL fallback
530
- * mode: a comma-separated list of the scanned source paths.
531
- */
532
- get dbPath() {
533
- return this._source.path;
534
- }
535
- /** Which data path was used: `'sqlite'` (on-disk SQLite database) or `'jsonl'` (fallback). */
536
- get sourceKind() {
537
- return this._source.kind;
538
- }
539
- /** Server/client-wide project scope applied to every read, if configured. */
540
- get projectScope() {
541
- return this._projectScope;
542
- }
543
- close() {
544
- if (this.closed)
545
- return;
546
- this.db.close();
547
- this.closed = true;
548
- }
549
- persistIfWritable() {
550
- if (this._source.kind !== 'sqlite')
551
- return;
552
- writeFileSync(this._source.path, Buffer.from(this.db.export()));
553
- }
554
- ensureTag(name, color) {
555
- const normalized = normalizeTagName(name);
556
- if (!normalized)
557
- throw new Error('tag name cannot be empty');
558
- const displayName = name.trim();
559
- const now = Date.now();
560
- this.db.run(`INSERT INTO tags (name, display_name, color, created_ms, updated_ms)
561
- VALUES (?, ?, ?, ?, ?)
562
- ON CONFLICT(name) DO UPDATE SET
563
- display_name = excluded.display_name,
564
- color = COALESCE(excluded.color, tags.color),
565
- updated_ms = excluded.updated_ms`, [normalized, displayName, color ?? null, now, now]);
566
- return runQuery(this.db, 'SELECT id FROM tags WHERE name = ?', [normalized])[0].id;
567
- }
568
- matchingSessions(sessionId, source) {
569
- const clauses = ['session_id = ?'];
570
- const params = [sessionId];
571
- if (source) {
572
- clauses.push('source = ?');
573
- params.push(source);
574
- }
575
- appendProjectFilter(clauses, params, undefined, this._projectScope);
576
- return runQuery(this.db, `SELECT source, session_id, MIN(project) AS project, COUNT(*) AS entry_count,
577
- MAX(timestamp_ms) AS last_activity_ms
578
- FROM history
579
- WHERE ${clauses.join(' AND ')}
580
- GROUP BY source, session_id
581
- ORDER BY source`, params).map((row) => ({
582
- source: row.source,
583
- sessionId: row.session_id,
584
- project: row.project,
585
- entryCount: row.entry_count,
586
- lastActivityMs: row.last_activity_ms,
587
- }));
588
- }
589
- tagSession(sessionId, tagName, opts = {}) {
590
- const sessions = this.matchingSessions(sessionId, opts.source);
591
- if (sessions.length === 0)
592
- return [];
593
- const tagId = this.ensureTag(tagName, opts.color);
594
- const now = Date.now();
595
- const insert = this.db.prepare('INSERT OR IGNORE INTO session_tags (source, session_id, tag_id, created_ms) VALUES (?, ?, ?, ?)');
596
- try {
597
- for (const session of sessions) {
598
- insert.run([session.source, session.sessionId, tagId, now]);
599
- }
600
- }
601
- finally {
602
- insert.free();
603
- }
604
- this.persistIfWritable();
605
- return sessions;
606
- }
607
- untagSession(sessionId, tagName, opts = {}) {
608
- const normalized = normalizeTagName(tagName);
609
- const sessions = this.matchingSessions(sessionId, opts.source);
610
- let removed = 0;
611
- for (const session of sessions) {
612
- this.db.run(`DELETE FROM session_tags
613
- WHERE source = ? AND session_id = ?
614
- AND tag_id IN (SELECT id FROM tags WHERE name = ?)`, [session.source, session.sessionId, normalized]);
615
- removed += this.db.getRowsModified();
616
- }
617
- this.persistIfWritable();
618
- return removed;
619
- }
620
- listTags(opts = {}) {
621
- const clauses = [];
622
- const params = [];
623
- if (opts.tag) {
624
- clauses.push('t.name = ?');
625
- params.push(normalizeTagName(opts.tag));
626
- }
627
- const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
628
- let scopedSessionSql = 'SELECT st.tag_id, st.id, st.created_ms FROM session_tags st';
629
- const scopedSessionParams = [];
630
- if (this._projectScope) {
631
- const scope = scopedPathClause('h.project', this._projectScope);
632
- scopedSessionSql = `SELECT st.tag_id, st.id, st.created_ms
633
- FROM session_tags st
634
- WHERE EXISTS (
635
- SELECT 1 FROM history h
636
- WHERE h.source = st.source
637
- AND h.session_id = st.session_id
638
- AND ${scope.sql}
639
- )`;
640
- scopedSessionParams.push(...scope.params);
641
- }
642
- return runQuery(this.db, `SELECT t.name, t.display_name, t.color, COUNT(st.id) AS session_count,
643
- MIN(st.created_ms) AS first_tagged_ms, MAX(st.created_ms) AS last_tagged_ms
644
- FROM tags t
645
- LEFT JOIN (${scopedSessionSql}) st ON st.tag_id = t.id
646
- ${where}
647
- GROUP BY t.id, t.name, t.display_name, t.color
648
- ORDER BY t.name`, [...scopedSessionParams, ...params]).map((row) => {
649
- const tag = {
650
- name: row.name,
651
- displayName: row.display_name,
652
- color: row.color,
653
- sessionCount: row.session_count,
654
- firstTaggedMs: row.first_tagged_ms,
655
- lastTaggedMs: row.last_tagged_ms,
656
- };
657
- if (opts.includeSessions) {
658
- tag.sessions = this.sessionsByTag(row.name);
659
- }
660
- return tag;
661
- });
662
- }
663
- sessionsByTag(tagName) {
664
- const clauses = ['t.name = ?'];
665
- const params = [normalizeTagName(tagName)];
666
- if (this._projectScope) {
667
- const scope = scopedPathClause('h.project', this._projectScope);
668
- clauses.push(scope.sql);
669
- params.push(...scope.params);
670
- }
671
- return runQuery(this.db, `SELECT st.source, st.session_id, MIN(h.project) AS project, COUNT(h.id) AS entry_count,
672
- MAX(h.timestamp_ms) AS last_activity_ms
673
- FROM session_tags st
674
- JOIN tags t ON t.id = st.tag_id
675
- JOIN history h ON h.source = st.source AND h.session_id = st.session_id
676
- WHERE ${clauses.join(' AND ')}
677
- GROUP BY st.source, st.session_id
678
- ORDER BY MAX(h.timestamp_ms) DESC`, params).map((row) => ({
679
- source: row.source,
680
- sessionId: row.session_id,
681
- project: row.project,
682
- entryCount: row.entry_count,
683
- lastActivityMs: row.last_activity_ms,
684
- }));
685
- }
686
- searchByTag(tagName, opts = {}) {
687
- return this.recent({ ...opts, tag: tagName });
688
- }
689
- /** Most recent prompts, newest first. */
690
- recent(opts = {}) {
691
- const limit = opts.limit ?? 50;
692
- const { sql, params } = buildFilters(opts, this._projectScope);
693
- return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
694
- FROM history
695
- WHERE 1=1${sql}
696
- ORDER BY timestamp_ms DESC
697
- LIMIT ?`, [...params, limit]).map(rowToEntry);
698
- }
699
- /**
700
- * Group history into sessions, ordered by last activity (newest first).
701
- * Sessions without a `session_id` are skipped.
702
- *
703
- * This derives sessions from the `history` table, so it only ever sees
704
- * sessions a full `ai-hist sync` has ingested, and it pays a window-function
705
- * pass over history to do it. For the fast and complete path — every session
706
- * the catalog knows, including ones only shallow discovery has seen, with
707
- * cwd, branch, models, originator and repo identity attached — use
708
- * {@link AiHist.listSessionCatalog}. This method stays as-is for callers
709
- * that want prompt counts and the project-derived grouping.
710
- *
711
- * Implementation note: this used to use a correlated scalar subquery
712
- * to pick `first_prompt`, which ran in O(sessions × rows) — ~19s on a
713
- * 35K-row DB. Switched to `ROW_NUMBER() OVER (PARTITION BY session_id
714
- * ORDER BY timestamp_ms)` so first-prompt picking is a single pass
715
- * over the table (~300ms on the same DB). Plus the index ensure step
716
- * in `openAiHist` keeps it fast even when the DB was written by an
717
- * older CLI that didn't create `idx_history_session`.
718
- */
719
- listSessions(opts = {}) {
720
- const limit = opts.limit ?? 50;
721
- const { sql, params } = buildFilters(opts, this._projectScope);
722
- const rows = runQuery(this.db, `WITH filtered AS (
723
- SELECT id, source, session_id, project, prompt, timestamp_ms
724
- FROM history
725
- WHERE session_id IS NOT NULL AND session_id != ''${sql}
726
- ),
727
- ranked AS (
728
- SELECT
729
- session_id,
730
- source,
731
- project,
732
- prompt,
733
- timestamp_ms,
734
- ROW_NUMBER() OVER (
735
- PARTITION BY session_id, source, project
736
- ORDER BY timestamp_ms ASC, id ASC
737
- ) AS rn_first,
738
- COUNT(*) OVER (PARTITION BY session_id, source, project) AS prompt_count,
739
- MIN(timestamp_ms) OVER (PARTITION BY session_id, source, project) AS first_activity_ms,
740
- MAX(timestamp_ms) OVER (PARTITION BY session_id, source, project) AS last_activity_ms
741
- FROM filtered
742
- )
743
- SELECT
744
- session_id,
745
- source,
746
- project,
747
- prompt AS first_prompt,
748
- first_activity_ms,
749
- last_activity_ms,
750
- prompt_count
751
- FROM ranked
752
- WHERE rn_first = 1
753
- ORDER BY last_activity_ms DESC
754
- LIMIT ?`, [...params, limit]);
755
- return rows.map((row) => ({
756
- sessionId: row.session_id,
757
- source: row.source,
758
- project: row.project,
759
- firstPrompt: row.first_prompt,
760
- lastActivityMs: row.last_activity_ms,
761
- firstActivityMs: row.first_activity_ms,
762
- promptCount: row.prompt_count,
763
- }));
764
- }
765
- /**
766
- * The session catalog, newest first — one indexed query over `sessions` and
767
- * nothing else.
768
- *
769
- * No provider transcript is opened and neither `history` nor
770
- * `session_events` is touched, so this stays fast on first paint even with
771
- * thousands of historical sessions. It is the read side of the native
772
- * `ai-hist sessions list`; populate the catalog with `discoverSessions()`
773
- * (or a full `ai-hist sync`).
774
- *
775
- * Details worth knowing:
776
- * - `trajectory` rows are excluded defensively — trajectories are derived
777
- * records, not sessions, and must never appear in a session list.
778
- * - The catalog's total order is
779
- * `(last_activity_ms DESC, source ASC, session_id ASC)`. SQLite sorts
780
- * NULL lowest, so rows of unknown recency land last under `DESC` without
781
- * a helper expression. Recency alone is not a key: one discovery pass can
782
- * stamp many sessions with the same mtime-derived millisecond, which is
783
- * why the identity columns are part of the order.
784
- * - To page, use {@link AiHist.listSessionCatalogPage} and follow its
785
- * `nextCursor`. `beforeMs` is only a *coarse* cutoff ("anything before
786
- * last Tuesday"): it cannot separate rows sharing a millisecond, so a
787
- * walk built on it drops every row tied with a page boundary. It is
788
- * ignored when `after` is set.
789
- * - A configured `projectScope` constrains rows by `cwd`, like
790
- * `getHandoff`. Sources with no working directory (relay) therefore drop
791
- * out of a scoped listing.
792
- * - In JSONL fallback mode (no SQLite database) the catalog is empty and
793
- * this returns `[]`: the fallback scan builds `history` rows only, and
794
- * only the native discovery engine writes the catalog.
795
- */
796
- listSessionCatalog(opts = {}) {
797
- if (!tableExists(this.db, 'sessions'))
798
- return [];
799
- // SQLite reads a negative LIMIT as "unlimited", so a negative cap would
800
- // quietly dump the whole catalog. The native CLI rejects it; so do we.
801
- if (typeof opts.limit === 'number' && opts.limit < 0) {
802
- throw new RangeError(`limit must not be negative (got ${opts.limit})`);
803
- }
804
- const limit = opts.limit ?? 50;
805
- const clauses = ["source != 'trajectory'"];
806
- const params = [];
807
- const sources = [...(opts.sources ?? [])].map((source) => String(source));
808
- if (sources.length > 0) {
809
- clauses.push(`source IN (${sources.map(() => '?').join(', ')})`);
810
- params.push(...sources);
811
- }
812
- if (opts.after) {
813
- // Everything strictly after the cursor in the catalog's total order.
814
- // Undated rows sort last, so a dated cursor must still reach them.
815
- const { lastActivityMs, source, sessionId } = opts.after;
816
- // A timestamp alone is not a cursor: without the identity columns the
817
- // predicate cannot separate rows sharing a millisecond, and silently
818
- // dropping the cursor would restart the walk at page one. The native CLI
819
- // rejects the same half-cursor; so does this, rather than looping.
820
- if (typeof source !== 'string' || source.length === 0 || typeof sessionId !== 'string' || sessionId.length === 0) {
821
- throw new TypeError('after must carry both source and sessionId — pass the whole nextCursor object from the previous page');
822
- }
823
- if (typeof lastActivityMs === 'number') {
824
- clauses.push(`(last_activity_ms IS NULL OR last_activity_ms < ?
825
- OR (last_activity_ms = ?
826
- AND (source > ? OR (source = ? AND session_id > ?))))`);
827
- params.push(lastActivityMs, lastActivityMs, source, source, sessionId);
828
- }
829
- else {
830
- clauses.push(`last_activity_ms IS NULL AND (source > ? OR (source = ? AND session_id > ?))`);
831
- params.push(source, source, sessionId);
832
- }
833
- }
834
- else if (typeof opts.beforeMs === 'number') {
835
- clauses.push('last_activity_ms < ?');
836
- params.push(opts.beforeMs);
837
- }
838
- if (this._projectScope) {
839
- const scope = scopedPathClause('cwd', this._projectScope);
840
- clauses.push(scope.sql);
841
- params.push(...scope.params);
842
- }
843
- return runQuery(this.db, `SELECT source, session_id, cwd, git_branch, first_activity_ms, last_activity_ms,
844
- first_prompt, last_assistant_text, models_json, originator, agent_version,
845
- repo_url, initial_commit, workspace_roots_json, raw_path, source_stamp,
846
- discovery_state, parser_version
847
- FROM sessions
848
- WHERE ${clauses.join(' AND ')}
849
- ORDER BY last_activity_ms DESC, source ASC, session_id ASC
850
- LIMIT ?`, [...params, limit]).map((row) => catalogSessionFromJson({ ...row, from_cache: true }));
851
- }
852
- /**
853
- * {@link AiHist.listSessionCatalog} plus the cursor that continues it.
854
- *
855
- * Follow `nextCursor` until it comes back `null` to walk the whole catalog:
856
- * no skipped and no duplicated rows, even when a whole page shares one
857
- * millisecond, and the undated tail stays reachable from a dated cursor.
858
- * `nextCursor` is non-null only when the page filled its limit, mirroring
859
- * the native `list_session_catalog_page` and the CLI's `next_cursor`.
860
- */
861
- listSessionCatalogPage(opts = {}) {
862
- const sessions = this.listSessionCatalog(opts);
863
- const limit = opts.limit ?? 50;
864
- const last = sessions[sessions.length - 1];
865
- const nextCursor = limit > 0 && sessions.length >= limit && last
866
- ? { lastActivityMs: last.lastActivityMs, source: last.source, sessionId: last.sessionId }
867
- : null;
868
- return { sessions, nextCursor };
869
- }
870
- /** All prompts in a session, ordered oldest → newest. */
871
- getSession(sessionId, opts = {}) {
872
- const clauses = ['session_id = ?'];
873
- const params = [sessionId];
874
- if (opts.source) {
875
- clauses.push('source = ?');
876
- params.push(opts.source);
877
- }
878
- appendProjectFilter(clauses, params, undefined, this._projectScope);
879
- appendTagFilter(clauses, params, opts.tag, 'history');
880
- return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
881
- FROM history
882
- WHERE ${clauses.join(' AND ')}
883
- ORDER BY timestamp_ms ASC`, params).map(rowToEntry);
884
- }
885
- /**
886
- * All normalized events for a session, oldest first — messages, thinking,
887
- * tool calls, and tool results with per-event token usage where the
888
- * transcript provides it. Returns `[]` when the database predates the
889
- * `session_events` table (or in JSONL fallback mode, which has no events).
890
- *
891
- * Size caveat: this SDK loads the whole database file into memory, so on
892
- * large event-bearing databases prefer streaming from the native CLI
893
- * (`ai-hist events <session-id> --json`).
894
- */
895
- getSessionEvents(sessionId, opts = {}) {
896
- if (!tableExists(this.db, 'session_events'))
897
- return [];
898
- const clauses = ['session_id = ?'];
899
- const params = [sessionId];
900
- if (opts.source) {
901
- clauses.push('source = ?');
902
- params.push(opts.source);
903
- }
904
- appendProjectFilter(clauses, params, undefined, this._projectScope);
905
- return runQuery(this.db, `SELECT id, source, session_id, project, cwd, git_branch, message_id, parent_id,
906
- ts_ms, role, kind, text, model, token_json, event_uid
907
- FROM session_events
908
- WHERE ${clauses.join(' AND ')}
909
- ORDER BY ts_ms IS NULL, ts_ms ASC, id ASC`, params).map((row) => ({
910
- id: row.id,
911
- source: row.source,
912
- sessionId: row.session_id,
913
- project: row.project,
914
- cwd: row.cwd,
915
- gitBranch: row.git_branch,
916
- messageId: row.message_id,
917
- parentId: row.parent_id,
918
- tsMs: row.ts_ms,
919
- role: row.role,
920
- kind: row.kind,
921
- text: row.text,
922
- model: row.model,
923
- tokenUsage: parseTokenJson(row.token_json),
924
- eventUid: row.event_uid,
925
- }));
926
- }
927
- /** All tool calls for a session, oldest first; `[]` on pre-events databases. */
928
- getToolCalls(sessionId, opts = {}) {
929
- if (!tableExists(this.db, 'tool_calls'))
930
- return [];
931
- const clauses = ['session_id = ?'];
932
- const params = [sessionId];
933
- if (opts.source) {
934
- clauses.push('source = ?');
935
- params.push(opts.source);
936
- }
937
- // tool_calls has no project column; the configured scope constrains
938
- // through the session's events. Without that table the scoped answer is
939
- // unknowable — fail closed rather than throw.
940
- if (this._projectScope) {
941
- if (!tableExists(this.db, 'session_events'))
942
- return [];
943
- const scope = scopedPathClause('e.project', this._projectScope);
944
- clauses.push(`EXISTS (SELECT 1 FROM session_events e
945
- WHERE e.source = tool_calls.source AND e.session_id = tool_calls.session_id
946
- AND ${scope.sql})`);
947
- params.push(...scope.params);
948
- }
949
- return runQuery(this.db, `SELECT id, source, session_id, message_id, tool_use_id, name, target, args_json, is_error, ts_ms
950
- FROM tool_calls
951
- WHERE ${clauses.join(' AND ')}
952
- ORDER BY ts_ms IS NULL, ts_ms ASC, id ASC`, params).map((row) => ({
953
- id: row.id,
954
- source: row.source,
955
- sessionId: row.session_id,
956
- messageId: row.message_id,
957
- toolUseId: row.tool_use_id,
958
- name: row.name,
959
- target: row.target,
960
- argsJson: row.args_json,
961
- isError: row.is_error === null ? null : row.is_error !== 0,
962
- tsMs: row.ts_ms,
963
- }));
964
- }
965
- /**
966
- * Substring search across prompt + project, case-insensitive, recent
967
- * matches first. The native CLI uses FTS5; this SDK uses LIKE because
968
- * sql.js's default WASM build doesn't ship the FTS5 module. Plenty fast
969
- * for the ai-hist scale (~tens of thousands of rows); revisit if a
970
- * future consumer needs phrase/boolean queries.
971
- *
972
- * The query is matched literally — `%` and `_` are escaped so users can
973
- * search for them. Empty queries return recent entries matching the filters.
974
- */
975
- search(query, opts = {}) {
976
- const trimmed = query.trim();
977
- const limit = opts.limit ?? 50;
978
- const clauses = [];
979
- const params = [];
980
- if (trimmed) {
981
- const escaped = trimmed.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
982
- const pattern = `%${escaped}%`;
983
- clauses.push(`(LOWER(prompt) LIKE LOWER(?) ESCAPE '\\' OR LOWER(COALESCE(project, '')) LIKE LOWER(?) ESCAPE '\\')`);
984
- params.push(pattern, pattern);
985
- }
986
- if (opts.source) {
987
- clauses.push('source = ?');
988
- params.push(opts.source);
989
- }
990
- appendProjectFilter(clauses, params, opts.project, this._projectScope);
991
- appendTagFilter(clauses, params, opts.tag, 'history');
992
- if (typeof opts.beforeMs === 'number') {
993
- clauses.push('timestamp_ms < ?');
994
- params.push(opts.beforeMs);
995
- }
996
- return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
997
- FROM history
998
- WHERE ${clauses.length > 0 ? clauses.join(' AND ') : '1=1'}
999
- ORDER BY timestamp_ms DESC
1000
- LIMIT ?`, [...params, limit]).map(rowToEntry);
1001
- }
1002
- /** Search compacted per-run trajectory WHY: decisions and retrospectives. */
1003
- searchTrajectories(query, opts = {}) {
1004
- const trimmed = query.trim();
1005
- if (!trimmed)
1006
- return [];
1007
- const limit = opts.limit ?? 20;
1008
- const escaped = trimmed.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
1009
- const pattern = `%${escaped}%`;
1010
- const clauses = [
1011
- `(LOWER(search_text) LIKE LOWER(?) ESCAPE '\\'
1012
- OR LOWER(COALESCE(task_title, '')) LIKE LOWER(?) ESCAPE '\\'
1013
- OR LOWER(COALESCE(task_description, '')) LIKE LOWER(?) ESCAPE '\\'
1014
- OR LOWER(COALESCE(persona_id, '')) LIKE LOWER(?) ESCAPE '\\'
1015
- OR LOWER(COALESCE(project_id, '')) LIKE LOWER(?) ESCAPE '\\')`,
1016
- ];
1017
- const params = [pattern, pattern, pattern, pattern, pattern];
1018
- for (const project of [this._projectScope, opts.project]) {
1019
- if (!project)
1020
- continue;
1021
- const scope = scopedTrajectoryClause(project);
1022
- clauses.push(scope.sql);
1023
- params.push(...scope.params);
1024
- }
1025
- return runQuery(this.db, `SELECT id, version, persona_id, project_id, task_title, task_description, status,
1026
- started_at, completed_at, decisions_json, retrospective_json, search_text,
1027
- path, updated_ms, timestamp_ms
1028
- FROM trajectories
1029
- WHERE ${clauses.join(' AND ')}
1030
- ORDER BY timestamp_ms DESC
1031
- LIMIT ?`, [...params, limit]).map(rowToTrajectory);
1032
- }
1033
- /** Best-matching per-run trajectory for a task query, or `null` if none match. */
1034
- whyForTask(query) {
1035
- return this.searchTrajectories(query, { limit: 1 })[0] ?? null;
1036
- }
1037
- /** Single entry by id, or `null` if not found. */
1038
- getEntry(id) {
1039
- const clauses = ['id = ?'];
1040
- const params = [id];
1041
- appendProjectFilter(clauses, params, undefined, this._projectScope);
1042
- const rows = runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
1043
- FROM history WHERE ${clauses.join(' AND ')}`, params);
1044
- return rows.length > 0 ? rowToEntry(rows[0]) : null;
1045
- }
1046
- /**
1047
- * All entries whose timestamp falls within [timestampMs - windowMs,
1048
- * timestampMs + windowMs], ordered oldest first. Used by get_context.
1049
- */
1050
- getInTimeWindow(timestampMs, windowMs) {
1051
- const clauses = ['timestamp_ms BETWEEN ? AND ?'];
1052
- const params = [timestampMs - windowMs, timestampMs + windowMs];
1053
- appendProjectFilter(clauses, params, undefined, this._projectScope);
1054
- return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
1055
- FROM history
1056
- WHERE ${clauses.join(' AND ')}
1057
- ORDER BY timestamp_ms ASC`, params).map(rowToEntry);
1058
- }
1059
- /**
1060
- * Find sessions matching the given repo/branch/source and return ranked
1061
- * handoff candidates with a warm-start command for the target CLI.
1062
- *
1063
- * Queries the `sessions` table (populated by `ai-hist sync`) for objective
1064
- * metadata, then joins the last N user prompts from `history` to generate
1065
- * a brief on demand — no pre-computed summaries.
1066
- */
1067
- getHandoff(opts = {}) {
1068
- const limit = opts.limit ?? 3;
1069
- const clauses = [];
1070
- const params = [];
1071
- if (this._projectScope) {
1072
- const escaped = this._projectScope.replace(/\|/g, '||').replace(/%/g, '|%').replace(/_/g, '|_');
1073
- clauses.push("cwd LIKE ? ESCAPE '|'");
1074
- params.push(`${escaped}%`);
1075
- }
1076
- if (opts.source) {
1077
- clauses.push('source = ?');
1078
- params.push(opts.source);
1079
- }
1080
- if (opts.repo) {
1081
- const escaped = opts.repo.replace(/\|/g, '||').replace(/%/g, '|%').replace(/_/g, '|_');
1082
- clauses.push("cwd LIKE ? ESCAPE '|'");
1083
- params.push(`%${escaped}%`);
1084
- }
1085
- if (opts.branch) {
1086
- const escaped = opts.branch.replace(/\|/g, '||').replace(/%/g, '|%').replace(/_/g, '|_');
1087
- clauses.push("git_branch LIKE ? ESCAPE '|'");
1088
- params.push(`%${escaped}%`);
1089
- }
1090
- const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
1091
- // Over-fetch sessions because many (old or sub-agent) sessions have no
1092
- // matching `history` prompts and get skipped below. Fetching exactly
1093
- // `limit` could starve the result to empty even when good candidates exist
1094
- // just past the cutoff. We stop scanning once we've collected `limit`.
1095
- const fetchCount = Math.min(Math.max(limit * 5, limit), 100);
1096
- const sessionRows = runQuery(this.db, `SELECT session_id, source, cwd, git_branch, first_activity_ms, last_activity_ms,
1097
- last_assistant_text, raw_path
1098
- FROM sessions
1099
- ${where}
1100
- ORDER BY last_activity_ms DESC
1101
- LIMIT ?`, [...params, fetchCount]);
1102
- const candidates = [];
1103
- for (const session of sessionRows) {
1104
- if (candidates.length >= limit)
1105
- break;
1106
- // Filter by both session_id AND source to prevent cross-source prompt mixing
1107
- // when two CLIs happen to use the same session ID (rare but possible).
1108
- const prompts = runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
1109
- FROM history
1110
- WHERE session_id = ? AND source = ?
1111
- ORDER BY timestamp_ms ASC`, [session.session_id, session.source]).map(rowToEntry);
1112
- if (prompts.length === 0)
1113
- continue;
1114
- const filesTouched = extractFilePaths(prompts.map((p) => p.prompt).join('\n'));
1115
- // The first prompt is often injected boilerplate (system-reminder /
1116
- // command wrappers), especially for relay-driven sessions. Prefer the
1117
- // first prompt that looks like a real user instruction for the goal.
1118
- const goalPrompt = prompts.find((p) => !isBoilerplatePrompt(p.prompt)) ?? prompts[0];
1119
- const goal = goalPrompt.prompt.slice(0, 300).replace(/\n/g, ' ');
1120
- const lastState = prompts[prompts.length - 1].prompt.slice(0, 300).replace(/\n/g, ' ');
1121
- const resume = resumeCommand({
1122
- source: session.source,
1123
- sessionId: session.session_id,
1124
- project: session.cwd,
1125
- });
1126
- const targetSource = session.source === 'claude' ? 'codex' : 'claude';
1127
- const warmStart = buildWarmStartCommand(targetSource, goal, filesTouched, lastState, session.last_assistant_text ?? null, session.cwd);
1128
- let confidence = Math.min(0.6, 0.2 + prompts.length * 0.02);
1129
- if (opts.branch && session.git_branch?.includes(opts.branch))
1130
- confidence += 0.25;
1131
- if (opts.repo && session.cwd?.includes(opts.repo))
1132
- confidence += 0.15;
1133
- confidence = Math.min(1.0, confidence);
1134
- candidates.push({
1135
- sessionId: session.session_id,
1136
- source: session.source,
1137
- cwd: session.cwd,
1138
- gitBranch: session.git_branch,
1139
- firstActivityMs: session.first_activity_ms,
1140
- lastActivityMs: session.last_activity_ms,
1141
- promptCount: prompts.length,
1142
- goal,
1143
- lastState,
1144
- lastAssistantText: session.last_assistant_text ?? null,
1145
- filesTouched,
1146
- resumeCommand: resume,
1147
- warmStartCommand: warmStart,
1148
- confidence,
1149
- });
1150
- }
1151
- return candidates.sort((a, b) => b.confidence - a.confidence);
1152
- }
1153
- /** Counts + date range, mirroring `ai-hist stats`. */
1154
- stats() {
1155
- const scopeClauses = [];
1156
- const scopeParams = [];
1157
- appendProjectFilter(scopeClauses, scopeParams, undefined, this._projectScope);
1158
- const where = scopeClauses.length > 0 ? ` WHERE ${scopeClauses.join(' AND ')}` : '';
1159
- const andScope = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(' AND ')}` : '';
1160
- const total = runQuery(this.db, `SELECT COUNT(*) AS c FROM history${where}`, scopeParams)[0]?.c ?? 0;
1161
- const bySourceRows = runQuery(this.db, `SELECT source, COUNT(*) AS c FROM history${where} GROUP BY source`, scopeParams);
1162
- const bySource = {};
1163
- for (const row of bySourceRows) {
1164
- bySource[row.source] = row.c;
1165
- }
1166
- const byProject = runQuery(this.db, `SELECT project, COUNT(*) AS c FROM history
1167
- WHERE project IS NOT NULL AND project != ''${andScope}
1168
- GROUP BY project ORDER BY c DESC LIMIT 10`, scopeParams).map((row) => ({ project: row.project, count: row.c }));
1169
- const range = runQuery(this.db, `SELECT MIN(timestamp_ms) AS mn, MAX(timestamp_ms) AS mx FROM history${where}`, scopeParams)[0];
1170
- return {
1171
- total,
1172
- bySource,
1173
- byProject,
1174
- firstTimestampMs: range?.mn ?? null,
1175
- lastTimestampMs: range?.mx ?? null,
1176
- };
184
+ export function defaultDbPath() {
185
+ if (process.env.AI_HIST_DB !== undefined)
186
+ return process.env.AI_HIST_DB;
187
+ if (process.env.XDG_DATA_HOME !== undefined) {
188
+ return join(process.env.XDG_DATA_HOME, 'ai-hist', 'ai-history.db');
1177
189
  }
190
+ return join(homedir(), '.local', 'share', 'ai-hist', 'ai-history.db');
1178
191
  }
1179
192
  /**
1180
- * Heuristic: does a prompt look like injected boilerplate rather than a real
1181
- * user instruction? Relay/agent sessions often open with a system-reminder or
1182
- * command wrapper that makes a poor "goal" summary.
193
+ * Optimization profile of the loaded native addon: 'release', 'debug', or
194
+ * 'unknown' for an addon predating the probe. Performance measurements are
195
+ * only meaningful against 'release'.
1183
196
  */
1184
- function isBoilerplatePrompt(prompt) {
1185
- const t = prompt.trimStart();
1186
- return (t.startsWith('<system-reminder') ||
1187
- t.startsWith('<command-') ||
1188
- t.startsWith('Caveat:') ||
1189
- t.startsWith('[Request interrupted'));
197
+ export async function nativeBuildProfile() {
198
+ return nativeCall(async (native) => native.nativeBuildProfile?.() ?? 'unknown');
1190
199
  }
1191
- /**
1192
- * Extract file paths with recognizable extensions from a block of text.
1193
- * Heuristic — used to populate filesTouched in HandoffCandidate.
1194
- */
1195
- function extractFilePaths(text) {
1196
- const exts = 'ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|java|cs|cpp|cc|c|h|json|yaml|yml|toml|md|sh|sql|css|scss|html|svelte|vue';
1197
- const regex = new RegExp(`(?:^|[\\s,\`'"(])([~./][\\w./-]*\\.(?:${exts})|-?[\\w/-]+\\.(?:${exts}))\\b`, 'gm');
1198
- const seen = new Set();
1199
- let m;
1200
- while ((m = regex.exec(text)) !== null) {
1201
- const p = m[1].trim();
1202
- if (p.length > 2 && p.length < 200)
1203
- seen.add(p);
1204
- }
1205
- return Array.from(seen).slice(0, 20);
200
+ export async function search(query, options = {}) {
201
+ return nativeCall(async (native) => (await native.search(query, options)).map(historyEntry));
1206
202
  }
1207
- /**
1208
- * Build a warm-start command for the target CLI, injecting context from a
1209
- * prior session so the new agent can pick up mid-task.
1210
- */
1211
- function buildWarmStartCommand(targetSource, goal, files, lastState, lastAssistant, cwd) {
1212
- const filesLine = files.length > 0 ? ` Files touched: ${files.slice(0, 10).join(', ')}.` : '';
1213
- const assistantLine = lastAssistant
1214
- ? ` Last assistant state: ${lastAssistant.replace(/\n/g, ' ').slice(0, 200)}`
1215
- : '';
1216
- const context = `Picking up from previous session. Goal: ${goal}.${filesLine} Last user prompt: ${lastState}.${assistantLine}`;
1217
- const cdPart = cwd ? `cd ${shellQuote(cwd)} && ` : '';
1218
- return `${cdPart}${targetSource} ${shellQuote(context)}`;
203
+ export async function recent(options = {}) {
204
+ return nativeCall(async (native) => (await native.recent(options)).map(historyEntry));
205
+ }
206
+ export async function getSession(sessionId, options = {}) {
207
+ return nativeCall(async (native) => (await native.getSession(sessionId, options)).map(historyEntry));
208
+ }
209
+ export async function listSessionCatalog(options = {}) {
210
+ return (await listSessionCatalogPage(options)).sessions;
211
+ }
212
+ export async function listSessionCatalogPage(options = {}) {
213
+ return nativeCall(async (native) => {
214
+ const page = await native.listSessionCatalogPage({ ...options, after: options.after ? {
215
+ ...options.after,
216
+ lastActivityMs: options.after.lastActivityMs ?? undefined,
217
+ } : undefined });
218
+ const contractVersion = Number(page.contractVersion);
219
+ assertCatalogContract(contractVersion);
220
+ return {
221
+ contractVersion,
222
+ sessions: Array.isArray(page.sessions) ? page.sessions.map(catalogSession) : [],
223
+ nextCursor: catalogCursor(page.nextCursor),
224
+ };
225
+ });
226
+ }
227
+ export async function discoverSessions(options = {}) {
228
+ return nativeCall(async (native) => {
229
+ const result = await native.discoverSessions(options);
230
+ const contractVersion = Number(result.contractVersion);
231
+ assertCatalogContract(contractVersion);
232
+ return {
233
+ contractVersion,
234
+ sessions: Array.isArray(result.sessions) ? result.sessions.map(catalogSession) : [],
235
+ discovered: Number(result.discovered),
236
+ skippedUnchanged: Number(result.skippedUnchanged),
237
+ providers: result.providers ?? [],
238
+ exemptSources: result.exemptSources ?? [],
239
+ diagnostics: Array.isArray(result.diagnostics) ? result.diagnostics.map((item) => ({
240
+ source: String(item.source), locator: nullableString(item.locator), error: String(item.error),
241
+ })) : [],
242
+ counters: result.counters,
243
+ };
244
+ });
245
+ }
246
+ export async function discoverAndList(options = {}) {
247
+ await discoverSessions(options);
248
+ return listSessionCatalogPage(options);
249
+ }
250
+ export async function getSessionEventsPage(sessionId, options = {}) {
251
+ return nativeCall(async (native) => {
252
+ const page = await native.getSessionEventsPage(sessionId, options);
253
+ return {
254
+ events: Array.isArray(page.events) ? page.events.map(sessionEvent) : [],
255
+ nextCursor: page.nextCursor && typeof page.nextCursor === 'object'
256
+ ? { tsMs: Number(page.nextCursor.tsMs), id: Number(page.nextCursor.id) }
257
+ : null,
258
+ };
259
+ });
260
+ }
261
+ export async function* sessionEvents(sessionId, options = {}) {
262
+ let after;
263
+ do {
264
+ const page = await getSessionEventsPage(sessionId, { ...options, after });
265
+ for (const event of page.events)
266
+ yield event;
267
+ after = page.nextCursor ?? undefined;
268
+ } while (after);
269
+ }
270
+ export async function getSessionEvents(sessionId, options = {}) {
271
+ const events = [];
272
+ for await (const event of sessionEvents(sessionId, options))
273
+ events.push(event);
274
+ return events;
275
+ }
276
+ export async function stats(options = {}) {
277
+ return nativeCall(async (native) => {
278
+ const result = await native.stats(options);
279
+ const bySource = {};
280
+ for (const item of result.bySource ?? []) {
281
+ bySource[String(item.source)] = Number(item.count);
282
+ }
283
+ return {
284
+ total: Number(result.total), bySource,
285
+ byProject: (result.byProject ?? []).map((item) => ({ project: String(item.project), count: Number(item.count) })),
286
+ firstTimestampMs: typeof result.firstTimestampMs === 'number' ? result.firstTimestampMs : null,
287
+ lastTimestampMs: typeof result.lastTimestampMs === 'number' ? result.lastTimestampMs : null,
288
+ };
289
+ });
290
+ }
291
+ export async function sync(options = {}) {
292
+ return nativeCall(async (native) => {
293
+ const result = await native.sync(options);
294
+ return { databasePath: String(result.databasePath), completed: result.completed === true };
295
+ });
1219
296
  }
1220
- /**
1221
- * Resume command for an entry/session, matching what `ai-hist show` prints.
1222
- * Returns `null` for sources that don't have a resume affordance (relay).
1223
- */
1224
297
  export function resumeCommand(entry) {
1225
298
  if (!entry.sessionId)
1226
299
  return null;
1227
- switch (entry.source) {
1228
- case 'claude':
1229
- return entry.project
1230
- ? `cd ${shellQuote(entry.project)} && claude --resume ${shellQuote(entry.sessionId)}`
1231
- : `claude --resume ${shellQuote(entry.sessionId)}`;
1232
- case 'codex':
300
+ const resume = (() => {
301
+ if (entry.source === 'claude')
302
+ return `claude --resume ${shellQuote(entry.sessionId)}`;
303
+ if (entry.source === 'codex')
1233
304
  return `codex resume ${shellQuote(entry.sessionId)}`;
1234
- case 'cursor':
1235
- return entry.project
1236
- ? `cd ${shellQuote(entry.project)} && cursor-agent --resume=${shellQuote(entry.sessionId)}`
1237
- : `cursor-agent --resume=${shellQuote(entry.sessionId)}`;
1238
- case 'relay':
1239
- return null;
1240
- default:
1241
- return null;
1242
- }
305
+ if (entry.source === 'cursor')
306
+ return `cursor-agent --resume=${shellQuote(entry.sessionId)}`;
307
+ if (entry.source === 'grok')
308
+ return `grok resume ${shellQuote(entry.sessionId)}`;
309
+ return null;
310
+ })();
311
+ return resume && entry.project ? `cd ${shellQuote(entry.project)} && ${resume}` : resume;
1243
312
  }
1244
313
  function shellQuote(value) {
1245
- if (/^[A-Za-z0-9_\-./]+$/.test(value))
1246
- return value;
1247
- return `'${value.replace(/'/g, `'\\''`)}'`;
314
+ return /^[A-Za-z0-9._:/-]+$/.test(value) ? value : `'${value.replace(/'/g, `'"'"'`)}'`;
1248
315
  }
1249
316
  //# sourceMappingURL=index.js.map