ai-hist 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,27 +3,37 @@
3
3
  *
4
4
  * Backed by sql.js (WASM SQLite) so the package has zero native build
5
5
  * requirements — works in Electron, Node, and browser contexts without
6
- * needing electron-rebuild. The SDK reads the same file the Python CLI
7
- * maintains (default `~/.local/share/ai-hist/ai-history.db`, or
8
- * `$AI_HIST_DB`); the Python tool stays the canonical sync engine.
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
9
  *
10
10
  * Trade-off vs better-sqlite3: sql.js loads the whole DB file into
11
11
  * memory. Fine for the ai-hist scale (tens of thousands of rows, MBs
12
12
  * of data); revisit if anyone hits millions.
13
13
  */
14
14
  import initSqlJs from 'sql.js';
15
- import { readFile, stat } from 'node:fs/promises';
15
+ import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
16
+ import { writeFileSync } from 'node:fs';
17
+ import { execFile } from 'node:child_process';
16
18
  import { homedir } from 'node:os';
17
19
  import { join } from 'node:path';
20
+ import { tmpdir } from 'node:os';
21
+ import { promisify } from 'node:util';
18
22
  import { scanLocalSources, LOCAL_SOURCE_PATHS } from './jsonl-sources.js';
19
23
  import { scanLocalTrajectories, trajectoryRootDescription, } from './trajectory-sources.js';
20
- /** Resolve the SQLite path the Python CLI writes to. */
24
+ const execFileAsync = promisify(execFile);
25
+ /** Resolve the SQLite path that ai-hist writes to. */
21
26
  export function defaultDbPath() {
22
27
  const fromEnv = process.env.AI_HIST_DB;
23
28
  if (fromEnv && fromEnv.trim().length > 0)
24
29
  return fromEnv;
25
30
  return join(homedir(), '.local', 'share', 'ai-hist', 'ai-history.db');
26
31
  }
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');
36
+ }
27
37
  let _sqlPromise = null;
