Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/platform/chronicle/node/sessionStore.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { mkdirSync } from 'fs';
7
import { DatabaseSync } from 'node:sqlite';
8
import { dirname } from 'path';
9
import type { CheckpointRow, FileRow, ISessionStore, RefRow, SearchResult, SessionRow, TurnRow } from '../common/sessionStore';
10
11
/**
12
* SQLite authorizer action codes that are safe for read-only access.
13
* Values from https://www.sqlite.org/c3ref/c_alter_table.html
14
* (node:sqlite constants do not expose authorizer codes)
15
*/
16
const SQLITE_OK = 0;
17
const SQLITE_DENY = 1;
18
const SQLITE_READ = 20;
19
const SQLITE_SELECT = 21;
20
const SQLITE_FUNCTION = 31;
21
const SQLITE_PRAGMA = 19;
22
const SQLITE_RECURSIVE = 33;
23
24
const READ_ONLY_ACTION_CODES = new Set([
25
SQLITE_READ, // read a column
26
SQLITE_SELECT, // top-level SELECT
27
SQLITE_FUNCTION, // call a function (needed for bm25(), etc.)
28
SQLITE_RECURSIVE, // recursive CTE
29
]);
30
31
/** Schema version — bump when altering tables so existing DBs get migrated. */
32
const SCHEMA_VERSION = 3;
33
34
/**
35
* Session store backed by SQLite + FTS5.
36
*
37
* Schema is identical to the copilot-agent-runtime SessionStore so that
38
* tooling and queries are portable across CLI and VS Code surfaces.
39
* The store is populated incrementally by live hooks — no background indexing.
40
*/
41
export class SessionStore implements ISessionStore {
42
declare readonly _serviceBrand: undefined;
43
private db: DatabaseSync | null = null;
44
private readonly dbPath: string;
45
46
constructor(dbPath: string) {
47
this.dbPath = dbPath;
48
}
49
50
/**
51
* Get the path to the database file.
52
*/
53
getPath(): string {
54
return this.dbPath;
55
}
56
57
/**
58
* Lazily open (or create) the database and ensure the schema exists.
59
*/
60
private ensureDb(): DatabaseSync {
61
if (this.db) {
62
return this.db;
63
}
64
65
if (this.dbPath !== ':memory:') {
66
mkdirSync(dirname(this.dbPath), { recursive: true });
67
}
68
69
const db = new DatabaseSync(this.dbPath);
70
try {
71
db.exec('PRAGMA journal_mode = WAL');
72
db.exec('PRAGMA busy_timeout = 3000');
73
db.exec('PRAGMA foreign_keys = ON');
74
this.db = db;
75
this.ensureSchema();
76
} catch (err) {
77
db.close();
78
this.db = null;
79
throw err;
80
}
81
return this.db;
82
}
83
84
/**
85
* Create or migrate tables to the current schema version.
86
*
87
* IMPORTANT: When bumping SCHEMA_VERSION, add explicit migration logic
88
* for each version step (e.g., v1→v2). CREATE TABLE IF NOT EXISTS does
89
* NOT alter existing tables — use ALTER TABLE for schema changes.
90
*/
91
private ensureSchema(): void {
92
const db = this.db!;
93
94
const versionRow = (() => {
95
try {
96
const stmt = db.prepare('SELECT version FROM schema_version LIMIT 1');
97
return stmt.get() as unknown as { version: number } | undefined;
98
} catch {
99
return undefined;
100
}
101
})();
102
103
const currentVersion = versionRow?.version ?? 0;
104
105
if (currentVersion >= SCHEMA_VERSION) {
106
return;
107
}
108
109
db.exec(`
110
CREATE TABLE IF NOT EXISTS schema_version (
111
version INTEGER NOT NULL
112
);
113
114
CREATE TABLE IF NOT EXISTS sessions (
115
id TEXT PRIMARY KEY,
116
cwd TEXT,
117
repository TEXT,
118
host_type TEXT,
119
branch TEXT,
120
summary TEXT,
121
agent_name TEXT,
122
agent_description TEXT,
123
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
124
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
125
);
126
127
CREATE TABLE IF NOT EXISTS turns (
128
id INTEGER PRIMARY KEY AUTOINCREMENT,
129
session_id TEXT NOT NULL REFERENCES sessions(id),
130
turn_index INTEGER NOT NULL,
131
user_message TEXT,
132
assistant_response TEXT,
133
timestamp TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
134
UNIQUE(session_id, turn_index)
135
);
136
137
CREATE TABLE IF NOT EXISTS checkpoints (
138
id INTEGER PRIMARY KEY AUTOINCREMENT,
139
session_id TEXT NOT NULL REFERENCES sessions(id),
140
checkpoint_number INTEGER NOT NULL,
141
title TEXT,
142
overview TEXT,
143
history TEXT,
144
work_done TEXT,
145
technical_details TEXT,
146
important_files TEXT,
147
next_steps TEXT,
148
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
149
UNIQUE(session_id, checkpoint_number)
150
);
151
152
CREATE TABLE IF NOT EXISTS session_files (
153
id INTEGER PRIMARY KEY AUTOINCREMENT,
154
session_id TEXT NOT NULL REFERENCES sessions(id),
155
file_path TEXT NOT NULL,
156
tool_name TEXT,
157
turn_index INTEGER,
158
first_seen_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
159
UNIQUE(session_id, file_path)
160
);
161
162
CREATE TABLE IF NOT EXISTS session_refs (
163
id INTEGER PRIMARY KEY AUTOINCREMENT,
164
session_id TEXT NOT NULL REFERENCES sessions(id),
165
ref_type TEXT NOT NULL,
166
ref_value TEXT NOT NULL,
167
turn_index INTEGER,
168
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
169
UNIQUE(session_id, ref_type, ref_value)
170
);
171
172
CREATE INDEX IF NOT EXISTS idx_sessions_repo ON sessions(repository);
173
CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
174
CREATE INDEX IF NOT EXISTS idx_session_files_path ON session_files(file_path);
175
CREATE INDEX IF NOT EXISTS idx_session_refs_type_value ON session_refs(ref_type, ref_value);
176
CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id);
177
CREATE INDEX IF NOT EXISTS idx_checkpoints_session ON checkpoints(session_id);
178
`);
179
180
// FTS5 virtual table — CREATE VIRTUAL TABLE doesn't support IF NOT EXISTS
181
// in all SQLite builds, so we guard with a check.
182
const ftsExists = db.prepare('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=\'search_index\'').get();
183
if (!ftsExists) {
184
db.exec(`
185
CREATE VIRTUAL TABLE search_index USING fts5(
186
content,
187
session_id UNINDEXED,
188
source_type UNINDEXED,
189
source_id UNINDEXED
190
);
191
`);
192
}
193
194
// ── Schema migrations ────────────────────────────────────────────
195
if (currentVersion >= 1 && currentVersion < 2) {
196
db.exec('ALTER TABLE sessions ADD COLUMN host_type TEXT');
197
}
198
if (currentVersion >= 1 && currentVersion < 3) {
199
db.exec('ALTER TABLE sessions ADD COLUMN agent_name TEXT');
200
db.exec('ALTER TABLE sessions ADD COLUMN agent_description TEXT');
201
}
202
203
// Update or insert schema version
204
if (currentVersion === 0) {
205
db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(SCHEMA_VERSION);
206
} else {
207
db.prepare('UPDATE schema_version SET version = ?').run(SCHEMA_VERSION);
208
}
209
}
210
211
// ── CRUD Methods ────────────────────────────────────────────────────
212
213
/**
214
* Insert or update a session's metadata.
215
*/
216
upsertSession(session: SessionRow): void {
217
const db = this.ensureDb();
218
db.prepare(
219
`INSERT INTO sessions (id, cwd, repository, host_type, branch, summary, agent_name, agent_description, created_at, updated_at)
220
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
221
ON CONFLICT(id) DO UPDATE SET
222
cwd = COALESCE(excluded.cwd, cwd),
223
repository = COALESCE(excluded.repository, repository),
224
host_type = COALESCE(excluded.host_type, host_type),
225
branch = COALESCE(excluded.branch, branch),
226
summary = COALESCE(excluded.summary, summary),
227
agent_name = COALESCE(excluded.agent_name, agent_name),
228
agent_description = COALESCE(excluded.agent_description, agent_description),
229
created_at = MIN(created_at, excluded.created_at),
230
updated_at = MAX(updated_at, excluded.updated_at)`,
231
).run(
232
session.id,
233
session.cwd ?? null,
234
session.repository ?? null,
235
session.host_type ?? null,
236
session.branch ?? null,
237
session.summary ?? null,
238
session.agent_name ?? null,
239
session.agent_description ?? null,
240
session.created_at ?? new Date().toISOString(),
241
session.updated_at ?? new Date().toISOString(),
242
);
243
}
244
245
/**
246
* Insert a conversation turn and index it for full-text search.
247
*/
248
insertTurn(turn: TurnRow): void {
249
const db = this.ensureDb();
250
251
// Ensure session exists (lightweight upsert)
252
db.prepare('INSERT OR IGNORE INTO sessions (id) VALUES (?)').run(turn.session_id);
253
254
db.prepare(
255
`INSERT INTO turns (session_id, turn_index, user_message, assistant_response, timestamp)
256
VALUES (?, ?, ?, ?, ?)
257
ON CONFLICT(session_id, turn_index) DO UPDATE SET
258
user_message = COALESCE(excluded.user_message, user_message),
259
assistant_response = COALESCE(excluded.assistant_response, assistant_response)`,
260
).run(
261
turn.session_id,
262
turn.turn_index,
263
turn.user_message ?? null,
264
turn.assistant_response ?? null,
265
turn.timestamp ?? new Date().toISOString(),
266
);
267
268
// Index searchable content
269
const content = [turn.user_message, turn.assistant_response].filter(Boolean).join('\n');
270
if (content) {
271
const turnId = `${turn.session_id}:turn:${turn.turn_index}`;
272
// Remove old FTS entry if exists, then insert new one
273
db.prepare('DELETE FROM search_index WHERE source_id = ?').run(turnId);
274
db.prepare(
275
'INSERT INTO search_index (content, session_id, source_type, source_id) VALUES (?, ?, ?, ?)',
276
).run(content, turn.session_id, 'turn', turnId);
277
}
278
}
279
280
/**
281
* Insert a compaction checkpoint and index its sections for full-text search.
282
*/
283
insertCheckpoint(checkpoint: CheckpointRow): void {
284
const db = this.ensureDb();
285
286
// Ensure session exists
287
db.prepare('INSERT OR IGNORE INTO sessions (id) VALUES (?)').run(checkpoint.session_id);
288
289
db.prepare(
290
`INSERT INTO checkpoints (session_id, checkpoint_number, title, overview, history, work_done, technical_details, important_files, next_steps, created_at)
291
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
292
ON CONFLICT(session_id, checkpoint_number) DO UPDATE SET
293
title = COALESCE(excluded.title, title),
294
overview = COALESCE(excluded.overview, overview),
295
history = COALESCE(excluded.history, history),
296
work_done = COALESCE(excluded.work_done, work_done),
297
technical_details = COALESCE(excluded.technical_details, technical_details),
298
important_files = COALESCE(excluded.important_files, important_files),
299
next_steps = COALESCE(excluded.next_steps, next_steps)`,
300
).run(
301
checkpoint.session_id,
302
checkpoint.checkpoint_number,
303
checkpoint.title ?? null,
304
checkpoint.overview ?? null,
305
checkpoint.history ?? null,
306
checkpoint.work_done ?? null,
307
checkpoint.technical_details ?? null,
308
checkpoint.important_files ?? null,
309
checkpoint.next_steps ?? null,
310
checkpoint.created_at ?? new Date().toISOString(),
311
);
312
313
// Index each non-empty section
314
const sections: [string, string | undefined][] = [
315
['checkpoint_overview', checkpoint.overview],
316
['checkpoint_history', checkpoint.history],
317
['checkpoint_work_done', checkpoint.work_done],
318
['checkpoint_technical', checkpoint.technical_details],
319
['checkpoint_files', checkpoint.important_files],
320
['checkpoint_next_steps', checkpoint.next_steps],
321
];
322
323
for (const [sourceType, content] of sections) {
324
if (!content) { continue; }
325
const sourceId = `${checkpoint.session_id}:ckpt:${checkpoint.checkpoint_number}:${sourceType}`;
326
db.prepare('DELETE FROM search_index WHERE source_id = ?').run(sourceId);
327
db.prepare(
328
'INSERT INTO search_index (content, session_id, source_type, source_id) VALUES (?, ?, ?, ?)',
329
).run(content, checkpoint.session_id, sourceType, sourceId);
330
}
331
}
332
333
/**
334
* Record a file touched during a session.
335
* Uses INSERT OR IGNORE so the first occurrence wins.
336
*/
337
insertFile(file: FileRow): void {
338
const db = this.ensureDb();
339
db.prepare('INSERT OR IGNORE INTO sessions (id) VALUES (?)').run(file.session_id);
340
db.prepare(
341
`INSERT OR IGNORE INTO session_files (session_id, file_path, tool_name, turn_index, first_seen_at)
342
VALUES (?, ?, ?, ?, ?)`,
343
).run(
344
file.session_id,
345
file.file_path,
346
file.tool_name ?? null,
347
file.turn_index ?? null,
348
file.first_seen_at ?? new Date().toISOString(),
349
);
350
}
351
352
/**
353
* Record a reference (commit, PR, issue) for a session.
354
*/
355
insertRef(ref: RefRow): void {
356
const db = this.ensureDb();
357
db.prepare('INSERT OR IGNORE INTO sessions (id) VALUES (?)').run(ref.session_id);
358
db.prepare(
359
`INSERT OR IGNORE INTO session_refs (session_id, ref_type, ref_value, turn_index, created_at)
360
VALUES (?, ?, ?, ?, ?)`,
361
).run(
362
ref.session_id,
363
ref.ref_type,
364
ref.ref_value,
365
ref.turn_index ?? null,
366
ref.created_at ?? new Date().toISOString(),
367
);
368
}
369
370
// ── Query Methods ───────────────────────────────────────────────────
371
372
/**
373
* Index a workspace artifact (e.g. plan.md, context files) for full-text search.
374
* Content is upserted: subsequent writes to the same file replace the previous index entry.
375
*/
376
indexWorkspaceArtifact(sessionId: string, filePath: string, content: string): void {
377
if (!content.trim()) { return; }
378
const db = this.ensureDb();
379
const sourceId = `${sessionId}:workspace:${filePath}`;
380
db.prepare('DELETE FROM search_index WHERE source_id = ?').run(sourceId);
381
db.prepare('INSERT INTO search_index (content, session_id, source_type, source_id) VALUES (?, ?, ?, ?)').run(
382
content,
383
sessionId,
384
'workspace_artifact',
385
sourceId,
386
);
387
}
388
389
/**
390
* Full-text search across all indexed content (turns, checkpoint sections, and workspace artifacts).
391
* Uses FTS5 MATCH with BM25 ranking.
392
*
393
* @param query FTS5 query string (supports AND, OR, NOT, phrase "..." etc.)
394
* @param limit Maximum results to return (default 20)
395
*/
396
search(query: string, limit: number = 20): SearchResult[] {
397
const db = this.ensureDb();
398
const stmt = db.prepare(`
399
SELECT content, session_id, source_type, bm25(search_index) AS rank
400
FROM search_index
401
WHERE search_index MATCH ?
402
ORDER BY rank
403
LIMIT ?
404
`);
405
return stmt.all(query, limit) as unknown as SearchResult[];
406
}
407
408
/**
409
* Get a session by ID.
410
*/
411
getSession(sessionId: string): SessionRow | undefined {
412
const db = this.ensureDb();
413
return db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId) as unknown as SessionRow | undefined;
414
}
415
416
/**
417
* Get all turns for a session, ordered by turn index.
418
*/
419
getTurns(sessionId: string): TurnRow[] {
420
const db = this.ensureDb();
421
return db
422
.prepare('SELECT * FROM turns WHERE session_id = ? ORDER BY turn_index')
423
.all(sessionId) as unknown as TurnRow[];
424
}
425
426
/**
427
* Get all files touched in a session.
428
*/
429
getFiles(sessionId: string): FileRow[] {
430
const db = this.ensureDb();
431
return db.prepare('SELECT * FROM session_files WHERE session_id = ?').all(sessionId) as unknown as FileRow[];
432
}
433
434
/**
435
* Get all refs for a session.
436
*/
437
getRefs(sessionId: string): RefRow[] {
438
const db = this.ensureDb();
439
return db.prepare('SELECT * FROM session_refs WHERE session_id = ?').all(sessionId) as unknown as RefRow[];
440
}
441
442
/**
443
* Execute a raw read-only SQL query against the store.
444
* Uses SQLite's authorizer API to enforce read-only access at the engine level,
445
* blocking INSERT, UPDATE, DELETE, DROP, CREATE, ATTACH, PRAGMA, etc.
446
*/
447
executeReadOnly(sql: string): Record<string, unknown>[] {
448
const db = this.ensureDb();
449
450
// Use setAuthorizer to enforce read-only when available (Node.js 24.2+)
451
const hasAuthorizer = typeof (db as DatabaseSync & { setAuthorizer?: unknown }).setAuthorizer === 'function';
452
453
if (!hasAuthorizer) {
454
// Fail closed: refuse to execute arbitrary SQL without engine-level enforcement
455
throw new Error('executeReadOnly requires SQLite authorizer support (Node.js 24.2+)');
456
}
457
458
(db as DatabaseSync & { setAuthorizer: (cb: ((actionCode: number, p1: string | null) => number) | null) => void }).setAuthorizer((actionCode: number, p1: string | null) => {
459
if (READ_ONLY_ACTION_CODES.has(actionCode)) {
460
return SQLITE_OK;
461
}
462
// FTS5 internally uses PRAGMA data_version to detect DB changes
463
if (actionCode === SQLITE_PRAGMA && p1 === 'data_version') {
464
return SQLITE_OK;
465
}
466
return SQLITE_DENY;
467
});
468
469
try {
470
return db.prepare(sql).all() as Record<string, unknown>[];
471
} finally {
472
(db as DatabaseSync & { setAuthorizer: (cb: null) => void }).setAuthorizer(null);
473
}
474
}
475
476
/**
477
* Execute a read-only SQL query without authorizer enforcement.
478
* Used as a fallback when the authorizer API is unavailable (Node.js < 24.2).
479
* Callers MUST validate SQL safety before calling this method.
480
*/
481
executeReadOnlyFallback(sql: string): Record<string, unknown>[] {
482
const db = this.ensureDb();
483
return db.prepare(sql).all() as Record<string, unknown>[];
484
}
485
486
/**
487
* Get the highest turn_index for a session, or -1 if no turns exist.
488
*/
489
getMaxTurnIndex(sessionId: string): number {
490
const db = this.ensureDb();
491
const row = db
492
.prepare('SELECT MAX(turn_index) as max_idx FROM turns WHERE session_id = ?')
493
.get(sessionId) as unknown as { max_idx: number | null } | undefined;
494
return row?.max_idx ?? -1;
495
}
496
497
/**
498
* Get basic stats about the store.
499
*/
500
getStats(): { sessions: number; turns: number; checkpoints: number; files: number; refs: number } {
501
const db = this.ensureDb();
502
const count = (table: string) =>
503
(db.prepare(`SELECT COUNT(*) as c FROM ${table}`).get() as unknown as { c: number }).c;
504
return {
505
sessions: count('sessions'),
506
turns: count('turns'),
507
checkpoints: count('checkpoints'),
508
files: count('session_files'),
509
refs: count('session_refs'),
510
};
511
}
512
513
/**
514
* Run a function inside a SQLite transaction (BEGIN/COMMIT/ROLLBACK).
515
* All writes are batched into a single atomic commit, which is significantly
516
* faster than auto-committing each individual INSERT.
517
*/
518
runInTransaction(fn: () => void): void {
519
const db = this.ensureDb();
520
db.exec('BEGIN');
521
try {
522
fn();
523
db.exec('COMMIT');
524
} catch (err) {
525
db.exec('ROLLBACK');
526
throw err;
527
}
528
}
529
530
/**
531
* Close the database connection.
532
*/
533
close(): void {
534
if (this.db) {
535
this.db.close();
536
this.db = null;
537
}
538
}
539
}
540
541