ai-hist 0.3.2 → 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/README.md +3 -3
- package/dist/index.d.ts +86 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +433 -13
- package/dist/index.js.map +1 -1
- package/dist/jsonl-sources.d.ts +1 -0
- package/dist/jsonl-sources.d.ts.map +1 -1
- package/dist/jsonl-sources.js +3 -0
- package/dist/jsonl-sources.js.map +1 -1
- package/dist/mcp-server.js +154 -12
- package/dist/mcp-server.js.map +1 -1
- package/dist/mcp-smoke.test.js +141 -2
- package/dist/mcp-smoke.test.js.map +1 -1
- package/package.json +3 -3
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
|
|
7
|
-
*
|
|
8
|
-
* `$AI_HIST_DB`)
|
|
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
|
-
|
|
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
|
-
|
|
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).
|