28
38
  function getSqlJs() {
29
39
  if (!_sqlPromise) {
@@ -31,6 +41,23 @@ function getSqlJs() {
31
41
  }
32
42
  return _sqlPromise;
33
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
+ }
34
61
  function ensureTrajectorySchema(db) {
35
62
  db.run(`CREATE TABLE IF NOT EXISTS trajectories (
36
63
  id TEXT PRIMARY KEY,
@@ -52,6 +79,27 @@ function ensureTrajectorySchema(db) {
52
79
  db.run('CREATE INDEX IF NOT EXISTS idx_trajectories_timestamp ON trajectories(timestamp_ms DESC)');
53
80
  db.run('CREATE INDEX IF NOT EXISTS idx_trajectories_project ON trajectories(project_id)');
54
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)');
102
+ }
55
103
  /**
56
104
  * Open an `AiHist` reader. Async because sql.js initializes its WASM
57
105
  * runtime lazily and the DB file is read asynchronously so the host
@@ -69,6 +117,13 @@ export async function openAiHist(opts = {}) {
69
117
  const fileBuffer = await readFile(dbPath);
70
118
  const db = new SQL.Database(fileBuffer);
71
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 */ }
72
127
  // The Python CLI's schema doesn't create `idx_history_session` or
73
128
  // `idx_history_timestamp`. Without them, listSessions degrades to
74
129
  // an O(sessions × rows) full table scan and freezes the WASM
@@ -79,7 +134,7 @@ export async function openAiHist(opts = {}) {
79
134
  // `openAiHist` call. Fast (~30ms on 35K rows).
80
135
  db.run('CREATE INDEX IF NOT EXISTS idx_history_session ON history(session_id)');
81
136
  db.run('CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp_ms DESC)');
82
- return new AiHist(db, { kind: 'sqlite', path: dbPath });
137
+ return new AiHist(db, { kind: 'sqlite', path: dbPath }, { projectScope: opts.projectScope });
83
138
  }
84
139
  if (fallback === 'error') {
85
140
  throw new Error(`ai-hist database not found at ${dbPath}. Run \`ai-hist sync\` first ` +
@@ -97,16 +152,20 @@ export async function openAiHist(opts = {}) {
97
152
  project TEXT,
98
153
  prompt TEXT NOT NULL,
99
154
  timestamp_ms INTEGER NOT NULL,
155
+ git_branch TEXT,
100
156
  UNIQUE(source, timestamp_ms, prompt)
101
157
  )`);
102
158
  db.run('CREATE INDEX idx_history_timestamp ON history (timestamp_ms DESC)');
103
159
  db.run('CREATE INDEX idx_history_session ON history (session_id)');
104
160
  ensureTrajectorySchema(db);
161
+ ensureTagSchema(db);
162
+ ensureSessionsSchema(db);
105
163
  // scanLocalSources is async with yields between sources so the event
106
164
  // loop stays responsive while we scan many MB of JSONL.
107
165
  const rows = await scanLocalSources();
166
+ const openCodeRows = await scanOpenCode(SQL);
108
167
  const trajectories = await scanLocalTrajectories();
109
- const insert = db.prepare('INSERT OR IGNORE INTO history (source, session_id, project, prompt, timestamp_ms) VALUES (?, ?, ?, ?, ?)');
168
+ const insert = db.prepare('INSERT OR IGNORE INTO history (source, session_id, project, prompt, timestamp_ms, git_branch) VALUES (?, ?, ?, ?, ?, ?)');
110
169
  const insertTrajectory = db.prepare(`INSERT OR REPLACE INTO trajectories
111
170
  (id, version, persona_id, project_id, task_title, task_description, status,
112
171
  started_at, completed_at, decisions_json, retrospective_json, search_text,
@@ -115,7 +174,10 @@ export async function openAiHist(opts = {}) {
115
174
  try {
116
175
  db.exec('BEGIN');
117
176
  for (const row of rows) {
118
- insert.run([row.source, row.sessionId, row.project, row.prompt, row.timestampMs]);
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]);
119
181
  }
120
182
  for (const trajectory of trajectories) {
121
183
  insertTrajectory.run([
@@ -141,6 +203,7 @@ export async function openAiHist(opts = {}) {
141
203
  trajectory.projectId,
142
204
  trajectory.searchText,
143
205
  trajectory.timestampMs,
206
+ null,
144
207
  ]);
145
208
  }
146
209
  db.exec('COMMIT');
@@ -150,7 +213,7 @@ export async function openAiHist(opts = {}) {
150
213
  insertTrajectory.free();
151
214
  }
152
215
  const scannedPaths = `${LOCAL_SOURCE_PATHS.claude}, ${LOCAL_SOURCE_PATHS.codex}, ${LOCAL_SOURCE_PATHS.cursorRoot}, ${trajectoryRootDescription()}`;
153
- return new AiHist(db, { kind: 'jsonl', path: scannedPaths });
216
+ return new AiHist(db, { kind: 'jsonl', path: scannedPaths }, { projectScope: opts.projectScope });
154
217
  }
155
218
  async function pathExists(p) {
156
219
  try {
@@ -161,6 +224,64 @@ async function pathExists(p) {
161
224
  return false;
162
225
  }
163
226
  }
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 });
244
+ }
245
+ }
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);
253
+ 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
+ });
274
+ }
275
+ return scanned;
276
+ }
277
+ finally {
278
+ db.close();
279
+ }
280
+ }
281
+ catch {
282
+ return [];
283
+ }
284
+ }
164
285
  function rowToEntry(row) {
165
286
  return {
166
287
  id: row.id,
@@ -169,6 +290,7 @@ function rowToEntry(row) {
169
290
  project: row.project,
170
291
  prompt: row.prompt,
171
292
  timestampMs: row.timestamp_ms,
293
+ gitBranch: row.git_branch ?? null,
172
294
  };
173
295
  }
174
296
  function parseJson(raw, fallback) {
@@ -205,17 +327,69 @@ function rowToTrajectory(row) {
205
327
  timestampMs: row.timestamp_ms,
206
328
  };
207
329
  }
208
- function buildFilters(opts) {
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}\\%`;
344
+ return {
345
+ sql: `(${column} = ? OR ${column} LIKE ? ESCAPE '|' OR ${column} LIKE ? ESCAPE '|')`,
346
+ params: [normalized, slashChildPattern, backslashChildPattern],
347
+ };
348
+ }
349
+ function scopedTrajectoryClause(project) {
350
+ const normalized = normalizeProjectScope(project) ?? project;
351
+ const pathScope = scopedPathClause('path', normalized);
352
+ return {
353
+ sql: `(project_id = ? OR ${pathScope.sql})`,
354
+ params: [normalized, ...pathScope.params],
355
+ };
356
+ }
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);
366
+ }
367
+ }
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) {
209
385
  const clauses = [];
210
386
  const params = [];
211
387
  if (opts.source) {
212
388
  clauses.push('source = ?');
213
389
  params.push(opts.source);
214
390
  }
215
- if (opts.project) {
216
- clauses.push('project = ?');
217
- params.push(opts.project);
218
- }
391
+ appendProjectFilter(clauses, params, opts.project, projectScope);
392
+ appendTagFilter(clauses, params, opts.tag, 'history');
219
393
  if (typeof opts.beforeMs === 'number') {
220
394
  clauses.push('timestamp_ms < ?');
221
395
  params.push(opts.beforeMs);
@@ -242,11 +416,13 @@ function runQuery(db, sql, params) {
242
416
  export class AiHist {
243
417
  db;
244
418
  _source;
419
+ _projectScope;
245
420
  closed = false;
246
421
  /** @internal — use `openAiHist(...)` to construct. */
247
- constructor(db, source) {
422
+ constructor(db, source, opts = {}) {
248
423
  this.db = db;
249
424
  this._source = source;
425
+ this._projectScope = normalizeProjectScope(opts.projectScope);
250
426
  }
251
427
  /**
252
428
  * Path the data came from. SQLite mode: the .db path. JSONL fallback
@@ -259,17 +435,161 @@ export class AiHist {
259
435
  get sourceKind() {
260
436
  return this._source.kind;
261
437
  }
438
+ /** Server/client-wide project scope applied to every read, if configured. */
439
+ get projectScope() {
440
+ return this._projectScope;
441
+ }
262
442
  close() {
263
443
  if (this.closed)
264
444
  return;
265
445
  this.db.close();
266
446
  this.closed = true;
267
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
+ }
268
588
  /** Most recent prompts, newest first. */
269
589
  recent(opts = {}) {
270
590
  const limit = opts.limit ?? 50;
271
- const { sql, params } = buildFilters(opts);
272
- return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms
591
+ const { sql, params } = buildFilters(opts, this._projectScope);
592
+ return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
273
593
  FROM history
274
594
  WHERE 1=1${sql}
275
595
  ORDER BY timestamp_ms DESC
@@ -289,7 +609,7 @@ export class AiHist {
289
609
  */
290
610
  listSessions(opts = {}) {
291
611
  const limit = opts.limit ?? 50;
292
- const { sql, params } = buildFilters(opts);
612
+ const { sql, params } = buildFilters(opts, this._projectScope);
293
613
  const rows = runQuery(this.db, `WITH filtered AS (
294
614
  SELECT id, source, session_id, project, prompt, timestamp_ms
295
615
  FROM history
@@ -334,11 +654,19 @@ export class AiHist {
334
654
  }));
