ai-hist 0.4.1 → 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 (46) hide show
  1. package/README.md +34 -156
  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 +153 -211
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +263 -902
  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 -675
  17. package/dist/mcp-server.js.map +1 -1
  18. package/package.json +14 -8
  19. package/dist/cloud-push.d.ts +0 -47
  20. package/dist/cloud-push.d.ts.map +0 -1
  21. package/dist/cloud-push.js +0 -111
  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 -68
  26. package/dist/cloud-push.test.js.map +0 -1
  27. package/dist/jsonl-sources.d.ts +0 -35
  28. package/dist/jsonl-sources.d.ts.map +0 -1
  29. package/dist/jsonl-sources.js +0 -354
  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 -424
  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/trajectory-sources.d.ts +0 -36
  44. package/dist/trajectory-sources.d.ts.map +0 -1
  45. package/dist/trajectory-sources.js +0 -201
  46. package/dist/trajectory-sources.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,955 +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
- const execFileAsync = promisify(execFile);
25
- /** Resolve the SQLite path that ai-hist writes to. */
26
- export function defaultDbPath() {
27
- const fromEnv = process.env.AI_HIST_DB;
28
- if (fromEnv && fromEnv.trim().length > 0)
29
- return fromEnv;
30
- return join(homedir(), '.local', 'share', 'ai-hist', 'ai-history.db');
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;
18
+ }
31
19
  }
