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/README.md +12 -3
- package/dist/index.d.ts +97 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +526 -43
- 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 +188 -13
- package/dist/mcp-server.js.map +1 -1
- package/dist/mcp-smoke.test.js +279 -8
- 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
|
|
@@ -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
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
|
341
|
-
ORDER BY timestamp_ms ASC`,
|
|
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
|
-
|
|
369
|
-
|
|
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
|
|
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 ?`, [
|
|
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
|
|
409
|
-
|
|
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
|
-
|
|
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
|
|
420
|
-
ORDER BY timestamp_ms ASC`,
|
|
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
|
|
425
|
-
const
|
|
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`,
|
|
433
|
-
const range = runQuery(this.db,
|
|
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).
|