ai-hist 0.4.1 → 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 -156
- 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 +153 -211
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +263 -902
- 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 -675
- package/dist/mcp-server.js.map +1 -1
- package/package.json +14 -8
- package/dist/cloud-push.d.ts +0 -47
- package/dist/cloud-push.d.ts.map +0 -1
- package/dist/cloud-push.js +0 -111
- 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 -68
- package/dist/cloud-push.test.js.map +0 -1
- package/dist/jsonl-sources.d.ts +0 -35
- package/dist/jsonl-sources.d.ts.map +0 -1
- package/dist/jsonl-sources.js +0 -354
- 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 -424
- 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/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,955 +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
|
-
|
|
29
|
-
return fromEnv;
|
|
30
|
-
return join(homedir(), '.local', 'share', 'ai-hist', 'ai-history.db');
|
|
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;
|
|
18
|
+
}
|
|
31
19
|
}
|
|
32
|
-
|
|
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');
|
|
20
|
+
export class UnsupportedPlatformError extends RelayHistoryError {
|
|
36
21
|
}
|
|
37
|
-
|
|
38
|
-
function getSqlJs() {
|
|
39
|
-
if (!_sqlPromise) {
|
|
40
|
-
_sqlPromise = initSqlJs();
|
|
41
|
-
}
|
|
42
|
-
return _sqlPromise;
|
|
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
|
-
}
|
|
61
|
-
function ensureTrajectorySchema(db) {
|
|
62
|
-
db.run(`CREATE TABLE IF NOT EXISTS trajectories (
|
|
63
|
-
id TEXT PRIMARY KEY,
|
|
64
|
-
version INTEGER,
|
|
65
|
-
persona_id TEXT,
|
|
66
|
-
project_id TEXT,
|
|
67
|
-
task_title TEXT,
|
|
68
|
-
task_description TEXT,
|
|
69
|
-
status TEXT,
|
|
70
|
-
started_at TEXT,
|
|
71
|
-
completed_at TEXT,
|
|
72
|
-
decisions_json TEXT NOT NULL,
|
|
73
|
-
retrospective_json TEXT NOT NULL,
|
|
74
|
-
search_text TEXT NOT NULL,
|
|
75
|
-
path TEXT,
|
|
76
|
-
updated_ms INTEGER NOT NULL,
|
|
77
|
-
timestamp_ms INTEGER NOT NULL
|
|
78
|
-
)`);
|
|
79
|
-
db.run('CREATE INDEX IF NOT EXISTS idx_trajectories_timestamp ON trajectories(timestamp_ms DESC)');
|
|
80
|
-
db.run('CREATE INDEX IF NOT EXISTS idx_trajectories_project ON trajectories(project_id)');
|
|
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)');
|
|
22
|
+
export class NativePackageMissingError extends RelayHistoryError {
|
|
102
23
|
}
|
|
103
|
-
|
|
104
|
-
* Open an `AiHist` reader. Async because sql.js initializes its WASM
|
|
105
|
-
* runtime lazily and the DB file is read asynchronously so the host
|
|
106
|
-
* process's event loop isn't blocked.
|
|
107
|
-
*
|
|
108
|
-
* Each call snapshots the data; to pick up later writes, call `reload()`
|
|
109
|
-
* (or open a fresh instance).
|
|
110
|
-
*/
|
|
111
|
-
export async function openAiHist(opts = {}) {
|
|
112
|
-
const dbPath = opts.dbPath ?? defaultDbPath();
|
|
113
|
-
const fallback = opts.fallback ?? 'jsonl';
|
|
114
|
-
const SQL = await getSqlJs();
|
|
115
|
-
// Fast path: SQLite written by `ai-hist sync`.
|
|
116
|
-
if (await pathExists(dbPath)) {
|
|
117
|
-
const fileBuffer = await readFile(dbPath);
|
|
118
|
-
const db = new SQL.Database(fileBuffer);
|
|
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 */ }
|
|
127
|
-
// The Python CLI's schema doesn't create `idx_history_session` or
|
|
128
|
-
// `idx_history_timestamp`. Without them, listSessions degrades to
|
|
129
|
-
// an O(sessions × rows) full table scan and freezes the WASM
|
|
130
|
-
// single-threaded JS engine for tens of seconds on real-sized DBs.
|
|
131
|
-
// The DB is opened read-only over a buffer, but sql.js still lets
|
|
132
|
-
// us run `CREATE INDEX` against the in-memory copy — the index
|
|
133
|
-
// lives only for this session's lifetime and is rebuilt each
|
|
134
|
-
// `openAiHist` call. Fast (~30ms on 35K rows).
|
|
135
|
-
db.run('CREATE INDEX IF NOT EXISTS idx_history_session ON history(session_id)');
|
|
136
|
-
db.run('CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp_ms DESC)');
|
|
137
|
-
return new AiHist(db, { kind: 'sqlite', path: dbPath }, { projectScope: opts.projectScope });
|
|
138
|
-
}
|
|
139
|
-
if (fallback === 'error') {
|
|
140
|
-
throw new Error(`ai-hist database not found at ${dbPath}. Run \`ai-hist sync\` first ` +
|
|
141
|
-
`(see https://github.com/AgentWorkforce/ai-hist).`);
|
|
142
|
-
}
|
|
143
|
-
// Fallback: scan local source files (Claude/Codex/Cursor/Grok) directly.
|
|
144
|
-
// No Python dependency; uses the same parsers documented in the Python
|
|
145
|
-
// CLI's source. Yields control to the event loop between sources so a
|
|
146
|
-
// large local history doesn't freeze the host.
|
|
147
|
-
const db = new SQL.Database();
|
|
148
|
-
db.run(`CREATE TABLE history (
|
|
149
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
150
|
-
source TEXT NOT NULL,
|
|
151
|
-
session_id TEXT,
|
|
152
|
-
project TEXT,
|
|
153
|
-
prompt TEXT NOT NULL,
|
|
154
|
-
timestamp_ms INTEGER NOT NULL,
|
|
155
|
-
git_branch TEXT,
|
|
156
|
-
UNIQUE(source, timestamp_ms, prompt)
|
|
157
|
-
)`);
|
|
158
|
-
db.run('CREATE INDEX idx_history_timestamp ON history (timestamp_ms DESC)');
|
|
159
|
-
db.run('CREATE INDEX idx_history_session ON history (session_id)');
|
|
160
|
-
ensureTrajectorySchema(db);
|
|
161
|
-
ensureTagSchema(db);
|
|
162
|
-
ensureSessionsSchema(db);
|
|
163
|
-
// scanLocalSources is async with yields between sources so the event
|
|
164
|
-
// loop stays responsive while we scan many MB of JSONL.
|
|
165
|
-
const rows = await scanLocalSources();
|
|
166
|
-
const openCodeRows = await scanOpenCode(SQL);
|
|
167
|
-
const trajectories = await scanLocalTrajectories();
|
|
168
|
-
const insert = db.prepare('INSERT OR IGNORE INTO history (source, session_id, project, prompt, timestamp_ms, git_branch) VALUES (?, ?, ?, ?, ?, ?)');
|
|
169
|
-
const insertTrajectory = db.prepare(`INSERT OR REPLACE INTO trajectories
|
|
170
|
-
(id, version, persona_id, project_id, task_title, task_description, status,
|
|
171
|
-
started_at, completed_at, decisions_json, retrospective_json, search_text,
|
|
172
|
-
path, updated_ms, timestamp_ms)
|
|
173
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
174
|
-
try {
|
|
175
|
-
db.exec('BEGIN');
|
|
176
|
-
for (const row of rows) {
|
|
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]);
|
|
181
|
-
}
|
|
182
|
-
for (const trajectory of trajectories) {
|
|
183
|
-
insertTrajectory.run([
|
|
184
|
-
trajectory.id,
|
|
185
|
-
trajectory.version,
|
|
186
|
-
trajectory.personaId,
|
|
187
|
-
trajectory.projectId,
|
|
188
|
-
trajectory.task.title,
|
|
189
|
-
trajectory.task.description,
|
|
190
|
-
trajectory.status,
|
|
191
|
-
trajectory.startedAt,
|
|
192
|
-
trajectory.completedAt,
|
|
193
|
-
JSON.stringify(trajectory.decisions),
|
|
194
|
-
JSON.stringify(trajectory.retrospective),
|
|
195
|
-
trajectory.searchText,
|
|
196
|
-
trajectory.path,
|
|
197
|
-
trajectory.updatedMs,
|
|
198
|
-
trajectory.timestampMs,
|
|
199
|
-
]);
|
|
200
|
-
insert.run([
|
|
201
|
-
'trajectory',
|
|
202
|
-
trajectory.id,
|
|
203
|
-
trajectory.projectId,
|
|
204
|
-
trajectory.searchText,
|
|
205
|
-
trajectory.timestampMs,
|
|
206
|
-
null,
|
|
207
|
-
]);
|
|
208
|
-
}
|
|
209
|
-
db.exec('COMMIT');
|
|
210
|
-
}
|
|
211
|
-
finally {
|
|
212
|
-
insert.free();
|
|
213
|
-
insertTrajectory.free();
|
|
214
|
-
}
|
|
215
|
-
const scannedPaths = `${LOCAL_SOURCE_PATHS.claude}, ${LOCAL_SOURCE_PATHS.codex}, ${LOCAL_SOURCE_PATHS.cursorRoot}, ${LOCAL_SOURCE_PATHS.grokSessionsRoot}, ${trajectoryRootDescription()}`;
|
|
216
|
-
return new AiHist(db, { kind: 'jsonl', path: scannedPaths }, { projectScope: opts.projectScope });
|
|
24
|
+
export class NativeLoadError extends RelayHistoryError {
|
|
217
25
|
}
|
|
218
|
-
|
|
219
|
-
try {
|
|
220
|
-
await stat(p);
|
|
221
|
-
return true;
|
|
222
|
-
}
|
|
223
|
-
catch {
|
|
224
|
-
return false;
|
|
225
|
-
}
|
|
26
|
+
export class NativeContractMismatchError extends RelayHistoryError {
|
|
226
27
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
28
|
+
export class DatabaseOpenError extends RelayHistoryError {
|
|
29
|
+
}
|
|
30
|
+
export class InvalidArgumentError extends RelayHistoryError {
|
|
31
|
+
}
|
|
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';
|
|
41
|
+
}
|
|
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');
|
|
244
53
|
}
|
|
245
54
|
}
|
|
246
|
-
async function
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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;
|
|
253
64
|
try {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
});
|
|
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 });
|
|
274
74
|
}
|
|
275
|
-
|
|
75
|
+
throw new NativeLoadError(`RelayHistory's native package for ${platform} failed to load: ${error.message}`, 'NATIVE_LOAD_FAILED', { cause });
|
|
276
76
|
}
|
|
277
|
-
|
|
278
|
-
|
|
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');
|
|
279
80
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
81
|
+
validateNativeContract(binding.nativeContractVersion());
|
|
82
|
+
return binding;
|
|
83
|
+
})();
|
|
84
|
+
return nativePromise.catch((error) => {
|
|
85
|
+
nativePromise = null;
|
|
86
|
+
throw error;
|
|
87
|
+
});
|
|
284
88
|
}
|
|
285
|
-
function
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
sessionId: row.session_id,
|
|
290
|
-
project: row.project,
|
|
291
|
-
prompt: row.prompt,
|
|
292
|
-
timestampMs: row.timestamp_ms,
|
|
293
|
-
gitBranch: row.git_branch ?? null,
|
|
294
|
-
};
|
|
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;
|
|
295
93
|
}
|
|
296
|
-
function
|
|
94
|
+
async function nativeCall(call) {
|
|
297
95
|
try {
|
|
298
|
-
return
|
|
299
|
-
}
|
|
300
|
-
catch {
|
|
301
|
-
|
|
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 });
|
|
106
|
+
}
|
|
107
|
+
if (native.code === 'INVALID_ARGUMENT') {
|
|
108
|
+
throw new InvalidArgumentError(native.message, native.code, { cause: error });
|
|
109
|
+
}
|
|
110
|
+
throw new RelayHistoryError(native.message, native.code, { cause: error });
|
|
302
111
|
}
|
|
303
112
|
}
|
|
304
|
-
function
|
|
113
|
+
function nullableString(value) {
|
|
114
|
+
return typeof value === 'string' ? value : null;
|
|
115
|
+
}
|
|
116
|
+
function historyEntry(value) {
|
|
305
117
|
return {
|
|
306
|
-
id:
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
description: row.task_description,
|
|
313
|
-
},
|
|
314
|
-
status: row.status,
|
|
315
|
-
startedAt: row.started_at,
|
|
316
|
-
completedAt: row.completed_at,
|
|
317
|
-
decisions: parseJson(row.decisions_json, []),
|
|
318
|
-
retrospective: parseJson(row.retrospective_json, {
|
|
319
|
-
summary: null,
|
|
320
|
-
approach: null,
|
|
321
|
-
learnings: [],
|
|
322
|
-
confidence: null,
|
|
323
|
-
}),
|
|
324
|
-
searchText: row.search_text,
|
|
325
|
-
path: row.path,
|
|
326
|
-
updatedMs: row.updated_ms,
|
|
327
|
-
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),
|
|
328
124
|
};
|
|
329
125
|
}
|
|
330
|
-
function
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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}\\%`;
|
|
126
|
+
function catalogCursor(value) {
|
|
127
|
+
if (!value || typeof value !== 'object')
|
|
128
|
+
return null;
|
|
129
|
+
const row = value;
|
|
344
130
|
return {
|
|
345
|
-
|
|
346
|
-
|
|
131
|
+
lastActivityMs: typeof row.lastActivityMs === 'number' ? row.lastActivityMs : null,
|
|
132
|
+
source: String(row.source),
|
|
133
|
+
sessionId: String(row.sessionId),
|
|
347
134
|
};
|
|
348
135
|
}
|
|
349
|
-
function
|
|
350
|
-
const normalized = normalizeProjectScope(project) ?? project;
|
|
351
|
-
const pathScope = scopedPathClause('path', normalized);
|
|
136
|
+
function catalogSession(value) {
|
|
352
137
|
return {
|
|
353
|
-
|
|
354
|
-
|
|
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,
|
|
355
156
|
};
|
|
356
157
|
}
|
|
357
|
-
function
|
|
358
|
-
if (
|
|
359
|
-
|
|
360
|
-
clauses.push(scope.sql);
|
|
361
|
-
params.push(...scope.params);
|
|
362
|
-
}
|
|
363
|
-
if (project) {
|
|
364
|
-
clauses.push('project = ?');
|
|
365
|
-
params.push(project);
|
|
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');
|
|
366
161
|
}
|
|
367
162
|
}
|
|
368
|
-
function
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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) {
|
|
385
|
-
const clauses = [];
|
|
386
|
-
const params = [];
|
|
387
|
-
if (opts.source) {
|
|
388
|
-
clauses.push('source = ?');
|
|
389
|
-
params.push(opts.source);
|
|
163
|
+
function tokenUsage(raw) {
|
|
164
|
+
if (typeof raw !== 'string')
|
|
165
|
+
return null;
|
|
166
|
+
try {
|
|
167
|
+
const parsed = JSON.parse(raw);
|
|
168
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
390
169
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if (typeof opts.beforeMs === 'number') {
|
|
394
|
-
clauses.push('timestamp_ms < ?');
|
|
395
|
-
params.push(opts.beforeMs);
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
396
172
|
}
|
|
173
|
+
}
|
|
174
|
+
function sessionEvent(value) {
|
|
397
175
|
return {
|
|
398
|
-
|
|
399
|
-
|
|
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),
|
|
400
182
|
};
|
|
401
183
|
}
|
|
402
|
-
function
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
while (stmt.step()) {
|
|
408
|
-
rows.push(stmt.getAsObject());
|
|
409
|
-
}
|
|
410
|
-
return rows;
|
|
411
|
-
}
|
|
412
|
-
finally {
|
|
413
|
-
stmt.free();
|
|
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');
|
|
414
189
|
}
|
|
190
|
+
return join(homedir(), '.local', 'share', 'ai-hist', 'ai-history.db');
|
|
415
191
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
return;
|
|
445
|
-
this.db.close();
|
|
446
|
-
this.closed = true;
|
|
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
|
-
}
|
|
588
|
-
/** Most recent prompts, newest first. */
|
|
589
|
-
recent(opts = {}) {
|
|
590
|
-
const limit = opts.limit ?? 50;
|
|
591
|
-
const { sql, params } = buildFilters(opts, this._projectScope);
|
|
592
|
-
return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
|
|
593
|
-
FROM history
|
|
594
|
-
WHERE 1=1${sql}
|
|
595
|
-
ORDER BY timestamp_ms DESC
|
|
596
|
-
LIMIT ?`, [...params, limit]).map(rowToEntry);
|
|
597
|
-
}
|
|
598
|
-
/**
|
|
599
|
-
* Group history into sessions, ordered by last activity (newest first).
|
|
600
|
-
* Sessions without a `session_id` are skipped.
|
|
601
|
-
*
|
|
602
|
-
* Implementation note: this used to use a correlated scalar subquery
|
|
603
|
-
* to pick `first_prompt`, which ran in O(sessions × rows) — ~19s on a
|
|
604
|
-
* 35K-row DB. Switched to `ROW_NUMBER() OVER (PARTITION BY session_id
|
|
605
|
-
* ORDER BY timestamp_ms)` so first-prompt picking is a single pass
|
|
606
|
-
* over the table (~300ms on the same DB). Plus the index ensure step
|
|
607
|
-
* in `openAiHist` keeps it fast even when the DB was written by the
|
|
608
|
-
* older Python CLI that didn't create `idx_history_session`.
|
|
609
|
-
*/
|
|
610
|
-
listSessions(opts = {}) {
|
|
611
|
-
const limit = opts.limit ?? 50;
|
|
612
|
-
const { sql, params } = buildFilters(opts, this._projectScope);
|
|
613
|
-
const rows = runQuery(this.db, `WITH filtered AS (
|
|
614
|
-
SELECT id, source, session_id, project, prompt, timestamp_ms
|
|
615
|
-
FROM history
|
|
616
|
-
WHERE session_id IS NOT NULL AND session_id != ''${sql}
|
|
617
|
-
),
|
|
618
|
-
ranked AS (
|
|
619
|
-
SELECT
|
|
620
|
-
session_id,
|
|
621
|
-
source,
|
|
622
|
-
project,
|
|
623
|
-
prompt,
|
|
624
|
-
timestamp_ms,
|
|
625
|
-
ROW_NUMBER() OVER (
|
|
626
|
-
PARTITION BY session_id, source, project
|
|
627
|
-
ORDER BY timestamp_ms ASC, id ASC
|
|
628
|
-
) AS rn_first,
|
|
629
|
-
COUNT(*) OVER (PARTITION BY session_id, source, project) AS prompt_count,
|
|
630
|
-
MIN(timestamp_ms) OVER (PARTITION BY session_id, source, project) AS first_activity_ms,
|
|
631
|
-
MAX(timestamp_ms) OVER (PARTITION BY session_id, source, project) AS last_activity_ms
|
|
632
|
-
FROM filtered
|
|
633
|
-
)
|
|
634
|
-
SELECT
|
|
635
|
-
session_id,
|
|
636
|
-
source,
|
|
637
|
-
project,
|
|
638
|
-
prompt AS first_prompt,
|
|
639
|
-
first_activity_ms,
|
|
640
|
-
last_activity_ms,
|
|
641
|
-
prompt_count
|
|
642
|
-
FROM ranked
|
|
643
|
-
WHERE rn_first = 1
|
|
644
|
-
ORDER BY last_activity_ms DESC
|
|
645
|
-
LIMIT ?`, [...params, limit]);
|
|
646
|
-
return rows.map((row) => ({
|
|
647
|
-
sessionId: row.session_id,
|
|
648
|
-
source: row.source,
|
|
649
|
-
project: row.project,
|
|
650
|
-
firstPrompt: row.first_prompt,
|
|
651
|
-
lastActivityMs: row.last_activity_ms,
|
|
652
|
-
firstActivityMs: row.first_activity_ms,
|
|
653
|
-
promptCount: row.prompt_count,
|
|
654
|
-
}));
|
|
655
|
-
}
|
|
656
|
-
/** All prompts in a session, ordered oldest → newest. */
|
|
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
|
|
667
|
-
FROM history
|
|
668
|
-
WHERE ${clauses.join(' AND ')}
|
|
669
|
-
ORDER BY timestamp_ms ASC`, params).map(rowToEntry);
|
|
670
|
-
}
|
|
671
|
-
/**
|
|
672
|
-
* Substring search across prompt + project, case-insensitive, recent
|
|
673
|
-
* matches first. The Python CLI uses FTS5; this SDK uses LIKE because
|
|
674
|
-
* sql.js's default WASM build doesn't ship the FTS5 module. Plenty fast
|
|
675
|
-
* for the ai-hist scale (~tens of thousands of rows); revisit if a
|
|
676
|
-
* future consumer needs phrase/boolean queries.
|
|
677
|
-
*
|
|
678
|
-
* The query is matched literally — `%` and `_` are escaped so users can
|
|
679
|
-
* search for them. Empty queries return recent entries matching the filters.
|
|
680
|
-
*/
|
|
681
|
-
search(query, opts = {}) {
|
|
682
|
-
const trimmed = query.trim();
|
|
683
|
-
const limit = opts.limit ?? 50;
|
|
684
|
-
const clauses = [];
|
|
685
|
-
const params = [];
|
|
686
|
-
if (trimmed) {
|
|
687
|
-
const escaped = trimmed.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
688
|
-
const pattern = `%${escaped}%`;
|
|
689
|
-
clauses.push(`(LOWER(prompt) LIKE LOWER(?) ESCAPE '\\' OR LOWER(COALESCE(project, '')) LIKE LOWER(?) ESCAPE '\\')`);
|
|
690
|
-
params.push(pattern, pattern);
|
|
691
|
-
}
|
|
692
|
-
if (opts.source) {
|
|
693
|
-
clauses.push('source = ?');
|
|
694
|
-
params.push(opts.source);
|
|
695
|
-
}
|
|
696
|
-
appendProjectFilter(clauses, params, opts.project, this._projectScope);
|
|
697
|
-
appendTagFilter(clauses, params, opts.tag, 'history');
|
|
698
|
-
if (typeof opts.beforeMs === 'number') {
|
|
699
|
-
clauses.push('timestamp_ms < ?');
|
|
700
|
-
params.push(opts.beforeMs);
|
|
701
|
-
}
|
|
702
|
-
return runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
|
|
703
|
-
FROM history
|
|
704
|
-
WHERE ${clauses.length > 0 ? clauses.join(' AND ') : '1=1'}
|
|
705
|
-
ORDER BY timestamp_ms DESC
|
|
706
|
-
LIMIT ?`, [...params, limit]).map(rowToEntry);
|
|
707
|
-
}
|
|
708
|
-
/** Search compacted per-run trajectory WHY: decisions and retrospectives. */
|
|
709
|
-
searchTrajectories(query, opts = {}) {
|
|
710
|
-
const trimmed = query.trim();
|
|
711
|
-
if (!trimmed)
|
|
712
|
-
return [];
|
|
713
|
-
const limit = opts.limit ?? 20;
|
|
714
|
-
const escaped = trimmed.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
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
|
-
}
|
|
731
|
-
return runQuery(this.db, `SELECT id, version, persona_id, project_id, task_title, task_description, status,
|
|
732
|
-
started_at, completed_at, decisions_json, retrospective_json, search_text,
|
|
733
|
-
path, updated_ms, timestamp_ms
|
|
734
|
-
FROM trajectories
|
|
735
|
-
WHERE ${clauses.join(' AND ')}
|
|
736
|
-
ORDER BY timestamp_ms DESC
|
|
737
|
-
LIMIT ?`, [...params, limit]).map(rowToTrajectory);
|
|
738
|
-
}
|
|
739
|
-
/** Best-matching per-run trajectory for a task query, or `null` if none match. */
|
|
740
|
-
whyForTask(query) {
|
|
741
|
-
return this.searchTrajectories(query, { limit: 1 })[0] ?? null;
|
|
742
|
-
}
|
|
743
|
-
/** Single entry by id, or `null` if not found. */
|
|
744
|
-
getEntry(id) {
|
|
745
|
-
const clauses = ['id = ?'];
|
|
746
|
-
const params = [id];
|
|
747
|
-
appendProjectFilter(clauses, params, undefined, this._projectScope);
|
|
748
|
-
const rows = runQuery(this.db, `SELECT id, source, session_id, project, prompt, timestamp_ms, git_branch
|
|
749
|
-
FROM history WHERE ${clauses.join(' AND ')}`, params);
|
|
750
|
-
return rows.length > 0 ? rowToEntry(rows[0]) : null;
|
|
751
|
-
}
|
|
752
|
-
/**
|
|
753
|
-
* All entries whose timestamp falls within [timestampMs - windowMs,
|
|
754
|
-
* timestampMs + windowMs], ordered oldest first. Used by get_context.
|
|
755
|
-
*/
|
|
756
|
-
getInTimeWindow(timestampMs, windowMs) {
|
|
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
|
|
761
|
-
FROM history
|
|
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);
|
|
858
|
-
}
|
|
859
|
-
/** Counts + date range, mirroring `ai-hist stats`. */
|
|
860
|
-
stats() {
|
|
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);
|
|
868
|
-
const bySource = {};
|
|
869
|
-
for (const row of bySourceRows) {
|
|
870
|
-
bySource[row.source] = row.c;
|
|
871
|
-
}
|
|
872
|
-
const byProject = runQuery(this.db, `SELECT project, COUNT(*) AS c FROM history
|
|
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];
|
|
192
|
+
/**
|
|
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'.
|
|
196
|
+
*/
|
|
197
|
+
export async function nativeBuildProfile() {
|
|
198
|
+
return nativeCall(async (native) => native.nativeBuildProfile?.() ?? 'unknown');
|
|
199
|
+
}
|
|
200
|
+
export async function search(query, options = {}) {
|
|
201
|
+
return nativeCall(async (native) => (await native.search(query, options)).map(historyEntry));
|
|
202
|
+
}
|
|
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);
|
|
876
220
|
return {
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
firstTimestampMs: range?.mn ?? null,
|
|
881
|
-
lastTimestampMs: range?.mx ?? null,
|
|
221
|
+
contractVersion,
|
|
222
|
+
sessions: Array.isArray(page.sessions) ? page.sessions.map(catalogSession) : [],
|
|
223
|
+
nextCursor: catalogCursor(page.nextCursor),
|
|
882
224
|
};
|
|
883
|
-
}
|
|
225
|
+
});
|
|
884
226
|
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
+
});
|
|
896
245
|
}
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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);
|
|
246
|
+
export async function discoverAndList(options = {}) {
|
|
247
|
+
await discoverSessions(options);
|
|
248
|
+
return listSessionCatalogPage(options);
|
|
912
249
|
}
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
+
});
|
|
925
296
|
}
|
|
926
|
-
/**
|
|
927
|
-
* Resume command for an entry/session, matching what `ai-hist show` prints.
|
|
928
|
-
* Returns `null` for sources that don't have a resume affordance (relay).
|
|
929
|
-
*/
|
|
930
297
|
export function resumeCommand(entry) {
|
|
931
298
|
if (!entry.sessionId)
|
|
932
299
|
return null;
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
return entry.
|
|
936
|
-
|
|
937
|
-
: `claude --resume ${shellQuote(entry.sessionId)}`;
|
|
938
|
-
case 'codex':
|
|
300
|
+
const resume = (() => {
|
|
301
|
+
if (entry.source === 'claude')
|
|
302
|
+
return `claude --resume ${shellQuote(entry.sessionId)}`;
|
|
303
|
+
if (entry.source === 'codex')
|
|
939
304
|
return `codex resume ${shellQuote(entry.sessionId)}`;
|
|
940
|
-
|
|
941
|
-
return entry.
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
return null;
|
|
948
|
-
}
|
|
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;
|
|
949
312
|
}
|
|
950
313
|
function shellQuote(value) {
|
|
951
|
-
|
|
952
|
-
return value;
|
|
953
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
314
|
+
return /^[A-Za-z0-9._:/-]+$/.test(value) ? value : `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
954
315
|
}
|
|
955
316
|
//# sourceMappingURL=index.js.map
|