335
655
  }
336
656
  /** All prompts in a session, ordered oldest → newest. */
337
- getSession(sessionId) {
338
- return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms
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
339
667
  FROM history
340
- WHERE session_id = ?
341
- ORDER BY timestamp_ms ASC`, [sessionId]).map(rowToEntry);
668
+ WHERE ${clauses.join(' AND ')}
669
+ ORDER BY timestamp_ms ASC`, params).map(rowToEntry);
342
670
  }
343
671
  /**
344
672
  * Substring search across prompt + project, case-insensitive, recent
@@ -365,15 +693,13 @@ export class AiHist {
365
693
  clauses.push('source = ?');
366
694
  params.push(opts.source);
367
695
  }
368
- if (opts.project) {
369
- clauses.push('project = ?');
370
- params.push(opts.project);
371
- }
696
+ appendProjectFilter(clauses, params, opts.project, this._projectScope);
697
+ appendTagFilter(clauses, params, opts.tag, 'history');
372
698
  if (typeof opts.beforeMs === 'number') {
373
699
  clauses.push('timestamp_ms < ?');
374
700
  params.push(opts.beforeMs);
375
701
  }
376
- return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms
702
+ return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
377
703
  FROM history
378
704
  WHERE ${clauses.join(' AND ')}
379
705
  ORDER BY timestamp_ms DESC
@@ -387,17 +713,28 @@ export class AiHist {
387
713
  const limit = opts.limit ?? 20;
388
714
  const escaped = trimmed.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
389
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
+ }
390
731
  return runQuery(this.db, `SELECT id, version, persona_id, project_id, task_title, task_description, status,
391
732
  started_at, completed_at, decisions_json, retrospective_json, search_text,
392
733
  path, updated_ms, timestamp_ms
393
734
  FROM trajectories
394
- WHERE LOWER(search_text) LIKE LOWER(?) ESCAPE '\\'
395
- OR LOWER(COALESCE(task_title, '')) LIKE LOWER(?) ESCAPE '\\'
396
- OR LOWER(COALESCE(task_description, '')) LIKE LOWER(?) ESCAPE '\\'
397
- OR LOWER(COALESCE(persona_id, '')) LIKE LOWER(?) ESCAPE '\\'
398
- OR LOWER(COALESCE(project_id, '')) LIKE LOWER(?) ESCAPE '\\'
735
+ WHERE ${clauses.join(' AND ')}
399
736
  ORDER BY timestamp_ms DESC
400
- LIMIT ?`, [pattern, pattern, pattern, pattern, pattern, limit]).map(rowToTrajectory);
737
+ LIMIT ?`, [...params, limit]).map(rowToTrajectory);
401
738
  }
402
739
  /** Best-matching per-run trajectory for a task query, or `null` if none match. */
