ai-hist 0.3.2 → 0.3.4

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
@@ -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');
@@ -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) {
@@ -243,6 +365,22 @@ function appendProjectFilter(clauses, params, project, projectScope) {
243
365
  params.push(project);
244
366
  }
245
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
+ }
246
384
  function buildFilters(opts, projectScope) {
247
385
  const clauses = [];
248
386
  const params = [];
@@ -251,6 +389,7 @@ function buildFilters(opts, projectScope) {
251
389
  params.push(opts.source);
252
390
  }
253
391
  appendProjectFilter(clauses, params, opts.project, projectScope);
392
+ appendTagFilter(clauses, params, opts.tag, 'history');
254
393
  if (typeof opts.beforeMs === 'number') {
255
394
  clauses.push('timestamp_ms < ?');
256
395
  params.push(opts.beforeMs);
@@ -306,11 +445,151 @@ export class AiHist {
306
445
  this.db.close();
307
446
  this.closed = true;
308
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
+ }
309
588
  /** Most recent prompts, newest first. */
310
589
  recent(opts = {}) {
311
590
  const limit = opts.limit ?? 50;
312
591
  const { sql, params } = buildFilters(opts, this._projectScope);
313
- return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms
592
+ return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
314
593
  FROM history
315
594
  WHERE 1=1${sql}
316
595
  ORDER BY timestamp_ms DESC
@@ -375,11 +654,16 @@ export class AiHist {
375
654
  }));
376
655
  }
377
656
  /** All prompts in a session, ordered oldest → newest. */
378
- getSession(sessionId) {
657
+ getSession(sessionId, opts = {}) {
379
658
  const clauses = ['session_id = ?'];
380
659
  const params = [sessionId];
660
+ if (opts.source) {
661
+ clauses.push('source = ?');
662
+ params.push(opts.source);
663
+ }
381
664
  appendProjectFilter(clauses, params, undefined, this._projectScope);
382
- return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms
665
+ appendTagFilter(clauses, params, opts.tag, 'history');
666
+ return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
383
667
  FROM history
384
668
  WHERE ${clauses.join(' AND ')}
385
669
  ORDER BY timestamp_ms ASC`, params).map(rowToEntry);
@@ -410,11 +694,12 @@ export class AiHist {
410
694
  params.push(opts.source);
411
695
  }
412
696
  appendProjectFilter(clauses, params, opts.project, this._projectScope);
697
+ appendTagFilter(clauses, params, opts.tag, 'history');
413
698
  if (typeof opts.beforeMs === 'number') {
414
699
  clauses.push('timestamp_ms < ?');
415
700
  params.push(opts.beforeMs);
416
701
  }
417
- 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
418
703
  FROM history
419
704
  WHERE ${clauses.join(' AND ')}
420
705
  ORDER BY timestamp_ms DESC
@@ -460,7 +745,7 @@ export class AiHist {
460
745
  const clauses = ['id = ?'];
461
746
  const params = [id];
462
747
  appendProjectFilter(clauses, params, undefined, this._projectScope);
463
- const rows = runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms
748
+ const rows = runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
464
749
  FROM history WHERE ${clauses.join(' AND ')}`, params);
465
750
  return rows.length > 0 ? rowToEntry(rows[0]) : null;
466
751
  }
@@ -472,11 +757,105 @@ export class AiHist {
472
757
  const clauses = ['timestamp_ms BETWEEN ? AND ?'];
473
758
  const params = [timestampMs - windowMs, timestampMs + windowMs];
474
759
  appendProjectFilter(clauses, params, undefined, this._projectScope);
475
- return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms
760
+ return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
476
761
  FROM history
477
762
  WHERE ${clauses.join(' AND ')}
478
763
  ORDER BY timestamp_ms ASC`, params).map(rowToEntry);
479
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
+ }
480
859
  /** Counts + date range, mirroring `ai-hist stats`. */
481
860
  stats() {
482
861
  const scopeClauses = [];
@@ -503,6 +882,47 @@ export class AiHist {
503
882
  };
504
883
  }
505
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
+ }
506
926
  /**
507
927
  * Resume command for an entry/session, matching what `ai-hist show` prints.
508
928
  * Returns `null` for sources that don't have a resume affordance (relay).