32
- function defaultOpenCodeDbPath() {
33
- return process.env.OPENCODE_DB && process.env.OPENCODE_DB.trim().length > 0
34
- ? process.env.OPENCODE_DB
35
- : join(homedir(), '.local', 'share', 'opencode', 'opencode.db');
20
+ export class UnsupportedPlatformError extends RelayHistoryError {
36
21
  }
37
- let _sqlPromise = null;
38
- function getSqlJs() {
39
- if (!_sqlPromise) {
40
- _sqlPromise = initSqlJs();
41
- }
42
- return _sqlPromise;
43
- }
44
- function ensureSessionsSchema(db) {
45
- db.run(`CREATE TABLE IF NOT EXISTS sessions (
46
- session_id TEXT NOT NULL,
47
- source TEXT NOT NULL,
48
- cwd TEXT,
49
- git_branch TEXT,
50
- first_activity_ms INTEGER,
51
- last_activity_ms INTEGER,
52
- last_assistant_text TEXT,
53
- raw_path TEXT,
54
- parser_version INTEGER NOT NULL DEFAULT 1,
55
- PRIMARY KEY (session_id, source)
56
- )`);
57
- db.run('CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd)');
58
- db.run('CREATE INDEX IF NOT EXISTS idx_sessions_branch ON sessions(git_branch)');
59
- db.run('CREATE INDEX IF NOT EXISTS idx_sessions_last ON sessions(last_activity_ms DESC)');
60
- }
61
- function ensureTrajectorySchema(db) {
62
- db.run(`CREATE TABLE IF NOT EXISTS trajectories (
63
- id TEXT PRIMARY KEY,
64
- version INTEGER,
65
- persona_id TEXT,
66
- project_id TEXT,
67
- task_title TEXT,
68
- task_description TEXT,
69
- status TEXT,
70
- started_at TEXT,
71
- completed_at TEXT,
72
- decisions_json TEXT NOT NULL,
73
- retrospective_json TEXT NOT NULL,
74
- search_text TEXT NOT NULL,
75
- path TEXT,
76
- updated_ms INTEGER NOT NULL,
77
- timestamp_ms INTEGER NOT NULL
78
- )`);
79
- db.run('CREATE INDEX IF NOT EXISTS idx_trajectories_timestamp ON trajectories(timestamp_ms DESC)');
80
- db.run('CREATE INDEX IF NOT EXISTS idx_trajectories_project ON trajectories(project_id)');
81
- }
82
- function ensureTagSchema(db) {
83
- db.run(`CREATE TABLE IF NOT EXISTS tags (
84
- id INTEGER PRIMARY KEY AUTOINCREMENT,
85
- name TEXT NOT NULL UNIQUE,
86
- display_name TEXT NOT NULL,
87
- color TEXT,
88
- created_ms INTEGER NOT NULL,
89
- updated_ms INTEGER NOT NULL
90
- )`);
91
- db.run(`CREATE TABLE IF NOT EXISTS session_tags (
92
- id INTEGER PRIMARY KEY AUTOINCREMENT,
93
- source TEXT NOT NULL,
94
- session_id TEXT NOT NULL,
95
- tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
96
- created_ms INTEGER NOT NULL,
97
- UNIQUE(source, session_id, tag_id)
98
- )`);
99
- db.run('CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name)');
100
- db.run('CREATE INDEX IF NOT EXISTS idx_session_tags_session ON session_tags(source, session_id)');
101
- db.run('CREATE INDEX IF NOT EXISTS idx_session_tags_tag ON session_tags(tag_id)');
22
+ export class NativePackageMissingError extends RelayHistoryError {
102
23
  }
103
- /**
104
- * Open an `AiHist` reader. Async because sql.js initializes its WASM
105
- * runtime lazily and the DB file is read asynchronously so the host
106
- * process's event loop isn't blocked.
107
- *
108
- * Each call snapshots the data; to pick up later writes, call `reload()`
109
- * (or open a fresh instance).
110
- */
111
- export async function openAiHist(opts = {}) {
112
- const dbPath = opts.dbPath ?? defaultDbPath();
113
- const fallback = opts.fallback ?? 'jsonl';
114
- const SQL = await getSqlJs();
115
- // Fast path: SQLite written by `ai-hist sync`.
116
- if (await pathExists(dbPath)) {
117
- const fileBuffer = await readFile(dbPath);
118
- const db = new SQL.Database(fileBuffer);
119
- ensureTrajectorySchema(db);
120
- ensureTagSchema(db);
121
- ensureSessionsSchema(db);
122
- // Add git_branch to history if missing (pre-handoff DBs lack this column).
123
- try {
124
- db.run('ALTER TABLE history ADD COLUMN git_branch TEXT');
125
- }
126
- catch { /* already exists */ }
127
- // The Python CLI's schema doesn't create `idx_history_session` or
128
- // `idx_history_timestamp`. Without them, listSessions degrades to
129
- // an O(sessions × rows) full table scan and freezes the WASM
130
- // single-threaded JS engine for tens of seconds on real-sized DBs.
131
- // The DB is opened read-only over a buffer, but sql.js still lets
132
- // us run `CREATE INDEX` against the in-memory copy — the index
133
- // lives only for this session's lifetime and is rebuilt each
134
- // `openAiHist` call. Fast (~30ms on 35K rows).
135
- db.run('CREATE INDEX IF NOT EXISTS idx_history_session ON history(session_id)');
136
- db.run('CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp_ms DESC)');
137
- return new AiHist(db, { kind: 'sqlite', path: dbPath }, { projectScope: opts.projectScope });
138
- }
139
- if (fallback === 'error') {
140
- throw new Error(`ai-hist database not found at ${dbPath}. Run \`ai-hist sync\` first ` +
141
- `(see https://github.com/AgentWorkforce/ai-hist).`);
142
- }
143
- // Fallback: scan local source files (Claude/Codex/Cursor/Grok) directly.
144
- // No Python dependency; uses the same parsers documented in the Python
145
- // CLI's source. Yields control to the event loop between sources so a
146
- // large local history doesn't freeze the host.
147
- const db = new SQL.Database();
148
- db.run(`CREATE TABLE history (
149
- id INTEGER PRIMARY KEY AUTOINCREMENT,
150
- source TEXT NOT NULL,
151
- session_id TEXT,
152
- project TEXT,
153
- prompt TEXT NOT NULL,
154
- timestamp_ms INTEGER NOT NULL,
155
- git_branch TEXT,
156
- UNIQUE(source, timestamp_ms, prompt)
157
- )`);
158
- db.run('CREATE INDEX idx_history_timestamp ON history (timestamp_ms DESC)');
159
- db.run('CREATE INDEX idx_history_session ON history (session_id)');
160
- ensureTrajectorySchema(db);
161
- ensureTagSchema(db);
162
- ensureSessionsSchema(db);
163
- // scanLocalSources is async with yields between sources so the event
164
- // loop stays responsive while we scan many MB of JSONL.
165
- const rows = await scanLocalSources();
166
- const openCodeRows = await scanOpenCode(SQL);
167
- const trajectories = await scanLocalTrajectories();
168
- const insert = db.prepare('INSERT OR IGNORE INTO history (source, session_id, project, prompt, timestamp_ms, git_branch) VALUES (?, ?, ?, ?, ?, ?)');
169
- const insertTrajectory = db.prepare(`INSERT OR REPLACE INTO trajectories
170
- (id, version, persona_id, project_id, task_title, task_description, status,
171
- started_at, completed_at, decisions_json, retrospective_json, search_text,
172
- path, updated_ms, timestamp_ms)
173
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
174
- try {
175
- db.exec('BEGIN');
176
- for (const row of rows) {
177
- insert.run([row.source, row.sessionId, row.project, row.prompt, row.timestampMs, row.gitBranch]);
178
- }
179
- for (const row of openCodeRows) {
180
- insert.run(['opencode', row.sessionId, row.project, row.prompt, row.timestampMs, null]);
181
- }
182
- for (const trajectory of trajectories) {
183
- insertTrajectory.run([
184
- trajectory.id,
185
- trajectory.version,
186
- trajectory.personaId,
187
- trajectory.projectId,
188
- trajectory.task.title,
189
- trajectory.task.description,
190
- trajectory.status,
191
- trajectory.startedAt,
192
- trajectory.completedAt,
193
- JSON.stringify(trajectory.decisions),
194
- JSON.stringify(trajectory.retrospective),
195
- trajectory.searchText,
196
- trajectory.path,
197
- trajectory.updatedMs,
198
- trajectory.timestampMs,
199
- ]);
200
- insert.run([
201
- 'trajectory',
202
- trajectory.id,
203
- trajectory.projectId,
204
- trajectory.searchText,
205
- trajectory.timestampMs,
206
- null,
207
- ]);
208
- }
209
- db.exec('COMMIT');
210
- }
211
- finally {
212
- insert.free();
213
- insertTrajectory.free();
214
- }
215
- const scannedPaths = `${LOCAL_SOURCE_PATHS.claude}, ${LOCAL_SOURCE_PATHS.codex}, ${LOCAL_SOURCE_PATHS.cursorRoot}, ${LOCAL_SOURCE_PATHS.grokSessionsRoot}, ${trajectoryRootDescription()}`;
216
- return new AiHist(db, { kind: 'jsonl', path: scannedPaths }, { projectScope: opts.projectScope });
24
+ export class NativeLoadError extends RelayHistoryError {
217
25
  }
218
- async function pathExists(p) {
219
- try {
220
- await stat(p);
221
- return true;
222
- }
223
- catch {
224
- return false;
225
- }
26
+ export class NativeContractMismatchError extends RelayHistoryError {
226
27
  }
227
- async function readSqliteSnapshot(dbPath) {
228
- const hasWal = await pathExists(`${dbPath}-wal`);
229
- const hasShm = await pathExists(`${dbPath}-shm`);
230
- if (!hasWal && !hasShm) {
231
- return readFile(dbPath);
232
- }
233
- const dir = await mkdtemp(join(tmpdir(), 'ai-hist-sqlite-snapshot-'));
234
- const snapshot = join(dir, 'snapshot.db');
235
- try {
236
- await execFileAsync('sqlite3', [dbPath, `.backup '${snapshot.replace(/'/g, "''")}'`], {
237
- timeout: 30_000,
238
- maxBuffer: 1024 * 1024,
239
- });
240
- return await readFile(snapshot);
241
- }
242
- finally {
243
- await rm(dir, { recursive: true, force: true });
28
+ export class DatabaseOpenError extends RelayHistoryError {
29
+ }
30
+ export class InvalidArgumentError extends RelayHistoryError {
31
+ }
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';
41
+ }
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');
244
53
  }