403
740
  whyForTask(query) {
@@ -405,8 +742,11 @@ export class AiHist {
405
742
  }
406
743
  /** Single entry by id, or `null` if not found. */
407
744
  getEntry(id) {
408
- const rows = runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms
409
- FROM history WHERE id = ?`, [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);
410
750
  return rows.length > 0 ? rowToEntry(rows[0]) : null;
411
751
  }
412
752
  /**
@@ -414,23 +754,125 @@ export class AiHist {
414
754
  * timestampMs + windowMs], ordered oldest first. Used by get_context.
415
755
  */
416
756
  getInTimeWindow(timestampMs, windowMs) {
417
- return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms
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
418
761
  FROM history
419
- WHERE timestamp_ms BETWEEN ? AND ?
420
- ORDER BY timestamp_ms ASC`, [timestampMs - windowMs, timestampMs + windowMs]).map(rowToEntry);
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);
421
858
  }
422
859
  /** Counts + date range, mirroring `ai-hist stats`. */
423
860
  stats() {
424
- const total = runQuery(this.db, 'SELECT COUNT(*) AS c FROM history', [])[0]?.c ?? 0;
425
- const bySourceRows = runQuery(this.db, 'SELECT source, COUNT(*) AS c FROM history GROUP BY source', []);
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);
426
868
  const bySource = {};
427
869
  for (const row of bySourceRows) {
428
870
  bySource[row.source] = row.c;
429
871
  }
430
872
  const byProject = runQuery(this.db, `SELECT project, COUNT(*) AS c FROM history
431
- WHERE project IS NOT NULL AND project != ''
432
- GROUP BY project ORDER BY c DESC LIMIT 10`, []).map((row) => ({ project: row.project, count: row.c }));
433
- const range = runQuery(this.db, 'SELECT MIN(timestamp_ms) AS mn, MAX(timestamp_ms) AS mx FROM history', [])[0];
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];
434
876
  return {
435
877
  total,
436
878
  bySource,
@@ -440,6 +882,47 @@ export class AiHist {
440
882
  };
441
883
  }
442
884
  }
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'));
896
+ }
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);
912
+ }
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)}`;
925
+ }
443
926
  /**
444
927
  * Resume command for an entry/session, matching what `ai-hist show` prints.
445
928
  * Returns `null` for sources that don't have a resume affordance (relay).