ai-hist 0.6.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -254
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +248 -0
- package/dist/cli.js.map +1 -0
- package/dist/cloud-client.d.ts +0 -1
- package/dist/cloud-client.d.ts.map +1 -1
- package/dist/cloud-client.js +0 -2
- package/dist/cloud-client.js.map +1 -1
- package/dist/index.d.ts +138 -309
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +258 -1191
- package/dist/index.js.map +1 -1
- package/dist/mcp-server.d.ts +0 -9
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +52 -790
- package/dist/mcp-server.js.map +1 -1
- package/package.json +14 -8
- package/dist/cloud-push.d.ts +0 -51
- package/dist/cloud-push.d.ts.map +0 -1
- package/dist/cloud-push.js +0 -113
- package/dist/cloud-push.js.map +0 -1
- package/dist/cloud-push.test.d.ts +0 -2
- package/dist/cloud-push.test.d.ts.map +0 -1
- package/dist/cloud-push.test.js +0 -82
- package/dist/cloud-push.test.js.map +0 -1
- package/dist/jsonl-sources.d.ts +0 -33
- package/dist/jsonl-sources.d.ts.map +0 -1
- package/dist/jsonl-sources.js +0 -351
- package/dist/jsonl-sources.js.map +0 -1
- package/dist/mcp-smoke.test.d.ts +0 -2
- package/dist/mcp-smoke.test.d.ts.map +0 -1
- package/dist/mcp-smoke.test.js +0 -574
- package/dist/mcp-smoke.test.js.map +0 -1
- package/dist/pair-client.d.ts +0 -41
- package/dist/pair-client.d.ts.map +0 -1
- package/dist/pair-client.js +0 -60
- package/dist/pair-client.js.map +0 -1
- package/dist/pair-client.test.d.ts +0 -2
- package/dist/pair-client.test.d.ts.map +0 -1
- package/dist/pair-client.test.js +0 -118
- package/dist/pair-client.test.js.map +0 -1
- package/dist/session-catalog.d.ts +0 -257
- package/dist/session-catalog.d.ts.map +0 -1
- package/dist/session-catalog.js +0 -340
- package/dist/session-catalog.js.map +0 -1
- package/dist/session-catalog.test.d.ts +0 -2
- package/dist/session-catalog.test.d.ts.map +0 -1
- package/dist/session-catalog.test.js +0 -710
- package/dist/session-catalog.test.js.map +0 -1
- package/dist/session-events.test.d.ts +0 -2
- package/dist/session-events.test.d.ts.map +0 -1
- package/dist/session-events.test.js +0 -177
- package/dist/session-events.test.js.map +0 -1
- package/dist/trajectory-sources.d.ts +0 -36
- package/dist/trajectory-sources.d.ts.map +0 -1
- package/dist/trajectory-sources.js +0 -201
- package/dist/trajectory-sources.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,1249 +1,316 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* RelayHistory's public TypeScript API.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* that `ai-hist sync` writes (default
|
|
8
|
-
* `~/.local/share/ai-hist/ai-history.db`, or `$AI_HIST_DB`).
|
|
9
|
-
*
|
|
10
|
-
* Trade-off vs better-sqlite3: sql.js loads the whole DB file into
|
|
11
|
-
* memory. Fine for the ai-hist scale (tens of thousands of rows, MBs
|
|
12
|
-
* of data); revisit if anyone hits millions.
|
|
4
|
+
* Every production operation crosses one mandatory Node-API boundary into the
|
|
5
|
+
* Rust engine. This module owns only input defaults, object normalization,
|
|
6
|
+
* pagination ergonomics, and stable JavaScript errors.
|
|
13
7
|
*/
|
|
14
|
-
import initSqlJs from 'sql.js';
|
|
15
|
-
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
|
16
|
-
import { writeFileSync } from 'node:fs';
|
|
17
|
-
import { execFile } from 'node:child_process';
|
|
18
8
|
import { homedir } from 'node:os';
|
|
19
9
|
import { join } from 'node:path';
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
export function defaultDbPath() {
|
|
29
|
-
const fromEnv = process.env.AI_HIST_DB;
|
|
30
|
-
if (fromEnv && fromEnv.trim().length > 0)
|
|
31
|
-
return fromEnv;
|
|
32
|
-
return join(homedir(), '.local', 'share', 'ai-hist', 'ai-history.db');
|
|
33
|
-
}
|
|
34
|
-
function defaultOpenCodeDbPath() {
|
|
35
|
-
return process.env.OPENCODE_DB && process.env.OPENCODE_DB.trim().length > 0
|
|
36
|
-
? process.env.OPENCODE_DB
|
|
37
|
-
: join(homedir(), '.local', 'share', 'opencode', 'opencode.db');
|
|
38
|
-
}
|
|
39
|
-
let _sqlPromise = null;
|
|
40
|
-
function getSqlJs() {
|
|
41
|
-
if (!_sqlPromise) {
|
|
42
|
-
_sqlPromise = initSqlJs();
|
|
10
|
+
export const NATIVE_CONTRACT_VERSION = 2;
|
|
11
|
+
export const SESSION_CATALOG_CONTRACT_VERSION = 1;
|
|
12
|
+
export class RelayHistoryError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(message, code, options) {
|
|
15
|
+
super(message, options);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.name = new.target.name;
|
|
43
18
|
}
|
|
44
|
-
return _sqlPromise;
|
|
45
19
|
}
|
|
46
|
-
|
|
47
|
-
* Every non-key column of the session catalog, in the order the native schema
|
|
48
|
-
* declares them. Used both to create the table and to backfill it column by
|
|
49
|
-
* column on a database snapshot written before the catalog existed.
|
|
50
|
-
*/
|
|
51
|
-
const SESSION_CATALOG_COLUMNS = [
|
|
52
|
-
['cwd', 'TEXT'],
|
|
53
|
-
['git_branch', 'TEXT'],
|
|
54
|
-
['first_activity_ms', 'INTEGER'],
|
|
55
|
-
['last_activity_ms', 'INTEGER'],
|
|
56
|
-
['last_assistant_text', 'TEXT'],
|
|
57
|
-
['raw_path', 'TEXT'],
|
|
58
|
-
['parser_version', 'INTEGER NOT NULL DEFAULT 1'],
|
|
59
|
-
['first_prompt', 'TEXT'],
|
|
60
|
-
['models_json', 'TEXT'],
|
|
61
|
-
['originator', 'TEXT'],
|
|
62
|
-
['agent_version', 'TEXT'],
|
|
63
|
-
['repo_url', 'TEXT'],
|
|
64
|
-
['initial_commit', 'TEXT'],
|
|
65
|
-
['workspace_roots_json', 'TEXT'],
|
|
66
|
-
['source_stamp', 'TEXT'],
|
|
67
|
-
['discovery_state', 'TEXT'],
|
|
68
|
-
];
|
|
69
|
-
/** Run a statement whose only expected failure is "already there". */
|
|
70
|
-
function tryRun(db, sql) {
|
|
71
|
-
try {
|
|
72
|
-
db.run(sql);
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
/* column/index already exists, or the table shape rules it out */
|
|
76
|
-
}
|
|
20
|
+
export class UnsupportedPlatformError extends RelayHistoryError {
|
|
77
21
|
}
|
|
78
|
-
|
|
79
|
-
* Ensure the in-memory copy has the current `sessions` shape.
|
|
80
|
-
*
|
|
81
|
-
* The catalog columns (`first_prompt`, `models_json`, `discovery_state`, …)
|
|
82
|
-
* were added after 0.5.0, so a database file written by an older CLI has the
|
|
83
|
-
* old nine-column table. `CREATE TABLE IF NOT EXISTS` would leave that table
|
|
84
|
-
* untouched and every catalog read would fail with `no such column`, so each
|
|
85
|
-
* column is also added best-effort with `ALTER TABLE` — the same trick used for
|
|
86
|
-
* `history.git_branch` in `openAiHist`. The writes land only in the in-memory
|
|
87
|
-
* copy sql.js holds; the file on disk is untouched and the missing columns
|
|
88
|
-
* simply read as `NULL`.
|
|
89
|
-
*/
|
|
90
|
-
function ensureSessionsSchema(db) {
|
|
91
|
-
db.run(`CREATE TABLE IF NOT EXISTS sessions (
|
|
92
|
-
session_id TEXT NOT NULL,
|
|
93
|
-
source TEXT NOT NULL,
|
|
94
|
-
cwd TEXT,
|
|
95
|
-
git_branch TEXT,
|
|
96
|
-
first_activity_ms INTEGER,
|
|
97
|
-
last_activity_ms INTEGER,
|
|
98
|
-
last_assistant_text TEXT,
|
|
99
|
-
raw_path TEXT,
|
|
100
|
-
parser_version INTEGER NOT NULL DEFAULT 1,
|
|
101
|
-
first_prompt TEXT,
|
|
102
|
-
models_json TEXT,
|
|
103
|
-
originator TEXT,
|
|
104
|
-
agent_version TEXT,
|
|
105
|
-
repo_url TEXT,
|
|
106
|
-
initial_commit TEXT,
|
|
107
|
-
workspace_roots_json TEXT,
|
|
108
|
-
source_stamp TEXT,
|
|
109
|
-
discovery_state TEXT,
|
|
110
|
-
PRIMARY KEY (session_id, source)
|
|
111
|
-
)`);
|
|
112
|
-
for (const [name, decl] of SESSION_CATALOG_COLUMNS) {
|
|
113
|
-
tryRun(db, `ALTER TABLE sessions ADD COLUMN ${name} ${decl}`);
|
|
114
|
-
}
|
|
115
|
-
tryRun(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd)');
|
|
116
|
-
tryRun(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_branch ON sessions(git_branch)');
|
|
117
|
-
tryRun(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_last ON sessions(last_activity_ms DESC)');
|
|
118
|
-
tryRun(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_source_last ON sessions(source, last_activity_ms DESC)');
|
|
119
|
-
// The catalog's total order is (last_activity_ms DESC, source, session_id) —
|
|
120
|
-
// recency alone ties constantly, since one discovery pass can stamp many
|
|
121
|
-
// sessions with the same mtime-derived millisecond. These two carry the whole
|
|
122
|
-
// ORDER BY, so the listing is answered without a sort here as it is natively.
|
|
123
|
-
tryRun(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_recency ON sessions(last_activity_ms DESC, source, session_id)');
|
|
124
|
-
tryRun(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_source_recency ON sessions(source, last_activity_ms DESC, session_id)');
|
|
125
|
-
tryRun(db, 'CREATE INDEX IF NOT EXISTS idx_sessions_raw_path ON sessions(source, raw_path)');
|
|
22
|
+
export class NativePackageMissingError extends RelayHistoryError {
|
|
126
23
|
}
|
|
127
|
-
|
|
128
|
-
* Mirror the discovery bookkeeping table.
|
|
129
|
-
*
|
|
130
|
-
* Shallow discovery records here that a given source was examined at a given
|
|
131
|
-
* stamp and found not to be a session (a subagent sidecar, say), so a rescan
|
|
132
|
-
* costs a primary-key lookup instead of a re-read. The SDK never reads it; the
|
|
133
|
-
* table is created so an in-memory copy has the same shape as the file the
|
|
134
|
-
* native tool writes.
|
|
135
|
-
*/
|
|
136
|
-
function ensureDiscoverySkipsSchema(db) {
|
|
137
|
-
db.run(`CREATE TABLE IF NOT EXISTS discovery_skips (
|
|
138
|
-
source TEXT NOT NULL,
|
|
139
|
-
locator TEXT NOT NULL,
|
|
140
|
-
stamp TEXT NOT NULL,
|
|
141
|
-
reason TEXT,
|
|
142
|
-
updated_ms INTEGER,
|
|
143
|
-
PRIMARY KEY (source, locator)
|
|
144
|
-
)`);
|
|
24
|
+
export class NativeLoadError extends RelayHistoryError {
|
|
145
25
|
}
|
|
146
|
-
|
|
147
|
-
db.run(`CREATE TABLE IF NOT EXISTS trajectories (
|
|
148
|
-
id TEXT PRIMARY KEY,
|
|
149
|
-
version INTEGER,
|
|
150
|
-
persona_id TEXT,
|
|
151
|
-
project_id TEXT,
|
|
152
|
-
task_title TEXT,
|
|
153
|
-
task_description TEXT,
|
|
154
|
-
status TEXT,
|
|
155
|
-
started_at TEXT,
|
|
156
|
-
completed_at TEXT,
|
|
157
|
-
decisions_json TEXT NOT NULL,
|
|
158
|
-
retrospective_json TEXT NOT NULL,
|
|
159
|
-
search_text TEXT NOT NULL,
|
|
160
|
-
path TEXT,
|
|
161
|
-
updated_ms INTEGER NOT NULL,
|
|
162
|
-
timestamp_ms INTEGER NOT NULL
|
|
163
|
-
)`);
|
|
164
|
-
db.run('CREATE INDEX IF NOT EXISTS idx_trajectories_timestamp ON trajectories(timestamp_ms DESC)');
|
|
165
|
-
db.run('CREATE INDEX IF NOT EXISTS idx_trajectories_project ON trajectories(project_id)');
|
|
26
|
+
export class NativeContractMismatchError extends RelayHistoryError {
|
|
166
27
|
}
|
|
167
|
-
|
|
168
|
-
db.run(`CREATE TABLE IF NOT EXISTS tags (
|
|
169
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
170
|
-
name TEXT NOT NULL UNIQUE,
|
|
171
|
-
display_name TEXT NOT NULL,
|
|
172
|
-
color TEXT,
|
|
173
|
-
created_ms INTEGER NOT NULL,
|
|
174
|
-
updated_ms INTEGER NOT NULL
|
|
175
|
-
)`);
|
|
176
|
-
db.run(`CREATE TABLE IF NOT EXISTS session_tags (
|
|
177
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
178
|
-
source TEXT NOT NULL,
|
|
179
|
-
session_id TEXT NOT NULL,
|
|
180
|
-
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
181
|
-
created_ms INTEGER NOT NULL,
|
|
182
|
-
UNIQUE(source, session_id, tag_id)
|
|
183
|
-
)`);
|
|
184
|
-
db.run('CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name)');
|
|
185
|
-
db.run('CREATE INDEX IF NOT EXISTS idx_session_tags_session ON session_tags(source, session_id)');
|
|
186
|
-
db.run('CREATE INDEX IF NOT EXISTS idx_session_tags_tag ON session_tags(tag_id)');
|
|
28
|
+
export class DatabaseOpenError extends RelayHistoryError {
|
|
187
29
|
}
|
|
188
|
-
|
|
189
|
-
* Open an `AiHist` reader. Async because sql.js initializes its WASM
|
|
190
|
-
* runtime lazily and the DB file is read asynchronously so the host
|
|
191
|
-
* process's event loop isn't blocked.
|
|
192
|
-
*
|
|
193
|
-
* Each call snapshots the data; to pick up later writes, call `reload()`
|
|
194
|
-
* (or open a fresh instance).
|
|
195
|
-
*/
|
|
196
|
-
export async function openAiHist(opts = {}) {
|
|
197
|
-
const dbPath = opts.dbPath ?? defaultDbPath();
|
|
198
|
-
const fallback = opts.fallback ?? 'jsonl';
|
|
199
|
-
const SQL = await getSqlJs();
|
|
200
|
-
// Fast path: SQLite written by `ai-hist sync`.
|
|
201
|
-
if (await pathExists(dbPath)) {
|
|
202
|
-
const fileBuffer = await readFile(dbPath);
|
|
203
|
-
const db = new SQL.Database(fileBuffer);
|
|
204
|
-
ensureTrajectorySchema(db);
|
|
205
|
-
ensureTagSchema(db);
|
|
206
|
-
ensureSessionsSchema(db);
|
|
207
|
-
ensureDiscoverySkipsSchema(db);
|
|
208
|
-
// Add git_branch to history if missing (pre-handoff DBs lack this column).
|
|
209
|
-
try {
|
|
210
|
-
db.run('ALTER TABLE history ADD COLUMN git_branch TEXT');
|
|
211
|
-
}
|
|
212
|
-
catch { /* already exists */ }
|
|
213
|
-
// Older database schemas don't contain `idx_history_session` or
|
|
214
|
-
// `idx_history_timestamp`. Without them, listSessions degrades to
|
|
215
|
-
// an O(sessions × rows) full table scan and freezes the WASM
|
|
216
|
-
// single-threaded JS engine for tens of seconds on real-sized DBs.
|
|
217
|
-
// The DB is opened read-only over a buffer, but sql.js still lets
|
|
218
|
-
// us run `CREATE INDEX` against the in-memory copy — the index
|
|
219
|
-
// lives only for this session's lifetime and is rebuilt each
|
|
220
|
-
// `openAiHist` call. Fast (~30ms on 35K rows).
|
|
221
|
-
db.run('CREATE INDEX IF NOT EXISTS idx_history_session ON history(session_id)');
|
|
222
|
-
db.run('CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp_ms DESC)');
|
|
223
|
-
return new AiHist(db, { kind: 'sqlite', path: dbPath }, { projectScope: opts.projectScope });
|
|
224
|
-
}
|
|
225
|
-
if (fallback === 'error') {
|
|
226
|
-
throw new Error(`ai-hist database not found at ${dbPath}. Run \`ai-hist sync\` first ` +
|
|
227
|
-
`(see https://github.com/AgentWorkforce/ai-hist).`);
|
|
228
|
-
}
|
|
229
|
-
// Fallback: scan local source files (Claude/Codex/Cursor/Grok) directly.
|
|
230
|
-
// Uses TypeScript implementations of the source parsers. Yields control to
|
|
231
|
-
// the event loop between sources so a
|
|
232
|
-
// large local history doesn't freeze the host.
|
|
233
|
-
const db = new SQL.Database();
|
|
234
|
-
db.run(`CREATE TABLE history (
|
|
235
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
236
|
-
source TEXT NOT NULL,
|
|
237
|
-
session_id TEXT,
|
|
238
|
-
project TEXT,
|
|
239
|
-
prompt TEXT NOT NULL,
|
|
240
|
-
timestamp_ms INTEGER NOT NULL,
|
|
241
|
-
git_branch TEXT,
|
|
242
|
-
UNIQUE(source, timestamp_ms, prompt)
|
|
243
|
-
)`);
|
|
244
|
-
db.run('CREATE INDEX idx_history_timestamp ON history (timestamp_ms DESC)');
|
|
245
|
-
db.run('CREATE INDEX idx_history_session ON history (session_id)');
|
|
246
|
-
ensureTrajectorySchema(db);
|
|
247
|
-
ensureTagSchema(db);
|
|
248
|
-
ensureSessionsSchema(db);
|
|
249
|
-
ensureDiscoverySkipsSchema(db);
|
|
250
|
-
// scanLocalSources is async with yields between sources so the event
|
|
251
|
-
// loop stays responsive while we scan many MB of JSONL.
|
|
252
|
-
const rows = await scanLocalSources();
|
|
253
|
-
const openCodeRows = await scanOpenCode(SQL);
|
|
254
|
-
const trajectories = await scanLocalTrajectories();
|
|
255
|
-
const insert = db.prepare('INSERT OR IGNORE INTO history (source, session_id, project, prompt, timestamp_ms, git_branch) VALUES (?, ?, ?, ?, ?, ?)');
|
|
256
|
-
const insertTrajectory = db.prepare(`INSERT OR REPLACE INTO trajectories
|
|
257
|
-
(id, version, persona_id, project_id, task_title, task_description, status,
|
|
258
|
-
started_at, completed_at, decisions_json, retrospective_json, search_text,
|
|
259
|
-
path, updated_ms, timestamp_ms)
|
|
260
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
261
|
-
try {
|
|
262
|
-
db.exec('BEGIN');
|
|
263
|
-
for (const row of rows) {
|
|
264
|
-
insert.run([row.source, row.sessionId, row.project, row.prompt, row.timestampMs, row.gitBranch]);
|
|
265
|
-
}
|
|
266
|
-
for (const row of openCodeRows) {
|
|
267
|
-
insert.run(['opencode', row.sessionId, row.project, row.prompt, row.timestampMs, null]);
|
|
268
|
-
}
|
|
269
|
-
for (const trajectory of trajectories) {
|
|
270
|
-
insertTrajectory.run([
|
|
271
|
-
trajectory.id,
|
|
272
|
-
trajectory.version,
|
|
273
|
-
trajectory.personaId,
|
|
274
|
-
trajectory.projectId,
|
|
275
|
-
trajectory.task.title,
|
|
276
|
-
trajectory.task.description,
|
|
277
|
-
trajectory.status,
|
|
278
|
-
trajectory.startedAt,
|
|
279
|
-
trajectory.completedAt,
|
|
280
|
-
JSON.stringify(trajectory.decisions),
|
|
281
|
-
JSON.stringify(trajectory.retrospective),
|
|
282
|
-
trajectory.searchText,
|
|
283
|
-
trajectory.path,
|
|
284
|
-
trajectory.updatedMs,
|
|
285
|
-
trajectory.timestampMs,
|
|
286
|
-
]);
|
|
287
|
-
insert.run([
|
|
288
|
-
'trajectory',
|
|
289
|
-
trajectory.id,
|
|
290
|
-
trajectory.projectId,
|
|
291
|
-
trajectory.searchText,
|
|
292
|
-
trajectory.timestampMs,
|
|
293
|
-
null,
|
|
294
|
-
]);
|
|
295
|
-
}
|
|
296
|
-
db.exec('COMMIT');
|
|
297
|
-
}
|
|
298
|
-
finally {
|
|
299
|
-
insert.free();
|
|
300
|
-
insertTrajectory.free();
|
|
301
|
-
}
|
|
302
|
-
const scannedPaths = `${LOCAL_SOURCE_PATHS.claude}, ${LOCAL_SOURCE_PATHS.codex}, ${LOCAL_SOURCE_PATHS.cursorRoot}, ${LOCAL_SOURCE_PATHS.grokSessionsRoot}, ${trajectoryRootDescription()}`;
|
|
303
|
-
return new AiHist(db, { kind: 'jsonl', path: scannedPaths }, { projectScope: opts.projectScope });
|
|
30
|
+
export class InvalidArgumentError extends RelayHistoryError {
|
|
304
31
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
32
|
+
const SUPPORTED_PLATFORMS = new Set([
|
|
33
|
+
'darwin-arm64', 'darwin-x64',
|
|
34
|
+
'linux-arm64-gnu', 'linux-arm64-musl',
|
|
35
|
+
'linux-x64-gnu', 'linux-x64-musl',
|
|
36
|
+
'win32-x64-msvc',
|
|
37
|
+
]);
|
|
38
|
+
function linuxLibc() {
|
|
39
|
+
const report = process.report?.getReport();
|
|
40
|
+
return report?.header?.glibcVersionRuntime ? 'gnu' : 'musl';
|
|
313
41
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (
|
|
318
|
-
return
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
maxBuffer: 1024 * 1024,
|
|
326
|
-
});
|
|
327
|
-
return await readFile(snapshot);
|
|
328
|
-
}
|
|
329
|
-
finally {
|
|
330
|
-
await rm(dir, { recursive: true, force: true });
|
|
42
|
+
export function runtimePlatform() {
|
|
43
|
+
if (process.platform === 'linux')
|
|
44
|
+
return `linux-${process.arch}-${linuxLibc()}`;
|
|
45
|
+
if (process.platform === 'win32')
|
|
46
|
+
return `win32-${process.arch}-msvc`;
|
|
47
|
+
return `${process.platform}-${process.arch}`;
|
|
48
|
+
}
|
|
49
|
+
let nativePromise = null;
|
|
50
|
+
export function validateNativeContract(actual) {
|
|
51
|
+
if (actual !== NATIVE_CONTRACT_VERSION) {
|
|
52
|
+
throw new NativeContractMismatchError(`ai-hist requires native contract ${NATIVE_CONTRACT_VERSION}, but ai-hist-native provides ${actual}. Reinstall matching versions.`, 'NATIVE_CONTRACT_MISMATCH');
|
|
331
53
|
}
|
|
332
54
|
}
|
|
333
|
-
async function
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
55
|
+
async function loadNative() {
|
|
56
|
+
if (nativePromise)
|
|
57
|
+
return nativePromise;
|
|
58
|
+
nativePromise = (async () => {
|
|
59
|
+
const platform = runtimePlatform();
|
|
60
|
+
if (!SUPPORTED_PLATFORMS.has(platform)) {
|
|
61
|
+
throw new UnsupportedPlatformError(`RelayHistory has no native build for ${platform}. Supported platforms: ${[...SUPPORTED_PLATFORMS].join(', ')}.`, 'UNSUPPORTED_PLATFORM');
|
|
62
|
+
}
|
|
63
|
+
let loaded;
|
|
340
64
|
try {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
for (const row of rows) {
|
|
351
|
-
const data = parseJson(row.data, {});
|
|
352
|
-
const prompt = typeof data.text === 'string' ? data.text.trim() : '';
|
|
353
|
-
if (!prompt)
|
|
354
|
-
continue;
|
|
355
|
-
scanned.push({
|
|
356
|
-
sessionId: row.session_id,
|
|
357
|
-
project: row.project,
|
|
358
|
-
prompt,
|
|
359
|
-
timestampMs: row.timestamp_ms ?? 0,
|
|
360
|
-
});
|
|
65
|
+
// Kept as a variable so TypeScript does not require native build-time
|
|
66
|
+
// declarations; npm installs this mandatory production dependency.
|
|
67
|
+
const packageName = 'ai-hist-native';
|
|
68
|
+
loaded = await import(packageName);
|
|
69
|
+
}
|
|
70
|
+
catch (cause) {
|
|
71
|
+
const error = cause;
|
|
72
|
+
if (error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'MODULE_NOT_FOUND') {
|
|
73
|
+
throw new NativePackageMissingError(`RelayHistory supports ${platform}, but its native package is missing. Reinstall ai-hist with optional dependencies enabled.`, 'NATIVE_PACKAGE_MISSING', { cause });
|
|
361
74
|
}
|
|
362
|
-
|
|
75
|
+
throw new NativeLoadError(`RelayHistory's native package for ${platform} failed to load: ${error.message}`, 'NATIVE_LOAD_FAILED', { cause });
|
|
76
|
+
}
|
|
77
|
+
const binding = (loaded.default ?? loaded);
|
|
78
|
+
if (typeof binding.nativeContractVersion !== 'function') {
|
|
79
|
+
throw new NativeContractMismatchError('The installed ai-hist-native package does not expose a contract version. Reinstall matching ai-hist packages.', 'NATIVE_CONTRACT_MISMATCH');
|
|
80
|
+
}
|
|
81
|
+
validateNativeContract(binding.nativeContractVersion());
|
|
82
|
+
return binding;
|
|
83
|
+
})();
|
|
84
|
+
return nativePromise.catch((error) => {
|
|
85
|
+
nativePromise = null;
|
|
86
|
+
throw error;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function nativeMessage(error) {
|
|
90
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
91
|
+
const match = message.match(/RELAYHISTORY_NATIVE::([A-Z_]+)::([\s\S]*)/);
|
|
92
|
+
return match ? { code: match[1], message: match[2] } : null;
|
|
93
|
+
}
|
|
94
|
+
async function nativeCall(call) {
|
|
95
|
+
try {
|
|
96
|
+
return await call(await loadNative());
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (error instanceof RelayHistoryError)
|
|
100
|
+
throw error;
|
|
101
|
+
const native = nativeMessage(error);
|
|
102
|
+
if (!native)
|
|
103
|
+
throw new NativeLoadError(String(error), 'NATIVE_CALL_FAILED', { cause: error });
|
|
104
|
+
if (native.code === 'DATABASE_OPEN_FAILED') {
|
|
105
|
+
throw new DatabaseOpenError(native.message, native.code, { cause: error });
|
|
363
106
|
}
|
|
364
|
-
|
|
365
|
-
|
|
107
|
+
if (native.code === 'INVALID_ARGUMENT') {
|
|
108
|
+
throw new InvalidArgumentError(native.message, native.code, { cause: error });
|
|
366
109
|
}
|
|
367
|
-
|
|
368
|
-
catch {
|
|
369
|
-
return [];
|
|
110
|
+
throw new RelayHistoryError(native.message, native.code, { cause: error });
|
|
370
111
|
}
|
|
371
112
|
}
|
|
372
|
-
function
|
|
373
|
-
return
|
|
374
|
-
id: row.id,
|
|
375
|
-
source: row.source,
|
|
376
|
-
sessionId: row.session_id,
|
|
377
|
-
project: row.project,
|
|
378
|
-
prompt: row.prompt,
|
|
379
|
-
timestampMs: row.timestamp_ms,
|
|
380
|
-
gitBranch: row.git_branch ?? null,
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
function parseJson(raw, fallback) {
|
|
384
|
-
try {
|
|
385
|
-
return JSON.parse(raw);
|
|
386
|
-
}
|
|
387
|
-
catch {
|
|
388
|
-
return fallback;
|
|
389
|
-
}
|
|
113
|
+
function nullableString(value) {
|
|
114
|
+
return typeof value === 'string' ? value : null;
|
|
390
115
|
}
|
|
391
|
-
function
|
|
116
|
+
function historyEntry(value) {
|
|
392
117
|
return {
|
|
393
|
-
id:
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
description: row.task_description,
|
|
400
|
-
},
|
|
401
|
-
status: row.status,
|
|
402
|
-
startedAt: row.started_at,
|
|
403
|
-
completedAt: row.completed_at,
|
|
404
|
-
decisions: parseJson(row.decisions_json, []),
|
|
405
|
-
retrospective: parseJson(row.retrospective_json, {
|
|
406
|
-
summary: null,
|
|
407
|
-
approach: null,
|
|
408
|
-
learnings: [],
|
|
409
|
-
confidence: null,
|
|
410
|
-
}),
|
|
411
|
-
searchText: row.search_text,
|
|
412
|
-
path: row.path,
|
|
413
|
-
updatedMs: row.updated_ms,
|
|
414
|
-
timestampMs: row.timestamp_ms,
|
|
118
|
+
id: Number(value.id),
|
|
119
|
+
source: String(value.source),
|
|
120
|
+
sessionId: nullableString(value.sessionId),
|
|
121
|
+
project: nullableString(value.project),
|
|
122
|
+
prompt: String(value.prompt),
|
|
123
|
+
timestampMs: Number(value.timestampMs),
|
|
415
124
|
};
|
|
416
125
|
}
|
|
417
|
-
function
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
return trimmed.replace(/[\\/]+$/, '') || trimmed;
|
|
422
|
-
}
|
|
423
|
-
function escapeLike(value) {
|
|
424
|
-
return value.replace(/\|/g, '||').replace(/%/g, '|%').replace(/_/g, '|_');
|
|
425
|
-
}
|
|
426
|
-
function scopedPathClause(column, project) {
|
|
427
|
-
const normalized = normalizeProjectScope(project) ?? project;
|
|
428
|
-
const escaped = escapeLike(normalized);
|
|
429
|
-
const slashChildPattern = normalized === '/' ? '/%' : `${escaped}/%`;
|
|
430
|
-
const backslashChildPattern = normalized === '\\' ? '\\%' : `${escaped}\\%`;
|
|
126
|
+
function catalogCursor(value) {
|
|
127
|
+
if (!value || typeof value !== 'object')
|
|
128
|
+
return null;
|
|
129
|
+
const row = value;
|
|
431
130
|
return {
|
|
432
|
-
|
|
433
|
-
|
|
131
|
+
lastActivityMs: typeof row.lastActivityMs === 'number' ? row.lastActivityMs : null,
|
|
132
|
+
source: String(row.source),
|
|
133
|
+
sessionId: String(row.sessionId),
|
|
434
134
|
};
|
|
435
135
|
}
|
|
436
|
-
function
|
|
437
|
-
const normalized = normalizeProjectScope(project) ?? project;
|
|
438
|
-
const pathScope = scopedPathClause('path', normalized);
|
|
136
|
+
function catalogSession(value) {
|
|
439
137
|
return {
|
|
440
|
-
|
|
441
|
-
|
|
138
|
+
source: String(value.source),
|
|
139
|
+
sessionId: String(value.sessionId),
|
|
140
|
+
cwd: nullableString(value.cwd),
|
|
141
|
+
gitBranch: nullableString(value.gitBranch),
|
|
142
|
+
firstActivityMs: typeof value.firstActivityMs === 'number' ? value.firstActivityMs : null,
|
|
143
|
+
lastActivityMs: typeof value.lastActivityMs === 'number' ? value.lastActivityMs : null,
|
|
144
|
+
firstPrompt: nullableString(value.firstPrompt),
|
|
145
|
+
lastAssistantText: nullableString(value.lastAssistantText),
|
|
146
|
+
models: Array.isArray(value.models) ? value.models.map(String) : [],
|
|
147
|
+
originator: nullableString(value.originator),
|
|
148
|
+
agentVersion: nullableString(value.agentVersion),
|
|
149
|
+
repoUrl: nullableString(value.repoUrl),
|
|
150
|
+
initialCommit: nullableString(value.initialCommit),
|
|
151
|
+
workspaceRoots: Array.isArray(value.workspaceRoots) ? value.workspaceRoots.map(String) : [],
|
|
152
|
+
rawPath: nullableString(value.rawPath),
|
|
153
|
+
sourceStamp: nullableString(value.sourceStamp),
|
|
154
|
+
discoveryState: value.discoveryState === 'shallow' ? 'shallow' : 'full',
|
|
155
|
+
fromCache: value.fromCache === true,
|
|
442
156
|
};
|
|
443
157
|
}
|
|
444
|
-
function
|
|
445
|
-
if (
|
|
446
|
-
|
|
447
|
-
clauses.push(scope.sql);
|
|
448
|
-
params.push(...scope.params);
|
|
449
|
-
}
|
|
450
|
-
if (project) {
|
|
451
|
-
clauses.push('project = ?');
|
|
452
|
-
params.push(project);
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
function normalizeTagName(tag) {
|
|
456
|
-
return tag.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
457
|
-
}
|
|
458
|
-
function appendTagFilter(clauses, params, tag, alias = 'history') {
|
|
459
|
-
const normalized = tag ? normalizeTagName(tag) : '';
|
|
460
|
-
if (!normalized)
|
|
461
|
-
return;
|
|
462
|
-
clauses.push(`EXISTS (
|
|
463
|
-
SELECT 1 FROM session_tags st
|
|
464
|
-
JOIN tags t ON t.id = st.tag_id
|
|
465
|
-
WHERE st.source = ${alias}.source
|
|
466
|
-
AND st.session_id = ${alias}.session_id
|
|
467
|
-
AND t.name = ?
|
|
468
|
-
)`);
|
|
469
|
-
params.push(normalized);
|
|
470
|
-
}
|
|
471
|
-
function buildFilters(opts, projectScope) {
|
|
472
|
-
const clauses = [];
|
|
473
|
-
const params = [];
|
|
474
|
-
if (opts.source) {
|
|
475
|
-
clauses.push('source = ?');
|
|
476
|
-
params.push(opts.source);
|
|
158
|
+
function assertCatalogContract(value) {
|
|
159
|
+
if (value !== SESSION_CATALOG_CONTRACT_VERSION) {
|
|
160
|
+
throw new NativeContractMismatchError(`ai-hist expects catalog contract ${SESSION_CATALOG_CONTRACT_VERSION}, but native returned ${value}.`, 'CATALOG_CONTRACT_MISMATCH');
|
|
477
161
|
}
|
|
478
|
-
appendProjectFilter(clauses, params, opts.project, projectScope);
|
|
479
|
-
appendTagFilter(clauses, params, opts.tag, 'history');
|
|
480
|
-
if (typeof opts.beforeMs === 'number') {
|
|
481
|
-
clauses.push('timestamp_ms < ?');
|
|
482
|
-
params.push(opts.beforeMs);
|
|
483
|
-
}
|
|
484
|
-
return {
|
|
485
|
-
sql: clauses.length > 0 ? ` AND ${clauses.join(' AND ')}` : '',
|
|
486
|
-
params,
|
|
487
|
-
};
|
|
488
162
|
}
|
|
489
|
-
function
|
|
490
|
-
|
|
491
|
-
}
|
|
492
|
-
function parseTokenJson(raw) {
|
|
493
|
-
if (!raw)
|
|
163
|
+
function tokenUsage(raw) {
|
|
164
|
+
if (typeof raw !== 'string')
|
|
494
165
|
return null;
|
|
495
166
|
try {
|
|
496
167
|
const parsed = JSON.parse(raw);
|
|
497
|
-
return typeof parsed === 'object' && parsed
|
|
168
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
498
169
|
}
|
|
499
170
|
catch {
|
|
500
171
|
return null;
|
|
501
172
|
}
|
|
502
173
|
}
|
|
503
|
-
function
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
}
|
|
513
|
-
finally {
|
|
514
|
-
stmt.free();
|
|
515
|
-
}
|
|
174
|
+
function sessionEvent(value) {
|
|
175
|
+
return {
|
|
176
|
+
id: Number(value.id), source: String(value.source), sessionId: String(value.sessionId),
|
|
177
|
+
project: nullableString(value.project), cwd: nullableString(value.cwd), gitBranch: nullableString(value.gitBranch),
|
|
178
|
+
messageId: nullableString(value.messageId), parentId: nullableString(value.parentId), tsMs: Number(value.tsMs),
|
|
179
|
+
role: String(value.role), kind: String(value.kind),
|
|
180
|
+
text: nullableString(value.text), model: nullableString(value.model), tokenUsage: tokenUsage(value.tokenJson),
|
|
181
|
+
eventUid: String(value.eventUid),
|
|
182
|
+
};
|
|
516
183
|
}
|
|
517
|
-
export
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
/** @internal — use `openAiHist(...)` to construct. */
|
|
523
|
-
constructor(db, source, opts = {}) {
|
|
524
|
-
this.db = db;
|
|
525
|
-
this._source = source;
|
|
526
|
-
this._projectScope = normalizeProjectScope(opts.projectScope);
|
|
527
|
-
}
|
|
528
|
-
/**
|
|
529
|
-
* Path the data came from. SQLite mode: the .db path. JSONL fallback
|
|
530
|
-
* mode: a comma-separated list of the scanned source paths.
|
|
531
|
-
*/
|
|
532
|
-
get dbPath() {
|
|
533
|
-
return this._source.path;
|
|
534
|
-
}
|
|
535
|
-
/** Which data path was used: `'sqlite'` (on-disk SQLite database) or `'jsonl'` (fallback). */
|
|
536
|
-
get sourceKind() {
|
|
537
|
-
return this._source.kind;
|
|
538
|
-
}
|
|
539
|
-
/** Server/client-wide project scope applied to every read, if configured. */
|
|
540
|
-
get projectScope() {
|
|
541
|
-
return this._projectScope;
|
|
542
|
-
}
|
|
543
|
-
close() {
|
|
544
|
-
if (this.closed)
|
|
545
|
-
return;
|
|
546
|
-
this.db.close();
|
|
547
|
-
this.closed = true;
|
|
548
|
-
}
|
|
549
|
-
persistIfWritable() {
|
|
550
|
-
if (this._source.kind !== 'sqlite')
|
|
551
|
-
return;
|
|
552
|
-
writeFileSync(this._source.path, Buffer.from(this.db.export()));
|
|
553
|
-
}
|
|
554
|
-
ensureTag(name, color) {
|
|
555
|
-
const normalized = normalizeTagName(name);
|
|
556
|
-
if (!normalized)
|
|
557
|
-
throw new Error('tag name cannot be empty');
|
|
558
|
-
const displayName = name.trim();
|
|
559
|
-
const now = Date.now();
|
|
560
|
-
this.db.run(`INSERT INTO tags (name, display_name, color, created_ms, updated_ms)
|
|
561
|
-
VALUES (?, ?, ?, ?, ?)
|
|
562
|
-
ON CONFLICT(name) DO UPDATE SET
|
|
563
|
-
display_name = excluded.display_name,
|
|
564
|
-
color = COALESCE(excluded.color, tags.color),
|
|
565
|
-
updated_ms = excluded.updated_ms`, [normalized, displayName, color ?? null, now, now]);
|
|
566
|
-
return runQuery(this.db, 'SELECT id FROM tags WHERE name = ?', [normalized])[0].id;
|
|
567
|
-
}
|
|
568
|
-
matchingSessions(sessionId, source) {
|
|
569
|
-
const clauses = ['session_id = ?'];
|
|
570
|
-
const params = [sessionId];
|
|
571
|
-
if (source) {
|
|
572
|
-
clauses.push('source = ?');
|
|
573
|
-
params.push(source);
|
|
574
|
-
}
|
|
575
|
-
appendProjectFilter(clauses, params, undefined, this._projectScope);
|
|
576
|
-
return runQuery(this.db, `SELECT source, session_id, MIN(project) AS project, COUNT(*) AS entry_count,
|
|
577
|
-
MAX(timestamp_ms) AS last_activity_ms
|
|
578
|
-
FROM history
|
|
579
|
-
WHERE ${clauses.join(' AND ')}
|
|
580
|
-
GROUP BY source, session_id
|
|
581
|
-
ORDER BY source`, params).map((row) => ({
|
|
582
|
-
source: row.source,
|
|
583
|
-
sessionId: row.session_id,
|
|
584
|
-
project: row.project,
|
|
585
|
-
entryCount: row.entry_count,
|
|
586
|
-
lastActivityMs: row.last_activity_ms,
|
|
587
|
-
}));
|
|
588
|
-
}
|
|
589
|
-
tagSession(sessionId, tagName, opts = {}) {
|
|
590
|
-
const sessions = this.matchingSessions(sessionId, opts.source);
|
|
591
|
-
if (sessions.length === 0)
|
|
592
|
-
return [];
|
|
593
|
-
const tagId = this.ensureTag(tagName, opts.color);
|
|
594
|
-
const now = Date.now();
|
|
595
|
-
const insert = this.db.prepare('INSERT OR IGNORE INTO session_tags (source, session_id, tag_id, created_ms) VALUES (?, ?, ?, ?)');
|
|
596
|
-
try {
|
|
597
|
-
for (const session of sessions) {
|
|
598
|
-
insert.run([session.source, session.sessionId, tagId, now]);
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
finally {
|
|
602
|
-
insert.free();
|
|
603
|
-
}
|
|
604
|
-
this.persistIfWritable();
|
|
605
|
-
return sessions;
|
|
606
|
-
}
|
|
607
|
-
untagSession(sessionId, tagName, opts = {}) {
|
|
608
|
-
const normalized = normalizeTagName(tagName);
|
|
609
|
-
const sessions = this.matchingSessions(sessionId, opts.source);
|
|
610
|
-
let removed = 0;
|
|
611
|
-
for (const session of sessions) {
|
|
612
|
-
this.db.run(`DELETE FROM session_tags
|
|
613
|
-
WHERE source = ? AND session_id = ?
|
|
614
|
-
AND tag_id IN (SELECT id FROM tags WHERE name = ?)`, [session.source, session.sessionId, normalized]);
|
|
615
|
-
removed += this.db.getRowsModified();
|
|
616
|
-
}
|
|
617
|
-
this.persistIfWritable();
|
|
618
|
-
return removed;
|
|
619
|
-
}
|
|
620
|
-
listTags(opts = {}) {
|
|
621
|
-
const clauses = [];
|
|
622
|
-
const params = [];
|
|
623
|
-
if (opts.tag) {
|
|
624
|
-
clauses.push('t.name = ?');
|
|
625
|
-
params.push(normalizeTagName(opts.tag));
|
|
626
|
-
}
|
|
627
|
-
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
628
|
-
let scopedSessionSql = 'SELECT st.tag_id, st.id, st.created_ms FROM session_tags st';
|
|
629
|
-
const scopedSessionParams = [];
|
|
630
|
-
if (this._projectScope) {
|
|
631
|
-
const scope = scopedPathClause('h.project', this._projectScope);
|
|
632
|
-
scopedSessionSql = `SELECT st.tag_id, st.id, st.created_ms
|
|
633
|
-
FROM session_tags st
|
|
634
|
-
WHERE EXISTS (
|
|
635
|
-
SELECT 1 FROM history h
|
|
636
|
-
WHERE h.source = st.source
|
|
637
|
-
AND h.session_id = st.session_id
|
|
638
|
-
AND ${scope.sql}
|
|
639
|
-
)`;
|
|
640
|
-
scopedSessionParams.push(...scope.params);
|
|
641
|
-
}
|
|
642
|
-
return runQuery(this.db, `SELECT t.name, t.display_name, t.color, COUNT(st.id) AS session_count,
|
|
643
|
-
MIN(st.created_ms) AS first_tagged_ms, MAX(st.created_ms) AS last_tagged_ms
|
|
644
|
-
FROM tags t
|
|
645
|
-
LEFT JOIN (${scopedSessionSql}) st ON st.tag_id = t.id
|
|
646
|
-
${where}
|
|
647
|
-
GROUP BY t.id, t.name, t.display_name, t.color
|
|
648
|
-
ORDER BY t.name`, [...scopedSessionParams, ...params]).map((row) => {
|
|
649
|
-
const tag = {
|
|
650
|
-
name: row.name,
|
|
651
|
-
displayName: row.display_name,
|
|
652
|
-
color: row.color,
|
|
653
|
-
sessionCount: row.session_count,
|
|
654
|
-
firstTaggedMs: row.first_tagged_ms,
|
|
655
|
-
lastTaggedMs: row.last_tagged_ms,
|
|
656
|
-
};
|
|
657
|
-
if (opts.includeSessions) {
|
|
658
|
-
tag.sessions = this.sessionsByTag(row.name);
|
|
659
|
-
}
|
|
660
|
-
return tag;
|
|
661
|
-
});
|
|
662
|
-
}
|
|
663
|
-
sessionsByTag(tagName) {
|
|
664
|
-
const clauses = ['t.name = ?'];
|
|
665
|
-
const params = [normalizeTagName(tagName)];
|
|
666
|
-
if (this._projectScope) {
|
|
667
|
-
const scope = scopedPathClause('h.project', this._projectScope);
|
|
668
|
-
clauses.push(scope.sql);
|
|
669
|
-
params.push(...scope.params);
|
|
670
|
-
}
|
|
671
|
-
return runQuery(this.db, `SELECT st.source, st.session_id, MIN(h.project) AS project, COUNT(h.id) AS entry_count,
|
|
672
|
-
MAX(h.timestamp_ms) AS last_activity_ms
|
|
673
|
-
FROM session_tags st
|
|
674
|
-
JOIN tags t ON t.id = st.tag_id
|
|
675
|
-
JOIN history h ON h.source = st.source AND h.session_id = st.session_id
|
|
676
|
-
WHERE ${clauses.join(' AND ')}
|
|
677
|
-
GROUP BY st.source, st.session_id
|
|
678
|
-
ORDER BY MAX(h.timestamp_ms) DESC`, params).map((row) => ({
|
|
679
|
-
source: row.source,
|
|
680
|
-
sessionId: row.session_id,
|
|
681
|
-
project: row.project,
|
|
682
|
-
entryCount: row.entry_count,
|
|
683
|
-
lastActivityMs: row.last_activity_ms,
|
|
684
|
-
}));
|
|
685
|
-
}
|
|
686
|
-
searchByTag(tagName, opts = {}) {
|
|
687
|
-
return this.recent({ ...opts, tag: tagName });
|
|
688
|
-
}
|
|
689
|
-
/** Most recent prompts, newest first. */
|
|
690
|
-
recent(opts = {}) {
|
|
691
|
-
const limit = opts.limit ?? 50;
|
|
692
|
-
const { sql, params } = buildFilters(opts, this._projectScope);
|
|
693
|
-
return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
|
|
694
|
-
FROM history
|
|
695
|
-
WHERE 1=1${sql}
|
|
696
|
-
ORDER BY timestamp_ms DESC
|
|
697
|
-
LIMIT ?`, [...params, limit]).map(rowToEntry);
|
|
698
|
-
}
|
|
699
|
-
/**
|
|
700
|
-
* Group history into sessions, ordered by last activity (newest first).
|
|
701
|
-
* Sessions without a `session_id` are skipped.
|
|
702
|
-
*
|
|
703
|
-
* This derives sessions from the `history` table, so it only ever sees
|
|
704
|
-
* sessions a full `ai-hist sync` has ingested, and it pays a window-function
|
|
705
|
-
* pass over history to do it. For the fast and complete path — every session
|
|
706
|
-
* the catalog knows, including ones only shallow discovery has seen, with
|
|
707
|
-
* cwd, branch, models, originator and repo identity attached — use
|
|
708
|
-
* {@link AiHist.listSessionCatalog}. This method stays as-is for callers
|
|
709
|
-
* that want prompt counts and the project-derived grouping.
|
|
710
|
-
*
|
|
711
|
-
* Implementation note: this used to use a correlated scalar subquery
|
|
712
|
-
* to pick `first_prompt`, which ran in O(sessions × rows) — ~19s on a
|
|
713
|
-
* 35K-row DB. Switched to `ROW_NUMBER() OVER (PARTITION BY session_id
|
|
714
|
-
* ORDER BY timestamp_ms)` so first-prompt picking is a single pass
|
|
715
|
-
* over the table (~300ms on the same DB). Plus the index ensure step
|
|
716
|
-
* in `openAiHist` keeps it fast even when the DB was written by an
|
|
717
|
-
* older CLI that didn't create `idx_history_session`.
|
|
718
|
-
*/
|
|
719
|
-
listSessions(opts = {}) {
|
|
720
|
-
const limit = opts.limit ?? 50;
|
|
721
|
-
const { sql, params } = buildFilters(opts, this._projectScope);
|
|
722
|
-
const rows = runQuery(this.db, `WITH filtered AS (
|
|
723
|
-
SELECT id, source, session_id, project, prompt, timestamp_ms
|
|
724
|
-
FROM history
|
|
725
|
-
WHERE session_id IS NOT NULL AND session_id != ''${sql}
|
|
726
|
-
),
|
|
727
|
-
ranked AS (
|
|
728
|
-
SELECT
|
|
729
|
-
session_id,
|
|
730
|
-
source,
|
|
731
|
-
project,
|
|
732
|
-
prompt,
|
|
733
|
-
timestamp_ms,
|
|
734
|
-
ROW_NUMBER() OVER (
|
|
735
|
-
PARTITION BY session_id, source, project
|
|
736
|
-
ORDER BY timestamp_ms ASC, id ASC
|
|
737
|
-
) AS rn_first,
|
|
738
|
-
COUNT(*) OVER (PARTITION BY session_id, source, project) AS prompt_count,
|
|
739
|
-
MIN(timestamp_ms) OVER (PARTITION BY session_id, source, project) AS first_activity_ms,
|
|
740
|
-
MAX(timestamp_ms) OVER (PARTITION BY session_id, source, project) AS last_activity_ms
|
|
741
|
-
FROM filtered
|
|
742
|
-
)
|
|
743
|
-
SELECT
|
|
744
|
-
session_id,
|
|
745
|
-
source,
|
|
746
|
-
project,
|
|
747
|
-
prompt AS first_prompt,
|
|
748
|
-
first_activity_ms,
|
|
749
|
-
last_activity_ms,
|
|
750
|
-
prompt_count
|
|
751
|
-
FROM ranked
|
|
752
|
-
WHERE rn_first = 1
|
|
753
|
-
ORDER BY last_activity_ms DESC
|
|
754
|
-
LIMIT ?`, [...params, limit]);
|
|
755
|
-
return rows.map((row) => ({
|
|
756
|
-
sessionId: row.session_id,
|
|
757
|
-
source: row.source,
|
|
758
|
-
project: row.project,
|
|
759
|
-
firstPrompt: row.first_prompt,
|
|
760
|
-
lastActivityMs: row.last_activity_ms,
|
|
761
|
-
firstActivityMs: row.first_activity_ms,
|
|
762
|
-
promptCount: row.prompt_count,
|
|
763
|
-
}));
|
|
764
|
-
}
|
|
765
|
-
/**
|
|
766
|
-
* The session catalog, newest first — one indexed query over `sessions` and
|
|
767
|
-
* nothing else.
|
|
768
|
-
*
|
|
769
|
-
* No provider transcript is opened and neither `history` nor
|
|
770
|
-
* `session_events` is touched, so this stays fast on first paint even with
|
|
771
|
-
* thousands of historical sessions. It is the read side of the native
|
|
772
|
-
* `ai-hist sessions list`; populate the catalog with `discoverSessions()`
|
|
773
|
-
* (or a full `ai-hist sync`).
|
|
774
|
-
*
|
|
775
|
-
* Details worth knowing:
|
|
776
|
-
* - `trajectory` rows are excluded defensively — trajectories are derived
|
|
777
|
-
* records, not sessions, and must never appear in a session list.
|
|
778
|
-
* - The catalog's total order is
|
|
779
|
-
* `(last_activity_ms DESC, source ASC, session_id ASC)`. SQLite sorts
|
|
780
|
-
* NULL lowest, so rows of unknown recency land last under `DESC` without
|
|
781
|
-
* a helper expression. Recency alone is not a key: one discovery pass can
|
|
782
|
-
* stamp many sessions with the same mtime-derived millisecond, which is
|
|
783
|
-
* why the identity columns are part of the order.
|
|
784
|
-
* - To page, use {@link AiHist.listSessionCatalogPage} and follow its
|
|
785
|
-
* `nextCursor`. `beforeMs` is only a *coarse* cutoff ("anything before
|
|
786
|
-
* last Tuesday"): it cannot separate rows sharing a millisecond, so a
|
|
787
|
-
* walk built on it drops every row tied with a page boundary. It is
|
|
788
|
-
* ignored when `after` is set.
|
|
789
|
-
* - A configured `projectScope` constrains rows by `cwd`, like
|
|
790
|
-
* `getHandoff`. Sources with no working directory (relay) therefore drop
|
|
791
|
-
* out of a scoped listing.
|
|
792
|
-
* - In JSONL fallback mode (no SQLite database) the catalog is empty and
|
|
793
|
-
* this returns `[]`: the fallback scan builds `history` rows only, and
|
|
794
|
-
* only the native discovery engine writes the catalog.
|
|
795
|
-
*/
|
|
796
|
-
listSessionCatalog(opts = {}) {
|
|
797
|
-
if (!tableExists(this.db, 'sessions'))
|
|
798
|
-
return [];
|
|
799
|
-
// SQLite reads a negative LIMIT as "unlimited", so a negative cap would
|
|
800
|
-
// quietly dump the whole catalog. The native CLI rejects it; so do we.
|
|
801
|
-
if (typeof opts.limit === 'number' && opts.limit < 0) {
|
|
802
|
-
throw new RangeError(`limit must not be negative (got ${opts.limit})`);
|
|
803
|
-
}
|
|
804
|
-
const limit = opts.limit ?? 50;
|
|
805
|
-
const clauses = ["source != 'trajectory'"];
|
|
806
|
-
const params = [];
|
|
807
|
-
const sources = [...(opts.sources ?? [])].map((source) => String(source));
|
|
808
|
-
if (sources.length > 0) {
|
|
809
|
-
clauses.push(`source IN (${sources.map(() => '?').join(', ')})`);
|
|
810
|
-
params.push(...sources);
|
|
811
|
-
}
|
|
812
|
-
if (opts.after) {
|
|
813
|
-
// Everything strictly after the cursor in the catalog's total order.
|
|
814
|
-
// Undated rows sort last, so a dated cursor must still reach them.
|
|
815
|
-
const { lastActivityMs, source, sessionId } = opts.after;
|
|
816
|
-
// A timestamp alone is not a cursor: without the identity columns the
|
|
817
|
-
// predicate cannot separate rows sharing a millisecond, and silently
|
|
818
|
-
// dropping the cursor would restart the walk at page one. The native CLI
|
|
819
|
-
// rejects the same half-cursor; so does this, rather than looping.
|
|
820
|
-
if (typeof source !== 'string' || source.length === 0 || typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
821
|
-
throw new TypeError('after must carry both source and sessionId — pass the whole nextCursor object from the previous page');
|
|
822
|
-
}
|
|
823
|
-
if (typeof lastActivityMs === 'number') {
|
|
824
|
-
clauses.push(`(last_activity_ms IS NULL OR last_activity_ms < ?
|
|
825
|
-
OR (last_activity_ms = ?
|
|
826
|
-
AND (source > ? OR (source = ? AND session_id > ?))))`);
|
|
827
|
-
params.push(lastActivityMs, lastActivityMs, source, source, sessionId);
|
|
828
|
-
}
|
|
829
|
-
else {
|
|
830
|
-
clauses.push(`last_activity_ms IS NULL AND (source > ? OR (source = ? AND session_id > ?))`);
|
|
831
|
-
params.push(source, source, sessionId);
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
else if (typeof opts.beforeMs === 'number') {
|
|
835
|
-
clauses.push('last_activity_ms < ?');
|
|
836
|
-
params.push(opts.beforeMs);
|
|
837
|
-
}
|
|
838
|
-
if (this._projectScope) {
|
|
839
|
-
const scope = scopedPathClause('cwd', this._projectScope);
|
|
840
|
-
clauses.push(scope.sql);
|
|
841
|
-
params.push(...scope.params);
|
|
842
|
-
}
|
|
843
|
-
return runQuery(this.db, `SELECT source, session_id, cwd, git_branch, first_activity_ms, last_activity_ms,
|
|
844
|
-
first_prompt, last_assistant_text, models_json, originator, agent_version,
|
|
845
|
-
repo_url, initial_commit, workspace_roots_json, raw_path, source_stamp,
|
|
846
|
-
discovery_state, parser_version
|
|
847
|
-
FROM sessions
|
|
848
|
-
WHERE ${clauses.join(' AND ')}
|
|
849
|
-
ORDER BY last_activity_ms DESC, source ASC, session_id ASC
|
|
850
|
-
LIMIT ?`, [...params, limit]).map((row) => catalogSessionFromJson({ ...row, from_cache: true }));
|
|
851
|
-
}
|
|
852
|
-
/**
|
|
853
|
-
* {@link AiHist.listSessionCatalog} plus the cursor that continues it.
|
|
854
|
-
*
|
|
855
|
-
* Follow `nextCursor` until it comes back `null` to walk the whole catalog:
|
|
856
|
-
* no skipped and no duplicated rows, even when a whole page shares one
|
|
857
|
-
* millisecond, and the undated tail stays reachable from a dated cursor.
|
|
858
|
-
* `nextCursor` is non-null only when the page filled its limit, mirroring
|
|
859
|
-
* the native `list_session_catalog_page` and the CLI's `next_cursor`.
|
|
860
|
-
*/
|
|
861
|
-
listSessionCatalogPage(opts = {}) {
|
|
862
|
-
const sessions = this.listSessionCatalog(opts);
|
|
863
|
-
const limit = opts.limit ?? 50;
|
|
864
|
-
const last = sessions[sessions.length - 1];
|
|
865
|
-
const nextCursor = limit > 0 && sessions.length >= limit && last
|
|
866
|
-
? { lastActivityMs: last.lastActivityMs, source: last.source, sessionId: last.sessionId }
|
|
867
|
-
: null;
|
|
868
|
-
return { sessions, nextCursor };
|
|
869
|
-
}
|
|
870
|
-
/** All prompts in a session, ordered oldest → newest. */
|
|
871
|
-
getSession(sessionId, opts = {}) {
|
|
872
|
-
const clauses = ['session_id = ?'];
|
|
873
|
-
const params = [sessionId];
|
|
874
|
-
if (opts.source) {
|
|
875
|
-
clauses.push('source = ?');
|
|
876
|
-
params.push(opts.source);
|
|
877
|
-
}
|
|
878
|
-
appendProjectFilter(clauses, params, undefined, this._projectScope);
|
|
879
|
-
appendTagFilter(clauses, params, opts.tag, 'history');
|
|
880
|
-
return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
|
|
881
|
-
FROM history
|
|
882
|
-
WHERE ${clauses.join(' AND ')}
|
|
883
|
-
ORDER BY timestamp_ms ASC`, params).map(rowToEntry);
|
|
884
|
-
}
|
|
885
|
-
/**
|
|
886
|
-
* All normalized events for a session, oldest first — messages, thinking,
|
|
887
|
-
* tool calls, and tool results with per-event token usage where the
|
|
888
|
-
* transcript provides it. Returns `[]` when the database predates the
|
|
889
|
-
* `session_events` table (or in JSONL fallback mode, which has no events).
|
|
890
|
-
*
|
|
891
|
-
* Size caveat: this SDK loads the whole database file into memory, so on
|
|
892
|
-
* large event-bearing databases prefer streaming from the native CLI
|
|
893
|
-
* (`ai-hist events <session-id> --json`).
|
|
894
|
-
*/
|
|
895
|
-
getSessionEvents(sessionId, opts = {}) {
|
|
896
|
-
if (!tableExists(this.db, 'session_events'))
|
|
897
|
-
return [];
|
|
898
|
-
const clauses = ['session_id = ?'];
|
|
899
|
-
const params = [sessionId];
|
|
900
|
-
if (opts.source) {
|
|
901
|
-
clauses.push('source = ?');
|
|
902
|
-
params.push(opts.source);
|
|
903
|
-
}
|
|
904
|
-
appendProjectFilter(clauses, params, undefined, this._projectScope);
|
|
905
|
-
return runQuery(this.db, `SELECT id, source, session_id, project, cwd, git_branch, message_id, parent_id,
|
|
906
|
-
ts_ms, role, kind, text, model, token_json, event_uid
|
|
907
|
-
FROM session_events
|
|
908
|
-
WHERE ${clauses.join(' AND ')}
|
|
909
|
-
ORDER BY ts_ms IS NULL, ts_ms ASC, id ASC`, params).map((row) => ({
|
|
910
|
-
id: row.id,
|
|
911
|
-
source: row.source,
|
|
912
|
-
sessionId: row.session_id,
|
|
913
|
-
project: row.project,
|
|
914
|
-
cwd: row.cwd,
|
|
915
|
-
gitBranch: row.git_branch,
|
|
916
|
-
messageId: row.message_id,
|
|
917
|
-
parentId: row.parent_id,
|
|
918
|
-
tsMs: row.ts_ms,
|
|
919
|
-
role: row.role,
|
|
920
|
-
kind: row.kind,
|
|
921
|
-
text: row.text,
|
|
922
|
-
model: row.model,
|
|
923
|
-
tokenUsage: parseTokenJson(row.token_json),
|
|
924
|
-
eventUid: row.event_uid,
|
|
925
|
-
}));
|
|
926
|
-
}
|
|
927
|
-
/** All tool calls for a session, oldest first; `[]` on pre-events databases. */
|
|
928
|
-
getToolCalls(sessionId, opts = {}) {
|
|
929
|
-
if (!tableExists(this.db, 'tool_calls'))
|
|
930
|
-
return [];
|
|
931
|
-
const clauses = ['session_id = ?'];
|
|
932
|
-
const params = [sessionId];
|
|
933
|
-
if (opts.source) {
|
|
934
|
-
clauses.push('source = ?');
|
|
935
|
-
params.push(opts.source);
|
|
936
|
-
}
|
|
937
|
-
// tool_calls has no project column; the configured scope constrains
|
|
938
|
-
// through the session's events. Without that table the scoped answer is
|
|
939
|
-
// unknowable — fail closed rather than throw.
|
|
940
|
-
if (this._projectScope) {
|
|
941
|
-
if (!tableExists(this.db, 'session_events'))
|
|
942
|
-
return [];
|
|
943
|
-
const scope = scopedPathClause('e.project', this._projectScope);
|
|
944
|
-
clauses.push(`EXISTS (SELECT 1 FROM session_events e
|
|
945
|
-
WHERE e.source = tool_calls.source AND e.session_id = tool_calls.session_id
|
|
946
|
-
AND ${scope.sql})`);
|
|
947
|
-
params.push(...scope.params);
|
|
948
|
-
}
|
|
949
|
-
return runQuery(this.db, `SELECT id, source, session_id, message_id, tool_use_id, name, target, args_json, is_error, ts_ms
|
|
950
|
-
FROM tool_calls
|
|
951
|
-
WHERE ${clauses.join(' AND ')}
|
|
952
|
-
ORDER BY ts_ms IS NULL, ts_ms ASC, id ASC`, params).map((row) => ({
|
|
953
|
-
id: row.id,
|
|
954
|
-
source: row.source,
|
|
955
|
-
sessionId: row.session_id,
|
|
956
|
-
messageId: row.message_id,
|
|
957
|
-
toolUseId: row.tool_use_id,
|
|
958
|
-
name: row.name,
|
|
959
|
-
target: row.target,
|
|
960
|
-
argsJson: row.args_json,
|
|
961
|
-
isError: row.is_error === null ? null : row.is_error !== 0,
|
|
962
|
-
tsMs: row.ts_ms,
|
|
963
|
-
}));
|
|
964
|
-
}
|
|
965
|
-
/**
|
|
966
|
-
* Substring search across prompt + project, case-insensitive, recent
|
|
967
|
-
* matches first. The native CLI uses FTS5; this SDK uses LIKE because
|
|
968
|
-
* sql.js's default WASM build doesn't ship the FTS5 module. Plenty fast
|
|
969
|
-
* for the ai-hist scale (~tens of thousands of rows); revisit if a
|
|
970
|
-
* future consumer needs phrase/boolean queries.
|
|
971
|
-
*
|
|
972
|
-
* The query is matched literally — `%` and `_` are escaped so users can
|
|
973
|
-
* search for them. Empty queries return recent entries matching the filters.
|
|
974
|
-
*/
|
|
975
|
-
search(query, opts = {}) {
|
|
976
|
-
const trimmed = query.trim();
|
|
977
|
-
const limit = opts.limit ?? 50;
|
|
978
|
-
const clauses = [];
|
|
979
|
-
const params = [];
|
|
980
|
-
if (trimmed) {
|
|
981
|
-
const escaped = trimmed.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
982
|
-
const pattern = `%${escaped}%`;
|
|
983
|
-
clauses.push(`(LOWER(prompt) LIKE LOWER(?) ESCAPE '\\' OR LOWER(COALESCE(project, '')) LIKE LOWER(?) ESCAPE '\\')`);
|
|
984
|
-
params.push(pattern, pattern);
|
|
985
|
-
}
|
|
986
|
-
if (opts.source) {
|
|
987
|
-
clauses.push('source = ?');
|
|
988
|
-
params.push(opts.source);
|
|
989
|
-
}
|
|
990
|
-
appendProjectFilter(clauses, params, opts.project, this._projectScope);
|
|
991
|
-
appendTagFilter(clauses, params, opts.tag, 'history');
|
|
992
|
-
if (typeof opts.beforeMs === 'number') {
|
|
993
|
-
clauses.push('timestamp_ms < ?');
|
|
994
|
-
params.push(opts.beforeMs);
|
|
995
|
-
}
|
|
996
|
-
return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
|
|
997
|
-
FROM history
|
|
998
|
-
WHERE ${clauses.length > 0 ? clauses.join(' AND ') : '1=1'}
|
|
999
|
-
ORDER BY timestamp_ms DESC
|
|
1000
|
-
LIMIT ?`, [...params, limit]).map(rowToEntry);
|
|
1001
|
-
}
|
|
1002
|
-
/** Search compacted per-run trajectory WHY: decisions and retrospectives. */
|
|
1003
|
-
searchTrajectories(query, opts = {}) {
|
|
1004
|
-
const trimmed = query.trim();
|
|
1005
|
-
if (!trimmed)
|
|
1006
|
-
return [];
|
|
1007
|
-
const limit = opts.limit ?? 20;
|
|
1008
|
-
const escaped = trimmed.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
1009
|
-
const pattern = `%${escaped}%`;
|
|
1010
|
-
const clauses = [
|
|
1011
|
-
`(LOWER(search_text) LIKE LOWER(?) ESCAPE '\\'
|
|
1012
|
-
OR LOWER(COALESCE(task_title, '')) LIKE LOWER(?) ESCAPE '\\'
|
|
1013
|
-
OR LOWER(COALESCE(task_description, '')) LIKE LOWER(?) ESCAPE '\\'
|
|
1014
|
-
OR LOWER(COALESCE(persona_id, '')) LIKE LOWER(?) ESCAPE '\\'
|
|
1015
|
-
OR LOWER(COALESCE(project_id, '')) LIKE LOWER(?) ESCAPE '\\')`,
|
|
1016
|
-
];
|
|
1017
|
-
const params = [pattern, pattern, pattern, pattern, pattern];
|
|
1018
|
-
for (const project of [this._projectScope, opts.project]) {
|
|
1019
|
-
if (!project)
|
|
1020
|
-
continue;
|
|
1021
|
-
const scope = scopedTrajectoryClause(project);
|
|
1022
|
-
clauses.push(scope.sql);
|
|
1023
|
-
params.push(...scope.params);
|
|
1024
|
-
}
|
|
1025
|
-
return runQuery(this.db, `SELECT id, version, persona_id, project_id, task_title, task_description, status,
|
|
1026
|
-
started_at, completed_at, decisions_json, retrospective_json, search_text,
|
|
1027
|
-
path, updated_ms, timestamp_ms
|
|
1028
|
-
FROM trajectories
|
|
1029
|
-
WHERE ${clauses.join(' AND ')}
|
|
1030
|
-
ORDER BY timestamp_ms DESC
|
|
1031
|
-
LIMIT ?`, [...params, limit]).map(rowToTrajectory);
|
|
1032
|
-
}
|
|
1033
|
-
/** Best-matching per-run trajectory for a task query, or `null` if none match. */
|
|
1034
|
-
whyForTask(query) {
|
|
1035
|
-
return this.searchTrajectories(query, { limit: 1 })[0] ?? null;
|
|
1036
|
-
}
|
|
1037
|
-
/** Single entry by id, or `null` if not found. */
|
|
1038
|
-
getEntry(id) {
|
|
1039
|
-
const clauses = ['id = ?'];
|
|
1040
|
-
const params = [id];
|
|
1041
|
-
appendProjectFilter(clauses, params, undefined, this._projectScope);
|
|
1042
|
-
const rows = runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
|
|
1043
|
-
FROM history WHERE ${clauses.join(' AND ')}`, params);
|
|
1044
|
-
return rows.length > 0 ? rowToEntry(rows[0]) : null;
|
|
1045
|
-
}
|
|
1046
|
-
/**
|
|
1047
|
-
* All entries whose timestamp falls within [timestampMs - windowMs,
|
|
1048
|
-
* timestampMs + windowMs], ordered oldest first. Used by get_context.
|
|
1049
|
-
*/
|
|
1050
|
-
getInTimeWindow(timestampMs, windowMs) {
|
|
1051
|
-
const clauses = ['timestamp_ms BETWEEN ? AND ?'];
|
|
1052
|
-
const params = [timestampMs - windowMs, timestampMs + windowMs];
|
|
1053
|
-
appendProjectFilter(clauses, params, undefined, this._projectScope);
|
|
1054
|
-
return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
|
|
1055
|
-
FROM history
|
|
1056
|
-
WHERE ${clauses.join(' AND ')}
|
|
1057
|
-
ORDER BY timestamp_ms ASC`, params).map(rowToEntry);
|
|
1058
|
-
}
|
|
1059
|
-
/**
|
|
1060
|
-
* Find sessions matching the given repo/branch/source and return ranked
|
|
1061
|
-
* handoff candidates with a warm-start command for the target CLI.
|
|
1062
|
-
*
|
|
1063
|
-
* Queries the `sessions` table (populated by `ai-hist sync`) for objective
|
|
1064
|
-
* metadata, then joins the last N user prompts from `history` to generate
|
|
1065
|
-
* a brief on demand — no pre-computed summaries.
|
|
1066
|
-
*/
|
|
1067
|
-
getHandoff(opts = {}) {
|
|
1068
|
-
const limit = opts.limit ?? 3;
|
|
1069
|
-
const clauses = [];
|
|
1070
|
-
const params = [];
|
|
1071
|
-
if (this._projectScope) {
|
|
1072
|
-
const escaped = this._projectScope.replace(/\|/g, '||').replace(/%/g, '|%').replace(/_/g, '|_');
|
|
1073
|
-
clauses.push("cwd LIKE ? ESCAPE '|'");
|
|
1074
|
-
params.push(`${escaped}%`);
|
|
1075
|
-
}
|
|
1076
|
-
if (opts.source) {
|
|
1077
|
-
clauses.push('source = ?');
|
|
1078
|
-
params.push(opts.source);
|
|
1079
|
-
}
|
|
1080
|
-
if (opts.repo) {
|
|
1081
|
-
const escaped = opts.repo.replace(/\|/g, '||').replace(/%/g, '|%').replace(/_/g, '|_');
|
|
1082
|
-
clauses.push("cwd LIKE ? ESCAPE '|'");
|
|
1083
|
-
params.push(`%${escaped}%`);
|
|
1084
|
-
}
|
|
1085
|
-
if (opts.branch) {
|
|
1086
|
-
const escaped = opts.branch.replace(/\|/g, '||').replace(/%/g, '|%').replace(/_/g, '|_');
|
|
1087
|
-
clauses.push("git_branch LIKE ? ESCAPE '|'");
|
|
1088
|
-
params.push(`%${escaped}%`);
|
|
1089
|
-
}
|
|
1090
|
-
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
1091
|
-
// Over-fetch sessions because many (old or sub-agent) sessions have no
|
|
1092
|
-
// matching `history` prompts and get skipped below. Fetching exactly
|
|
1093
|
-
// `limit` could starve the result to empty even when good candidates exist
|
|
1094
|
-
// just past the cutoff. We stop scanning once we've collected `limit`.
|
|
1095
|
-
const fetchCount = Math.min(Math.max(limit * 5, limit), 100);
|
|
1096
|
-
const sessionRows = runQuery(this.db, `SELECT session_id, source, cwd, git_branch, first_activity_ms, last_activity_ms,
|
|
1097
|
-
last_assistant_text, raw_path
|
|
1098
|
-
FROM sessions
|
|
1099
|
-
${where}
|
|
1100
|
-
ORDER BY last_activity_ms DESC
|
|
1101
|
-
LIMIT ?`, [...params, fetchCount]);
|
|
1102
|
-
const candidates = [];
|
|
1103
|
-
for (const session of sessionRows) {
|
|
1104
|
-
if (candidates.length >= limit)
|
|
1105
|
-
break;
|
|
1106
|
-
// Filter by both session_id AND source to prevent cross-source prompt mixing
|
|
1107
|
-
// when two CLIs happen to use the same session ID (rare but possible).
|
|
1108
|
-
const prompts = runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
|
|
1109
|
-
FROM history
|
|
1110
|
-
WHERE session_id = ? AND source = ?
|
|
1111
|
-
ORDER BY timestamp_ms ASC`, [session.session_id, session.source]).map(rowToEntry);
|
|
1112
|
-
if (prompts.length === 0)
|
|
1113
|
-
continue;
|
|
1114
|
-
const filesTouched = extractFilePaths(prompts.map((p) => p.prompt).join('\n'));
|
|
1115
|
-
// The first prompt is often injected boilerplate (system-reminder /
|
|
1116
|
-
// command wrappers), especially for relay-driven sessions. Prefer the
|
|
1117
|
-
// first prompt that looks like a real user instruction for the goal.
|
|
1118
|
-
const goalPrompt = prompts.find((p) => !isBoilerplatePrompt(p.prompt)) ?? prompts[0];
|
|
1119
|
-
const goal = goalPrompt.prompt.slice(0, 300).replace(/\n/g, ' ');
|
|
1120
|
-
const lastState = prompts[prompts.length - 1].prompt.slice(0, 300).replace(/\n/g, ' ');
|
|
1121
|
-
const resume = resumeCommand({
|
|
1122
|
-
source: session.source,
|
|
1123
|
-
sessionId: session.session_id,
|
|
1124
|
-
project: session.cwd,
|
|
1125
|
-
});
|
|
1126
|
-
const targetSource = session.source === 'claude' ? 'codex' : 'claude';
|
|
1127
|
-
const warmStart = buildWarmStartCommand(targetSource, goal, filesTouched, lastState, session.last_assistant_text ?? null, session.cwd);
|
|
1128
|
-
let confidence = Math.min(0.6, 0.2 + prompts.length * 0.02);
|
|
1129
|
-
if (opts.branch && session.git_branch?.includes(opts.branch))
|
|
1130
|
-
confidence += 0.25;
|
|
1131
|
-
if (opts.repo && session.cwd?.includes(opts.repo))
|
|
1132
|
-
confidence += 0.15;
|
|
1133
|
-
confidence = Math.min(1.0, confidence);
|
|
1134
|
-
candidates.push({
|
|
1135
|
-
sessionId: session.session_id,
|
|
1136
|
-
source: session.source,
|
|
1137
|
-
cwd: session.cwd,
|
|
1138
|
-
gitBranch: session.git_branch,
|
|
1139
|
-
firstActivityMs: session.first_activity_ms,
|
|
1140
|
-
lastActivityMs: session.last_activity_ms,
|
|
1141
|
-
promptCount: prompts.length,
|
|
1142
|
-
goal,
|
|
1143
|
-
lastState,
|
|
1144
|
-
lastAssistantText: session.last_assistant_text ?? null,
|
|
1145
|
-
filesTouched,
|
|
1146
|
-
resumeCommand: resume,
|
|
1147
|
-
warmStartCommand: warmStart,
|
|
1148
|
-
confidence,
|
|
1149
|
-
});
|
|
1150
|
-
}
|
|
1151
|
-
return candidates.sort((a, b) => b.confidence - a.confidence);
|
|
1152
|
-
}
|
|
1153
|
-
/** Counts + date range, mirroring `ai-hist stats`. */
|
|
1154
|
-
stats() {
|
|
1155
|
-
const scopeClauses = [];
|
|
1156
|
-
const scopeParams = [];
|
|
1157
|
-
appendProjectFilter(scopeClauses, scopeParams, undefined, this._projectScope);
|
|
1158
|
-
const where = scopeClauses.length > 0 ? ` WHERE ${scopeClauses.join(' AND ')}` : '';
|
|
1159
|
-
const andScope = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(' AND ')}` : '';
|
|
1160
|
-
const total = runQuery(this.db, `SELECT COUNT(*) AS c FROM history${where}`, scopeParams)[0]?.c ?? 0;
|
|
1161
|
-
const bySourceRows = runQuery(this.db, `SELECT source, COUNT(*) AS c FROM history${where} GROUP BY source`, scopeParams);
|
|
1162
|
-
const bySource = {};
|
|
1163
|
-
for (const row of bySourceRows) {
|
|
1164
|
-
bySource[row.source] = row.c;
|
|
1165
|
-
}
|
|
1166
|
-
const byProject = runQuery(this.db, `SELECT project, COUNT(*) AS c FROM history
|
|
1167
|
-
WHERE project IS NOT NULL AND project != ''${andScope}
|
|
1168
|
-
GROUP BY project ORDER BY c DESC LIMIT 10`, scopeParams).map((row) => ({ project: row.project, count: row.c }));
|
|
1169
|
-
const range = runQuery(this.db, `SELECT MIN(timestamp_ms) AS mn, MAX(timestamp_ms) AS mx FROM history${where}`, scopeParams)[0];
|
|
1170
|
-
return {
|
|
1171
|
-
total,
|
|
1172
|
-
bySource,
|
|
1173
|
-
byProject,
|
|
1174
|
-
firstTimestampMs: range?.mn ?? null,
|
|
1175
|
-
lastTimestampMs: range?.mx ?? null,
|
|
1176
|
-
};
|
|
184
|
+
export function defaultDbPath() {
|
|
185
|
+
if (process.env.AI_HIST_DB !== undefined)
|
|
186
|
+
return process.env.AI_HIST_DB;
|
|
187
|
+
if (process.env.XDG_DATA_HOME !== undefined) {
|
|
188
|
+
return join(process.env.XDG_DATA_HOME, 'ai-hist', 'ai-history.db');
|
|
1177
189
|
}
|
|
190
|
+
return join(homedir(), '.local', 'share', 'ai-hist', 'ai-history.db');
|
|
1178
191
|
}
|
|
1179
192
|
/**
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
193
|
+
* Optimization profile of the loaded native addon: 'release', 'debug', or
|
|
194
|
+
* 'unknown' for an addon predating the probe. Performance measurements are
|
|
195
|
+
* only meaningful against 'release'.
|
|
1183
196
|
*/
|
|
1184
|
-
function
|
|
1185
|
-
|
|
1186
|
-
return (t.startsWith('<system-reminder') ||
|
|
1187
|
-
t.startsWith('<command-') ||
|
|
1188
|
-
t.startsWith('Caveat:') ||
|
|
1189
|
-
t.startsWith('[Request interrupted'));
|
|
197
|
+
export async function nativeBuildProfile() {
|
|
198
|
+
return nativeCall(async (native) => native.nativeBuildProfile?.() ?? 'unknown');
|
|
1190
199
|
}
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
* Heuristic — used to populate filesTouched in HandoffCandidate.
|
|
1194
|
-
*/
|
|
1195
|
-
function extractFilePaths(text) {
|
|
1196
|
-
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';
|
|
1197
|
-
const regex = new RegExp(`(?:^|[\\s,\`'"(])([~./][\\w./-]*\\.(?:${exts})|-?[\\w/-]+\\.(?:${exts}))\\b`, 'gm');
|
|
1198
|
-
const seen = new Set();
|
|
1199
|
-
let m;
|
|
1200
|
-
while ((m = regex.exec(text)) !== null) {
|
|
1201
|
-
const p = m[1].trim();
|
|
1202
|
-
if (p.length > 2 && p.length < 200)
|
|
1203
|
-
seen.add(p);
|
|
1204
|
-
}
|
|
1205
|
-
return Array.from(seen).slice(0, 20);
|
|
200
|
+
export async function search(query, options = {}) {
|
|
201
|
+
return nativeCall(async (native) => (await native.search(query, options)).map(historyEntry));
|
|
1206
202
|
}
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
203
|
+
export async function recent(options = {}) {
|
|
204
|
+
return nativeCall(async (native) => (await native.recent(options)).map(historyEntry));
|
|
205
|
+
}
|
|
206
|
+
export async function getSession(sessionId, options = {}) {
|
|
207
|
+
return nativeCall(async (native) => (await native.getSession(sessionId, options)).map(historyEntry));
|
|
208
|
+
}
|
|
209
|
+
export async function listSessionCatalog(options = {}) {
|
|
210
|
+
return (await listSessionCatalogPage(options)).sessions;
|
|
211
|
+
}
|
|
212
|
+
export async function listSessionCatalogPage(options = {}) {
|
|
213
|
+
return nativeCall(async (native) => {
|
|
214
|
+
const page = await native.listSessionCatalogPage({ ...options, after: options.after ? {
|
|
215
|
+
...options.after,
|
|
216
|
+
lastActivityMs: options.after.lastActivityMs ?? undefined,
|
|
217
|
+
} : undefined });
|
|
218
|
+
const contractVersion = Number(page.contractVersion);
|
|
219
|
+
assertCatalogContract(contractVersion);
|
|
220
|
+
return {
|
|
221
|
+
contractVersion,
|
|
222
|
+
sessions: Array.isArray(page.sessions) ? page.sessions.map(catalogSession) : [],
|
|
223
|
+
nextCursor: catalogCursor(page.nextCursor),
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
export async function discoverSessions(options = {}) {
|
|
228
|
+
return nativeCall(async (native) => {
|
|
229
|
+
const result = await native.discoverSessions(options);
|
|
230
|
+
const contractVersion = Number(result.contractVersion);
|
|
231
|
+
assertCatalogContract(contractVersion);
|
|
232
|
+
return {
|
|
233
|
+
contractVersion,
|
|
234
|
+
sessions: Array.isArray(result.sessions) ? result.sessions.map(catalogSession) : [],
|
|
235
|
+
discovered: Number(result.discovered),
|
|
236
|
+
skippedUnchanged: Number(result.skippedUnchanged),
|
|
237
|
+
providers: result.providers ?? [],
|
|
238
|
+
exemptSources: result.exemptSources ?? [],
|
|
239
|
+
diagnostics: Array.isArray(result.diagnostics) ? result.diagnostics.map((item) => ({
|
|
240
|
+
source: String(item.source), locator: nullableString(item.locator), error: String(item.error),
|
|
241
|
+
})) : [],
|
|
242
|
+
counters: result.counters,
|
|
243
|
+
};
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
export async function discoverAndList(options = {}) {
|
|
247
|
+
await discoverSessions(options);
|
|
248
|
+
return listSessionCatalogPage(options);
|
|
249
|
+
}
|
|
250
|
+
export async function getSessionEventsPage(sessionId, options = {}) {
|
|
251
|
+
return nativeCall(async (native) => {
|
|
252
|
+
const page = await native.getSessionEventsPage(sessionId, options);
|
|
253
|
+
return {
|
|
254
|
+
events: Array.isArray(page.events) ? page.events.map(sessionEvent) : [],
|
|
255
|
+
nextCursor: page.nextCursor && typeof page.nextCursor === 'object'
|
|
256
|
+
? { tsMs: Number(page.nextCursor.tsMs), id: Number(page.nextCursor.id) }
|
|
257
|
+
: null,
|
|
258
|
+
};
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
export async function* sessionEvents(sessionId, options = {}) {
|
|
262
|
+
let after;
|
|
263
|
+
do {
|
|
264
|
+
const page = await getSessionEventsPage(sessionId, { ...options, after });
|
|
265
|
+
for (const event of page.events)
|
|
266
|
+
yield event;
|
|
267
|
+
after = page.nextCursor ?? undefined;
|
|
268
|
+
} while (after);
|
|
269
|
+
}
|
|
270
|
+
export async function getSessionEvents(sessionId, options = {}) {
|
|
271
|
+
const events = [];
|
|
272
|
+
for await (const event of sessionEvents(sessionId, options))
|
|
273
|
+
events.push(event);
|
|
274
|
+
return events;
|
|
275
|
+
}
|
|
276
|
+
export async function stats(options = {}) {
|
|
277
|
+
return nativeCall(async (native) => {
|
|
278
|
+
const result = await native.stats(options);
|
|
279
|
+
const bySource = {};
|
|
280
|
+
for (const item of result.bySource ?? []) {
|
|
281
|
+
bySource[String(item.source)] = Number(item.count);
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
total: Number(result.total), bySource,
|
|
285
|
+
byProject: (result.byProject ?? []).map((item) => ({ project: String(item.project), count: Number(item.count) })),
|
|
286
|
+
firstTimestampMs: typeof result.firstTimestampMs === 'number' ? result.firstTimestampMs : null,
|
|
287
|
+
lastTimestampMs: typeof result.lastTimestampMs === 'number' ? result.lastTimestampMs : null,
|
|
288
|
+
};
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
export async function sync(options = {}) {
|
|
292
|
+
return nativeCall(async (native) => {
|
|
293
|
+
const result = await native.sync(options);
|
|
294
|
+
return { databasePath: String(result.databasePath), completed: result.completed === true };
|
|
295
|
+
});
|
|
1219
296
|
}
|
|
1220
|
-
/**
|
|
1221
|
-
* Resume command for an entry/session, matching what `ai-hist show` prints.
|
|
1222
|
-
* Returns `null` for sources that don't have a resume affordance (relay).
|
|
1223
|
-
*/
|
|
1224
297
|
export function resumeCommand(entry) {
|
|
1225
298
|
if (!entry.sessionId)
|
|
1226
299
|
return null;
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
return entry.
|
|
1230
|
-
|
|
1231
|
-
: `claude --resume ${shellQuote(entry.sessionId)}`;
|
|
1232
|
-
case 'codex':
|
|
300
|
+
const resume = (() => {
|
|
301
|
+
if (entry.source === 'claude')
|
|
302
|
+
return `claude --resume ${shellQuote(entry.sessionId)}`;
|
|
303
|
+
if (entry.source === 'codex')
|
|
1233
304
|
return `codex resume ${shellQuote(entry.sessionId)}`;
|
|
1234
|
-
|
|
1235
|
-
return entry.
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
return null;
|
|
1242
|
-
}
|
|
305
|
+
if (entry.source === 'cursor')
|
|
306
|
+
return `cursor-agent --resume=${shellQuote(entry.sessionId)}`;
|
|
307
|
+
if (entry.source === 'grok')
|
|
308
|
+
return `grok resume ${shellQuote(entry.sessionId)}`;
|
|
309
|
+
return null;
|
|
310
|
+
})();
|
|
311
|
+
return resume && entry.project ? `cd ${shellQuote(entry.project)} && ${resume}` : resume;
|
|
1243
312
|
}
|
|
1244
313
|
function shellQuote(value) {
|
|
1245
|
-
|
|
1246
|
-
return value;
|
|
1247
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
314
|
+
return /^[A-Za-z0-9._:/-]+$/.test(value) ? value : `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
1248
315
|
}
|
|
1249
316
|
//# sourceMappingURL=index.js.map
|