245
54
  }
246
- async function scanOpenCode(SQL) {
247
- const dbPath = defaultOpenCodeDbPath();
248
- if (!(await pathExists(dbPath)))
249
- return [];
250
- try {
251
- const fileBuffer = await readSqliteSnapshot(dbPath);
252
- 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;
253
64
  try {
254
- const rows = runQuery(db, `SELECT s.id AS session_id, s.directory AS project, p.data,
255
- COALESCE(p.time_created, m.time_created, s.time_created) AS timestamp_ms
256
- FROM part p
257
- JOIN message m ON m.id = p.message_id
258
- JOIN session s ON s.id = p.session_id
259
- WHERE json_extract(m.data, '$.role') = 'user'
260
- AND json_extract(p.data, '$.type') = 'text'
261
- ORDER BY p.time_created ASC`, []);
262
- const scanned = [];
263
- for (const row of rows) {
264
- const data = parseJson(row.data, {});
265
- const prompt = typeof data.text === 'string' ? data.text.trim() : '';
266
- if (!prompt)
267
- continue;
268
- scanned.push({
269
- sessionId: row.session_id,
270
- project: row.project,
271
- prompt,
272
- timestampMs: row.timestamp_ms ?? 0,
273
- });
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 });
274
74
  }
275
- return scanned;
75
+ throw new NativeLoadError(`RelayHistory's native package for ${platform} failed to load: ${error.message}`, 'NATIVE_LOAD_FAILED', { cause });
276
76
  }
277
- finally {
278
- db.close();
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');
279
80
  }
280
- }
281
- catch {
282
- return [];
283
- }
81
+ validateNativeContract(binding.nativeContractVersion());
82
+ return binding;
83
+ })();
84
+ return nativePromise.catch((error) => {
85
+ nativePromise = null;
86
+ throw error;
87
+ });
284
88
  }
285
- function rowToEntry(row) {
286
- return {
287
- id: row.id,
288
- source: row.source,
289
- sessionId: row.session_id,
290
- project: row.project,
291
- prompt: row.prompt,
292
- timestampMs: row.timestamp_ms,
293
- gitBranch: row.git_branch ?? null,
294
- };
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;
295
93
  }
296
- function parseJson(raw, fallback) {
94
+ async function nativeCall(call) {
297
95
  try {
298
- return JSON.parse(raw);
299
- }
300
- catch {
301
- return fallback;
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 });
106
+ }
107
+ if (native.code === 'INVALID_ARGUMENT') {
108
+ throw new InvalidArgumentError(native.message, native.code, { cause: error });
109
+ }
110
+ throw new RelayHistoryError(native.message, native.code, { cause: error });
302
111
  }
303
112
  }
304
- function rowToTrajectory(row) {
113
+ function nullableString(value) {
114
+ return typeof value === 'string' ? value : null;
115
+ }
116
+ function historyEntry(value) {
305
117
  return {
306
- id: row.id,
307
- version: row.version,
308
- personaId: row.persona_id,
309
- projectId: row.project_id,
310
- task: {
311
- title: row.task_title,
312
- description: row.task_description,
313
- },
314
- status: row.status,
315
- startedAt: row.started_at,
316
- completedAt: row.completed_at,
317
- decisions: parseJson(row.decisions_json, []),
318
- retrospective: parseJson(row.retrospective_json, {
319
- summary: null,
320
- approach: null,
321
- learnings: [],
322
- confidence: null,
323
- }),
324
- searchText: row.search_text,
325
- path: row.path,
326
- updatedMs: row.updated_ms,
327
- 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),
328
124
  };
329
125
  }
330
- function normalizeProjectScope(project) {
331
- const trimmed = project?.trim();
332
- if (!trimmed)
333
- return undefined;
334
- return trimmed.replace(/[\\/]+$/, '') || trimmed;
335
- }
336
- function escapeLike(value) {
337
- return value.replace(/\|/g, '||').replace(/%/g, '|%').replace(/_/g, '|_');
338
- }
339
- function scopedPathClause(column, project) {
340
- const normalized = normalizeProjectScope(project) ?? project;
341
- const escaped = escapeLike(normalized);
342
- const slashChildPattern = normalized === '/' ? '/%' : `${escaped}/%`;
343
- const backslashChildPattern = normalized === '\\' ? '\\%' : `${escaped}\\%`;
126
+ function catalogCursor(value) {
127
+ if (!value || typeof value !== 'object')
128
+ return null;
129
+ const row = value;
344
130
  return {
345
- sql: `(${column} = ? OR ${column} LIKE ? ESCAPE '|' OR ${column} LIKE ? ESCAPE '|')`,
346
- params: [normalized, slashChildPattern, backslashChildPattern],
131
+ lastActivityMs: typeof row.lastActivityMs === 'number' ? row.lastActivityMs : null,
132
+ source: String(row.source),
133
+ sessionId: String(row.sessionId),
347
134
  };
348
135
  }
349
- function scopedTrajectoryClause(project) {
350
- const normalized = normalizeProjectScope(project) ?? project;
351
- const pathScope = scopedPathClause('path', normalized);
136
+ function catalogSession(value) {
352
137
  return {
353
- sql: `(project_id = ? OR ${pathScope.sql})`,
354
- 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,
355
156
  };
356
157
  }
357
- function appendProjectFilter(clauses, params, project, projectScope) {
358
- if (projectScope) {
359
- const scope = scopedPathClause('project', projectScope);
360
- clauses.push(scope.sql);
361
- params.push(...scope.params);
362
- }
363
- if (project) {
364
- clauses.push('project = ?');
365
- params.push(project);
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');
366
161
  }
367
162
  }
368
- function normalizeTagName(tag) {
369
- return tag.trim().toLowerCase().replace(/\s+/g, ' ');
370
- }
371
- function appendTagFilter(clauses, params, tag, alias = 'history') {
372
- const normalized = tag ? normalizeTagName(tag) : '';
373
- if (!normalized)
374
- return;
375
- clauses.push(`EXISTS (
376
- SELECT 1 FROM session_tags st
377
- JOIN tags t ON t.id = st.tag_id
378
- WHERE st.source = ${alias}.source
379
- AND st.session_id = ${alias}.session_id
380
- AND t.name = ?
381
- )`);
382
- params.push(normalized);
383
- }
384
- function buildFilters(opts, projectScope) {
385
- const clauses = [];
386
- const params = [];
387
- if (opts.source) {
388
- clauses.push('source = ?');
389
- params.push(opts.source);
163
+ function tokenUsage(raw) {
164
+ if (typeof raw !== 'string')
165
+ return null;
166
+ try {
167
+ const parsed = JSON.parse(raw);
168
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
390
169
  }
391
- appendProjectFilter(clauses, params, opts.project, projectScope);
392
- appendTagFilter(clauses, params, opts.tag, 'history');
393
- if (typeof opts.beforeMs === 'number') {
394
- clauses.push('timestamp_ms < ?');
395
- params.push(opts.beforeMs);
170
+ catch {
171
+ return null;
396
172
  }
173
+ }
174
+ function sessionEvent(value) {
397
175
  return {
398
- sql: clauses.length > 0 ? ` AND ${clauses.join(' AND ')}` : '',
399
- params,
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),
400
182
  };
401
183
  }
402
- function runQuery(db, sql, params) {
403
- const stmt = db.prepare(sql);
404
- try {
405
- stmt.bind(params);
406
- const rows = [];
407
- while (stmt.step()) {
408
- rows.push(stmt.getAsObject());
409
- }
410
- return rows;
411
- }
412
- finally {
413
- stmt.free();
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');
414
189
  }
190
+ return join(homedir(), '.local', 'share', 'ai-hist', 'ai-history.db');
415
191
  }
416
- export class AiHist {
417
- db;
418
- _source;
419
- _projectScope;
420
- closed = false;
421
- /** @internal — use `openAiHist(...)` to construct. */
422
- constructor(db, source, opts = {}) {
423
- this.db = db;
424
- this._source = source;
425
- this._projectScope = normalizeProjectScope(opts.projectScope);
426
- }
427
- /**
428
- * Path the data came from. SQLite mode: the .db path. JSONL fallback
429
- * mode: a comma-separated list of the scanned source paths.
430
- */
431
- get dbPath() {
432
- return this._source.path;
433
- }
434
- /** Which data path was used: `'sqlite'` (Python tool) or `'jsonl'` (fallback). */
435
- get sourceKind() {
436
- return this._source.kind;
437
- }
438
- /** Server/client-wide project scope applied to every read, if configured. */
439
- get projectScope() {
440
- return this._projectScope;
441
- }
442
- close() {
443
- if (this.closed)
444
- return;
445
- this.db.close();
446
- this.closed = true;
447
- }
448
- persistIfWritable() {
449
- if (this._source.kind !== 'sqlite')
450
- return;
451
- writeFileSync(this._source.path, Buffer.from(this.db.export()));
452
- }
453
- ensureTag(name, color) {
454
- const normalized = normalizeTagName(name);
455
- if (!normalized)
456
- throw new Error('tag name cannot be empty');
457
- const displayName = name.trim();
458
- const now = Date.now();
459
- this.db.run(`INSERT INTO tags (name, display_name, color, created_ms, updated_ms)
460
- VALUES (?, ?, ?, ?, ?)
461
- ON CONFLICT(name) DO UPDATE SET
462
- display_name = excluded.display_name,
463
- color = COALESCE(excluded.color, tags.color),
464
- updated_ms = excluded.updated_ms`, [normalized, displayName, color ?? null, now, now]);
465
- return runQuery(this.db, 'SELECT id FROM tags WHERE name = ?', [normalized])[0].id;
466
- }
467
- matchingSessions(sessionId, source) {
468
- const clauses = ['session_id = ?'];
469
- const params = [sessionId];
470
- if (source) {
471
- clauses.push('source = ?');
472
- params.push(source);
473
- }
474
- appendProjectFilter(clauses, params, undefined, this._projectScope);
475
- return runQuery(this.db, `SELECT source, session_id, MIN(project) AS project, COUNT(*) AS entry_count,
476
- MAX(timestamp_ms) AS last_activity_ms
477
- FROM history
478
- WHERE ${clauses.join(' AND ')}
479
- GROUP BY source, session_id
480
- ORDER BY source`, params).map((row) => ({
481
- source: row.source,
482
- sessionId: row.session_id,
483
- project: row.project,
484
- entryCount: row.entry_count,
485
- lastActivityMs: row.last_activity_ms,
486
- }));
487
- }
488
- tagSession(sessionId, tagName, opts = {}) {
489
- const sessions = this.matchingSessions(sessionId, opts.source);
490
- if (sessions.length === 0)
491
- return [];
492
- const tagId = this.ensureTag(tagName, opts.color);
493
- const now = Date.now();
494
- const insert = this.db.prepare('INSERT OR IGNORE INTO session_tags (source, session_id, tag_id, created_ms) VALUES (?, ?, ?, ?)');
495
- try {
496
- for (const session of sessions) {
497
- insert.run([session.source, session.sessionId, tagId, now]);
498
- }
499
- }
500
- finally {
501
- insert.free();
502
- }
503
- this.persistIfWritable();
504
- return sessions;
505
- }
506
- untagSession(sessionId, tagName, opts = {}) {
507
- const normalized = normalizeTagName(tagName);
508
- const sessions = this.matchingSessions(sessionId, opts.source);
509
- let removed = 0;
510
- for (const session of sessions) {
511
- this.db.run(`DELETE FROM session_tags
512
- WHERE source = ? AND session_id = ?
513
- AND tag_id IN (SELECT id FROM tags WHERE name = ?)`, [session.source, session.sessionId, normalized]);
514
- removed += this.db.getRowsModified();
515
- }
516
- this.persistIfWritable();
517
- return removed;
518
- }
519
- listTags(opts = {}) {
520
- const clauses = [];
521
- const params = [];
522
- if (opts.tag) {
523
- clauses.push('t.name = ?');
524
- params.push(normalizeTagName(opts.tag));
525
- }
526
- const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
527
- let scopedSessionSql = 'SELECT st.tag_id, st.id, st.created_ms FROM session_tags st';
528
- const scopedSessionParams = [];
529
- if (this._projectScope) {
530
- const scope = scopedPathClause('h.project', this._projectScope);
531
- scopedSessionSql = `SELECT st.tag_id, st.id, st.created_ms
532
- FROM session_tags st
533
- WHERE EXISTS (
534
- SELECT 1 FROM history h
535
- WHERE h.source = st.source
536
- AND h.session_id = st.session_id
537
- AND ${scope.sql}
538
- )`;
539
- scopedSessionParams.push(...scope.params);
540
- }
541
- return runQuery(this.db, `SELECT t.name, t.display_name, t.color, COUNT(st.id) AS session_count,
542
- MIN(st.created_ms) AS first_tagged_ms, MAX(st.created_ms) AS last_tagged_ms
543
- FROM tags t
544
- LEFT JOIN (${scopedSessionSql}) st ON st.tag_id = t.id
545
- ${where}
546
- GROUP BY t.id, t.name, t.display_name, t.color
547
- ORDER BY t.name`, [...scopedSessionParams, ...params]).map((row) => {
548
- const tag = {
549
- name: row.name,
550
- displayName: row.display_name,
551
- color: row.color,
552
- sessionCount: row.session_count,
553
- firstTaggedMs: row.first_tagged_ms,
554
- lastTaggedMs: row.last_tagged_ms,
555
- };
556
- if (opts.includeSessions) {
557
- tag.sessions = this.sessionsByTag(row.name);
558
- }
559
- return tag;
560
- });
561
- }
562
- sessionsByTag(tagName) {
563
- const clauses = ['t.name = ?'];
564
- const params = [normalizeTagName(tagName)];
565
- if (this._projectScope) {
566
- const scope = scopedPathClause('h.project', this._projectScope);
567
- clauses.push(scope.sql);
568
- params.push(...scope.params);
569
- }
570
- return runQuery(this.db, `SELECT st.source, st.session_id, MIN(h.project) AS project, COUNT(h.id) AS entry_count,
571
- MAX(h.timestamp_ms) AS last_activity_ms
572
- FROM session_tags st
573
- JOIN tags t ON t.id = st.tag_id
574
- JOIN history h ON h.source = st.source AND h.session_id = st.session_id
575
- WHERE ${clauses.join(' AND ')}
576
- GROUP BY st.source, st.session_id
577
- ORDER BY MAX(h.timestamp_ms) DESC`, params).map((row) => ({
578
- source: row.source,
579
- sessionId: row.session_id,
580
- project: row.project,
581
- entryCount: row.entry_count,
582
- lastActivityMs: row.last_activity_ms,
583
- }));
584
- }
585
- searchByTag(tagName, opts = {}) {
586
- return this.recent({ ...opts, tag: tagName });
587
- }
588
- /** Most recent prompts, newest first. */
589
- recent(opts = {}) {
590
- const limit = opts.limit ?? 50;
591
- const { sql, params } = buildFilters(opts, this._projectScope);
592
- return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
593
- FROM history
594
- WHERE 1=1${sql}
595
- ORDER BY timestamp_ms DESC
596
- LIMIT ?`, [...params, limit]).map(rowToEntry);
597
- }
598
- /**
599
- * Group history into sessions, ordered by last activity (newest first).
600
- * Sessions without a `session_id` are skipped.
601
- *
602
- * Implementation note: this used to use a correlated scalar subquery
603
- * to pick `first_prompt`, which ran in O(sessions × rows) — ~19s on a
604
- * 35K-row DB. Switched to `ROW_NUMBER() OVER (PARTITION BY session_id
605
- * ORDER BY timestamp_ms)` so first-prompt picking is a single pass
606
- * over the table (~300ms on the same DB). Plus the index ensure step
607
- * in `openAiHist` keeps it fast even when the DB was written by the
608
- * older Python CLI that didn't create `idx_history_session`.
609
- */
610
- listSessions(opts = {}) {
611
- const limit = opts.limit ?? 50;
612
- const { sql, params } = buildFilters(opts, this._projectScope);
613
- const rows = runQuery(this.db, `WITH filtered AS (
614
- SELECT id, source, session_id, project, prompt, timestamp_ms
615
- FROM history
616
- WHERE session_id IS NOT NULL AND session_id != ''${sql}
617
- ),
618
- ranked AS (
619
- SELECT
620
- session_id,
621
- source,
622
- project,
623
- prompt,
624
- timestamp_ms,
625
- ROW_NUMBER() OVER (
626
- PARTITION BY session_id, source, project
627
- ORDER BY timestamp_ms ASC, id ASC
628
- ) AS rn_first,
629
- COUNT(*) OVER (PARTITION BY session_id, source, project) AS prompt_count,
630
- MIN(timestamp_ms) OVER (PARTITION BY session_id, source, project) AS first_activity_ms,
631
- MAX(timestamp_ms) OVER (PARTITION BY session_id, source, project) AS last_activity_ms
632
- FROM filtered
633
- )
634
- SELECT
635
- session_id,
636
- source,
637
- project,
638
- prompt AS first_prompt,
639
- first_activity_ms,
640
- last_activity_ms,
641
- prompt_count
642
- FROM ranked
643
- WHERE rn_first = 1
644
- ORDER BY last_activity_ms DESC
645
- LIMIT ?`, [...params, limit]);
646
- return rows.map((row) => ({
647
- sessionId: row.session_id,
648
- source: row.source,
649
- project: row.project,
650
- firstPrompt: row.first_prompt,
651
- lastActivityMs: row.last_activity_ms,
652
- firstActivityMs: row.first_activity_ms,
653
- promptCount: row.prompt_count,
654
- }));
655
- }
656
- /** All prompts in a session, ordered oldest → newest. */
657
- getSession(sessionId, opts = {}) {
658
- const clauses = ['session_id = ?'];
659
- const params = [sessionId];
660
- if (opts.source) {
661
- clauses.push('source = ?');
662
- params.push(opts.source);
663
- }
664
- appendProjectFilter(clauses, params, undefined, this._projectScope);
665
- appendTagFilter(clauses, params, opts.tag, 'history');
666
- return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
667
- FROM history
668
- WHERE ${clauses.join(' AND ')}
669
- ORDER BY timestamp_ms ASC`, params).map(rowToEntry);
670
- }
671
- /**
672
- * Substring search across prompt + project, case-insensitive, recent
673
- * matches first. The Python CLI uses FTS5; this SDK uses LIKE because
674
- * sql.js's default WASM build doesn't ship the FTS5 module. Plenty fast
675
- * for the ai-hist scale (~tens of thousands of rows); revisit if a
676
- * future consumer needs phrase/boolean queries.
677
- *
678
- * The query is matched literally — `%` and `_` are escaped so users can
679
- * search for them. Empty queries return recent entries matching the filters.
680
- */
681
- search(query, opts = {}) {
682
- const trimmed = query.trim();
683
- const limit = opts.limit ?? 50;
684
- const clauses = [];
685
- const params = [];
686
- if (trimmed) {
687
- const escaped = trimmed.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
688
- const pattern = `%${escaped}%`;
689
- clauses.push(`(LOWER(prompt) LIKE LOWER(?) ESCAPE '\\' OR LOWER(COALESCE(project, '')) LIKE LOWER(?) ESCAPE '\\')`);
690
- params.push(pattern, pattern);
691
- }
692
- if (opts.source) {
693
- clauses.push('source = ?');
694
- params.push(opts.source);
695
- }
696
- appendProjectFilter(clauses, params, opts.project, this._projectScope);
697
- appendTagFilter(clauses, params, opts.tag, 'history');
698
- if (typeof opts.beforeMs === 'number') {
699
- clauses.push('timestamp_ms < ?');
700
- params.push(opts.beforeMs);
701
- }
702
- return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
703
- FROM history
704
- WHERE ${clauses.length > 0 ? clauses.join(' AND ') : '1=1'}
705
- ORDER BY timestamp_ms DESC
706
- LIMIT ?`, [...params, limit]).map(rowToEntry);
707
- }
708
- /** Search compacted per-run trajectory WHY: decisions and retrospectives. */
709
- searchTrajectories(query, opts = {}) {
710
- const trimmed = query.trim();
711
- if (!trimmed)
712
- return [];
713
- const limit = opts.limit ?? 20;
714
- const escaped = trimmed.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
715
- const pattern = `%${escaped}%`;
716
- const clauses = [
717
- `(LOWER(search_text) LIKE LOWER(?) ESCAPE '\\'
718
- OR LOWER(COALESCE(task_title, '')) LIKE LOWER(?) ESCAPE '\\'
719
- OR LOWER(COALESCE(task_description, '')) LIKE LOWER(?) ESCAPE '\\'
720
- OR LOWER(COALESCE(persona_id, '')) LIKE LOWER(?) ESCAPE '\\'
721
- OR LOWER(COALESCE(project_id, '')) LIKE LOWER(?) ESCAPE '\\')`,
722
- ];
723
- const params = [pattern, pattern, pattern, pattern, pattern];
724
- for (const project of [this._projectScope, opts.project]) {
725
- if (!project)
726
- continue;
727
- const scope = scopedTrajectoryClause(project);
728
- clauses.push(scope.sql);
729
- params.push(...scope.params);
730
- }
731
- return runQuery(this.db, `SELECT id, version, persona_id, project_id, task_title, task_description, status,
732
- started_at, completed_at, decisions_json, retrospective_json, search_text,
733
- path, updated_ms, timestamp_ms
734
- FROM trajectories
735
- WHERE ${clauses.join(' AND ')}
736
- ORDER BY timestamp_ms DESC
737
- LIMIT ?`, [...params, limit]).map(rowToTrajectory);
738
- }
739
- /** Best-matching per-run trajectory for a task query, or `null` if none match. */
740
- whyForTask(query) {
741
- return this.searchTrajectories(query, { limit: 1 })[0] ?? null;
742
- }
743
- /** Single entry by id, or `null` if not found. */
744
- getEntry(id) {
745
- const clauses = ['id = ?'];
746
- const params = [id];
747
- appendProjectFilter(clauses, params, undefined, this._projectScope);
748
- const rows = runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
749
- FROM history WHERE ${clauses.join(' AND ')}`, params);
750
- return rows.length > 0 ? rowToEntry(rows[0]) : null;
751
- }
752
- /**
753
- * All entries whose timestamp falls within [timestampMs - windowMs,
754
- * timestampMs + windowMs], ordered oldest first. Used by get_context.
755
- */
756
- getInTimeWindow(timestampMs, windowMs) {
757
- const clauses = ['timestamp_ms BETWEEN ? AND ?'];
758
- const params = [timestampMs - windowMs, timestampMs + windowMs];
759
- appendProjectFilter(clauses, params, undefined, this._projectScope);
760
- return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
761
- FROM history
762
- WHERE ${clauses.join(' AND ')}
763
- ORDER BY timestamp_ms ASC`, params).map(rowToEntry);
764
- }
765
- /**
766
- * Find sessions matching the given repo/branch/source and return ranked
767
- * handoff candidates with a warm-start command for the target CLI.
768
- *
769
- * Queries the `sessions` table (populated by `ai-hist sync`) for objective
770
- * metadata, then joins the last N user prompts from `history` to generate
771
- * a brief on demand — no pre-computed summaries.
772
- */
773
- getHandoff(opts = {}) {
774
- const limit = opts.limit ?? 3;
775
- const clauses = [];
776
- const params = [];
777
- if (this._projectScope) {
778
- const escaped = this._projectScope.replace(/\|/g, '||').replace(/%/g, '|%').replace(/_/g, '|_');
779
- clauses.push("cwd LIKE ? ESCAPE '|'");
780
- params.push(`${escaped}%`);
781
- }
782
- if (opts.source) {
783
- clauses.push('source = ?');
784
- params.push(opts.source);
785
- }
786
- if (opts.repo) {
787
- const escaped = opts.repo.replace(/\|/g, '||').replace(/%/g, '|%').replace(/_/g, '|_');
788
- clauses.push("cwd LIKE ? ESCAPE '|'");
789
- params.push(`%${escaped}%`);
790
- }
791
- if (opts.branch) {
792
- const escaped = opts.branch.replace(/\|/g, '||').replace(/%/g, '|%').replace(/_/g, '|_');
793
- clauses.push("git_branch LIKE ? ESCAPE '|'");
794
- params.push(`%${escaped}%`);
795
- }
796
- const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
797
- // Over-fetch sessions because many (old or sub-agent) sessions have no
798
- // matching `history` prompts and get skipped below. Fetching exactly
799
- // `limit` could starve the result to empty even when good candidates exist
800
- // just past the cutoff. We stop scanning once we've collected `limit`.
801
- const fetchCount = Math.min(Math.max(limit * 5, limit), 100);
802
- const sessionRows = runQuery(this.db, `SELECT session_id, source, cwd, git_branch, first_activity_ms, last_activity_ms,
803
- last_assistant_text, raw_path
804
- FROM sessions
805
- ${where}
806
- ORDER BY last_activity_ms DESC
807
- LIMIT ?`, [...params, fetchCount]);
808
- const candidates = [];
809
- for (const session of sessionRows) {
810
- if (candidates.length >= limit)
811
- break;
812
- // Filter by both session_id AND source to prevent cross-source prompt mixing
813
- // when two CLIs happen to use the same session ID (rare but possible).
814
- const prompts = runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
815
- FROM history
816
- WHERE session_id = ? AND source = ?
817
- ORDER BY timestamp_ms ASC`, [session.session_id, session.source]).map(rowToEntry);
818
- if (prompts.length === 0)
819
- continue;
820
- const filesTouched = extractFilePaths(prompts.map((p) => p.prompt).join('\n'));
821
- // The first prompt is often injected boilerplate (system-reminder /
822
- // command wrappers), especially for relay-driven sessions. Prefer the
823
- // first prompt that looks like a real user instruction for the goal.
824
- const goalPrompt = prompts.find((p) => !isBoilerplatePrompt(p.prompt)) ?? prompts[0];
825
- const goal = goalPrompt.prompt.slice(0, 300).replace(/\n/g, ' ');
826
- const lastState = prompts[prompts.length - 1].prompt.slice(0, 300).replace(/\n/g, ' ');
827
- const resume = resumeCommand({
828
- source: session.source,
829
- sessionId: session.session_id,
830
- project: session.cwd,
831
- });
832
- const targetSource = session.source === 'claude' ? 'codex' : 'claude';
833
- const warmStart = buildWarmStartCommand(targetSource, goal, filesTouched, lastState, session.last_assistant_text ?? null, session.cwd);
834
- let confidence = Math.min(0.6, 0.2 + prompts.length * 0.02);
835
- if (opts.branch && session.git_branch?.includes(opts.branch))
836
- confidence += 0.25;
837
- if (opts.repo && session.cwd?.includes(opts.repo))
838
- confidence += 0.15;
839
- confidence = Math.min(1.0, confidence);
840
- candidates.push({
841
- sessionId: session.session_id,
842
- source: session.source,
843
- cwd: session.cwd,
844
- gitBranch: session.git_branch,
845
- firstActivityMs: session.first_activity_ms,
846
- lastActivityMs: session.last_activity_ms,
847
- promptCount: prompts.length,
848
- goal,
849
- lastState,
850
- lastAssistantText: session.last_assistant_text ?? null,
851
- filesTouched,
852
- resumeCommand: resume,
853
- warmStartCommand: warmStart,
854
- confidence,
855
- });
856
- }
857
- return candidates.sort((a, b) => b.confidence - a.confidence);
858
- }
859
- /** Counts + date range, mirroring `ai-hist stats`. */
860
- stats() {
861
- const scopeClauses = [];
862
- const scopeParams = [];
863
- appendProjectFilter(scopeClauses, scopeParams, undefined, this._projectScope);
864
- const where = scopeClauses.length > 0 ? ` WHERE ${scopeClauses.join(' AND ')}` : '';
865
- const andScope = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(' AND ')}` : '';
866
- const total = runQuery(this.db, `SELECT COUNT(*) AS c FROM history${where}`, scopeParams)[0]?.c ?? 0;
867
- const bySourceRows = runQuery(this.db, `SELECT source, COUNT(*) AS c FROM history${where} GROUP BY source`, scopeParams);
868
- const bySource = {};
869
- for (const row of bySourceRows) {
870
- bySource[row.source] = row.c;
871
- }
872
- const byProject = runQuery(this.db, `SELECT project, COUNT(*) AS c FROM history
873
- WHERE project IS NOT NULL AND project != ''${andScope}
874
- GROUP BY project ORDER BY c DESC LIMIT 10`, scopeParams).map((row) => ({ project: row.project, count: row.c }));
875
- const range = runQuery(this.db, `SELECT MIN(timestamp_ms) AS mn, MAX(timestamp_ms) AS mx FROM history${where}`, scopeParams)[0];
192
+ /**
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'.
196
+ */
197
+ export async function nativeBuildProfile() {
198
+ return nativeCall(async (native) => native.nativeBuildProfile?.() ?? 'unknown');
199
+ }
200
+ export async function search(query, options = {}) {
201
+ return nativeCall(async (native) => (await native.search(query, options)).map(historyEntry));
202
+ }
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);
876
220
  return {
877
- total,
878
- bySource,
879
- byProject,
880
- firstTimestampMs: range?.mn ?? null,
881
- lastTimestampMs: range?.mx ?? null,
221
+ contractVersion,
222
+ sessions: Array.isArray(page.sessions) ? page.sessions.map(catalogSession) : [],
223
+ nextCursor: catalogCursor(page.nextCursor),
882
224
  };
883
- }
225
+ });
884
226
  }
885
- /**
886
- * Heuristic: does a prompt look like injected boilerplate rather than a real
887
- * user instruction? Relay/agent sessions often open with a system-reminder or
888
- * command wrapper that makes a poor "goal" summary.
889
- */
890
- function isBoilerplatePrompt(prompt) {
891
- const t = prompt.trimStart();
892
- return (t.startsWith('<system-reminder') ||
893
- t.startsWith('<command-') ||
894
- t.startsWith('Caveat:') ||
895
- t.startsWith('[Request interrupted'));
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
+ });
896
245
  }
897
- /**
898
- * Extract file paths with recognizable extensions from a block of text.
899
- * Heuristic — used to populate filesTouched in HandoffCandidate.
900
- */
901
- function extractFilePaths(text) {
902
- 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';
903
- const regex = new RegExp(`(?:^|[\\s,\`'"(])([~./][\\w./-]*\\.(?:${exts})|-?[\\w/-]+\\.(?:${exts}))\\b`, 'gm');
904
- const seen = new Set();
905
- let m;
906
- while ((m = regex.exec(text)) !== null) {
907
- const p = m[1].trim();
908
- if (p.length > 2 && p.length < 200)
909
- seen.add(p);
910
- }
911
- return Array.from(seen).slice(0, 20);
246
+ export async function discoverAndList(options = {}) {
247
+ await discoverSessions(options);
248
+ return listSessionCatalogPage(options);
912
249
  }
913
- /**
914
- * Build a warm-start command for the target CLI, injecting context from a
915
- * prior session so the new agent can pick up mid-task.
916
- */
917
- function buildWarmStartCommand(targetSource, goal, files, lastState, lastAssistant, cwd) {
918
- const filesLine = files.length > 0 ? ` Files touched: ${files.slice(0, 10).join(', ')}.` : '';
919
- const assistantLine = lastAssistant
920
- ? ` Last assistant state: ${lastAssistant.replace(/\n/g, ' ').slice(0, 200)}`
921
- : '';
922
- const context = `Picking up from previous session. Goal: ${goal}.${filesLine} Last user prompt: ${lastState}.${assistantLine}`;
923
- const cdPart = cwd ? `cd ${shellQuote(cwd)} && ` : '';
924
- return `${cdPart}${targetSource} ${shellQuote(context)}`;
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
+ });
925
296
  }
926
- /**
927
- * Resume command for an entry/session, matching what `ai-hist show` prints.
928
- * Returns `null` for sources that don't have a resume affordance (relay).
929
- */
930
297
  export function resumeCommand(entry) {
931
298
  if (!entry.sessionId)
932
299
  return null;
933
- switch (entry.source) {
934
- case 'claude':
935
- return entry.project
936
- ? `cd ${shellQuote(entry.project)} && claude --resume ${shellQuote(entry.sessionId)}`
937
- : `claude --resume ${shellQuote(entry.sessionId)}`;
938
- case 'codex':
300
+ const resume = (() => {
301
+ if (entry.source === 'claude')
302
+ return `claude --resume ${shellQuote(entry.sessionId)}`;
303
+ if (entry.source === 'codex')
939
304
  return `codex resume ${shellQuote(entry.sessionId)}`;
940
- case 'cursor':
941
- return entry.project
942
- ? `cd ${shellQuote(entry.project)} && cursor-agent --resume=${shellQuote(entry.sessionId)}`
943
- : `cursor-agent --resume=${shellQuote(entry.sessionId)}`;
944
- case 'relay':
945
- return null;
946
- default:
947
- return null;
948
- }
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;
949
312
  }
950
313
  function shellQuote(value) {
951
- if (/^[A-Za-z0-9_\-./]+$/.test(value))
952
- return value;
953
- return `'${value.replace(/'/g, `'\\''`)}'`;
314
+ return /^[A-Za-z0-9._:/-]+$/.test(value) ? value : `'${value.replace(/'/g, `'"'"'`)}'`;
954
315
  }
955
316
  //# sourceMappingURL=index.js.map