pi-hermes-memory 0.7.19 → 0.7.21
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hermes-memory",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.21",
|
|
4
4
|
"description": "🧠 Persistent memory + 🔍 session search + 🛡️ secret scanning for Pi. Token-aware policy-only memory by default, SQLite FTS5 search, auto-consolidation, procedural skills. 368 tests. Ported from Hermes agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@earendil-works/pi-ai": "^0.74.0",
|
|
52
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
52
|
+
"@earendil-works/pi-coding-agent": "^0.80.2",
|
|
53
53
|
"@types/better-sqlite3": "^7.6.13",
|
|
54
54
|
"tsx": "^4.21.0",
|
|
55
55
|
"typebox": "^1.1.33",
|
|
@@ -9,7 +9,7 @@ import { DatabaseManager } from '../store/db.js';
|
|
|
9
9
|
import { indexAllSessions, getSessionStats } from '../store/session-indexer.js';
|
|
10
10
|
import { AGENT_ROOT } from '../paths.js';
|
|
11
11
|
|
|
12
|
-
const SESSIONS_DIR = path.join(AGENT_ROOT, 'sessions');
|
|
12
|
+
const SESSIONS_DIR = process.env.PI_CODING_AGENT_SESSION_DIR || path.join(AGENT_ROOT, 'sessions');
|
|
13
13
|
|
|
14
14
|
export function registerIndexSessionsCommand(pi: ExtensionAPI): void {
|
|
15
15
|
pi.registerCommand("memory-index-sessions", {
|
|
@@ -52,7 +52,9 @@ export function scheduleLiveSessionIndex(
|
|
|
52
52
|
state.promise = new Promise<void>((resolve) => {
|
|
53
53
|
setTimeoutFn(() => {
|
|
54
54
|
try {
|
|
55
|
-
|
|
55
|
+
dbManager.withCorruptionRecovery(() => {
|
|
56
|
+
indexLiveSessionFn(dbManager, sessionManager);
|
|
57
|
+
});
|
|
56
58
|
} catch (err) {
|
|
57
59
|
try { options.onError?.(err); } catch { /* best effort */ }
|
|
58
60
|
} finally {
|
package/src/index.ts
CHANGED
|
@@ -246,12 +246,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
246
246
|
if (sessionFile && require("node:fs").existsSync(sessionFile)) {
|
|
247
247
|
const sessionData = parseSessionFile(sessionFile);
|
|
248
248
|
if (sessionData) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
249
|
+
dbManager.withCorruptionRecovery(() => {
|
|
250
|
+
indexSession(dbManager, sessionData);
|
|
251
|
+
// Keep session_files metadata in sync with the final on-disk state.
|
|
252
|
+
// Pi appends the closing session entry on shutdown after the last
|
|
253
|
+
// message_end, so without this upsert the stored size/mtime would be
|
|
254
|
+
// stale and the next startup would re-parse this file unnecessarily.
|
|
255
|
+
upsertSessionFileMetadata(dbManager, sessionFile, sessionData.id);
|
|
256
|
+
});
|
|
255
257
|
}
|
|
256
258
|
}
|
|
257
259
|
} catch {
|
package/src/store/db.ts
CHANGED
|
@@ -7,6 +7,7 @@ type StatementLike = {
|
|
|
7
7
|
run: (...args: any[]) => any;
|
|
8
8
|
get: (...args: any[]) => any;
|
|
9
9
|
all: (...args: any[]) => any;
|
|
10
|
+
iterate?: (...args: any[]) => Iterable<Record<string, unknown>>;
|
|
10
11
|
};
|
|
11
12
|
|
|
12
13
|
type DatabaseLike = {
|
|
@@ -25,6 +26,39 @@ type BunDatabaseInstance = {
|
|
|
25
26
|
transaction?: (fn: any) => any;
|
|
26
27
|
};
|
|
27
28
|
|
|
29
|
+
type DatabaseFileSuffix = '' | '-wal' | '-shm';
|
|
30
|
+
|
|
31
|
+
type MovedDatabaseFile = {
|
|
32
|
+
original: string;
|
|
33
|
+
backup: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export interface DatabaseRecoveryResult {
|
|
37
|
+
strategy: 'rebuilt' | 'recreated-empty';
|
|
38
|
+
backupPaths: string[];
|
|
39
|
+
recoveredRows?: Record<string, number>;
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class DatabaseCorruptionError extends Error {
|
|
44
|
+
code = 'SQLITE_CORRUPT';
|
|
45
|
+
|
|
46
|
+
constructor(message: string) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = 'DatabaseCorruptionError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const SQLITE_WAL_AUTOCHECKPOINT_PAGES = 1000;
|
|
53
|
+
|
|
54
|
+
const DATABASE_FILE_SUFFIXES: readonly DatabaseFileSuffix[] = ['', '-wal', '-shm'];
|
|
55
|
+
const MEMORY_TARGETS = new Set(['memory', 'user', 'failure']);
|
|
56
|
+
const MEMORY_CATEGORIES = new Set(['failure', 'correction', 'insight', 'preference', 'convention', 'tool-quirk']);
|
|
57
|
+
|
|
58
|
+
function quoteIdentifier(identifier: string): string {
|
|
59
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
60
|
+
}
|
|
61
|
+
|
|
28
62
|
function createBunCompatDatabaseCtor(require: NodeRequire): DatabaseCtor {
|
|
29
63
|
const bunSqlite = require('bun:sqlite') as { Database: new (dbPath: string) => BunDatabaseInstance };
|
|
30
64
|
|
|
@@ -77,11 +111,37 @@ const Database = loadDatabaseCtor();
|
|
|
77
111
|
export class DatabaseManager {
|
|
78
112
|
private db: DatabaseLike | null = null;
|
|
79
113
|
private readonly dbPath: string;
|
|
114
|
+
private lastRecovery: DatabaseRecoveryResult | null = null;
|
|
80
115
|
|
|
81
116
|
constructor(memoryDir: string) {
|
|
82
117
|
this.dbPath = path.join(memoryDir, 'sessions.db');
|
|
83
118
|
}
|
|
84
119
|
|
|
120
|
+
/**
|
|
121
|
+
* True when an error indicates SQLite file/page corruption rather than a
|
|
122
|
+
* normal constraint, migration, or query failure.
|
|
123
|
+
*/
|
|
124
|
+
static isCorruptionError(err: unknown): boolean {
|
|
125
|
+
if (!err) return false;
|
|
126
|
+
|
|
127
|
+
const code = typeof err === 'object' && 'code' in err ? String((err as { code?: unknown }).code) : '';
|
|
128
|
+
if (code === 'SQLITE_CORRUPT' || code === 'SQLITE_NOTADB') return true;
|
|
129
|
+
|
|
130
|
+
const message = DatabaseManager.errorMessage(err).toLowerCase();
|
|
131
|
+
return message.includes('database disk image is malformed')
|
|
132
|
+
|| message.includes('file is not a database')
|
|
133
|
+
|| message.includes('database schema is corrupt')
|
|
134
|
+
|| message.includes('malformed database schema')
|
|
135
|
+
|| message.includes('btreeinitpage')
|
|
136
|
+
|| message.includes('sqlite_corrupt')
|
|
137
|
+
|| message.includes('sqlite_notadb');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private static errorMessage(err: unknown): string {
|
|
141
|
+
if (err instanceof Error) return err.message;
|
|
142
|
+
return String(err);
|
|
143
|
+
}
|
|
144
|
+
|
|
85
145
|
/**
|
|
86
146
|
* Get the database instance. Creates/opens on first call.
|
|
87
147
|
*/
|
|
@@ -92,60 +152,468 @@ export class DatabaseManager {
|
|
|
92
152
|
return this.db;
|
|
93
153
|
}
|
|
94
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Last self-heal performed by this manager, if any. Exposed for diagnostics
|
|
157
|
+
* and tests; normal callers do not need it.
|
|
158
|
+
*/
|
|
159
|
+
getLastRecovery(): DatabaseRecoveryResult | null {
|
|
160
|
+
return this.lastRecovery;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Retry a DB operation once after quarantining/rebuilding a corrupt DB.
|
|
165
|
+
*/
|
|
166
|
+
withCorruptionRecovery<T>(operation: () => T): T {
|
|
167
|
+
try {
|
|
168
|
+
return operation();
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (!DatabaseManager.isCorruptionError(err)) {
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
this.recoverFromCorruption(err);
|
|
174
|
+
return operation();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Close any open handle, rebuild/quarantine the DB file set, and let the next
|
|
180
|
+
* getDb() reopen a clean database.
|
|
181
|
+
*/
|
|
182
|
+
recoverFromCorruption(cause?: unknown): DatabaseRecoveryResult {
|
|
183
|
+
this.close();
|
|
184
|
+
const recovery = this.recoverDatabaseFile(cause);
|
|
185
|
+
this.lastRecovery = recovery;
|
|
186
|
+
return recovery;
|
|
187
|
+
}
|
|
188
|
+
|
|
95
189
|
/**
|
|
96
190
|
* Open the database and initialize schema.
|
|
97
191
|
*/
|
|
98
192
|
private open(): DatabaseLike {
|
|
99
|
-
// Ensure directory exists
|
|
100
193
|
const dir = path.dirname(this.dbPath);
|
|
101
194
|
if (!fs.existsSync(dir)) {
|
|
102
195
|
fs.mkdirSync(dir, { recursive: true });
|
|
103
196
|
}
|
|
104
197
|
|
|
198
|
+
try {
|
|
199
|
+
return this.openUnchecked();
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (!DatabaseManager.isCorruptionError(err)) {
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const recovery = this.recoverDatabaseFile(err);
|
|
206
|
+
this.lastRecovery = recovery;
|
|
207
|
+
return this.openUnchecked();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private openUnchecked(): DatabaseLike {
|
|
212
|
+
const existed = this.hasExistingMainDatabaseFile();
|
|
105
213
|
const db = new Database(this.dbPath);
|
|
214
|
+
let ok = false;
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
if (existed) {
|
|
218
|
+
this.assertIntegrityOk(db, 'quick_check', 'before schema initialization');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.configureConnection(db);
|
|
222
|
+
this.initializeSchema(db);
|
|
223
|
+
this.assertIntegrityOk(db, 'quick_check', 'after schema initialization');
|
|
224
|
+
ok = true;
|
|
225
|
+
return db;
|
|
226
|
+
} finally {
|
|
227
|
+
if (!ok) {
|
|
228
|
+
this.safeClose(db);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
106
232
|
|
|
107
|
-
|
|
233
|
+
private configureConnection(db: DatabaseLike): void {
|
|
234
|
+
// Enable WAL mode + FK enforcement for each connection. Keep SQLite's
|
|
235
|
+
// default WAL autocheckpoint size; a very aggressive checkpoint cadence
|
|
236
|
+
// increases the chance that abrupt VM/host shutdown catches a checkpoint.
|
|
108
237
|
db.exec('PRAGMA journal_mode = WAL');
|
|
109
|
-
db.exec(
|
|
238
|
+
db.exec(`PRAGMA wal_autocheckpoint = ${SQLITE_WAL_AUTOCHECKPOINT_PAGES}`);
|
|
110
239
|
db.exec('PRAGMA journal_size_limit = 5242880');
|
|
111
240
|
db.exec('PRAGMA foreign_keys = ON');
|
|
241
|
+
}
|
|
112
242
|
|
|
243
|
+
private initializeSchema(db: DatabaseLike): void {
|
|
113
244
|
// Create tables and triggers
|
|
114
245
|
try {
|
|
115
246
|
db.exec(SCHEMA_SQL);
|
|
116
247
|
} catch (err) {
|
|
117
|
-
if (!this.
|
|
248
|
+
if (!this.isLegacySchemaError(err)) {
|
|
118
249
|
throw err;
|
|
119
250
|
}
|
|
120
251
|
|
|
121
|
-
// Legacy
|
|
122
|
-
//
|
|
123
|
-
this.
|
|
252
|
+
// Legacy DBs can be missing v0.6 failure-memory columns and/or the project
|
|
253
|
+
// column on sessions/memories. Add missing columns, then retry schema.
|
|
254
|
+
this.ensureLegacySchemaColumns(db);
|
|
124
255
|
db.exec(SCHEMA_SQL);
|
|
125
256
|
}
|
|
126
257
|
|
|
127
|
-
// Extra safety: always ensure legacy
|
|
128
|
-
//
|
|
129
|
-
this.
|
|
258
|
+
// Extra safety: always ensure legacy columns exist, then migrate legacy
|
|
259
|
+
// CHECK(target IN ('memory','user')) constraints to include 'failure'.
|
|
260
|
+
this.ensureLegacySchemaColumns(db);
|
|
130
261
|
this.migrateLegacyMemoriesTargetConstraint(db);
|
|
131
262
|
this.rebuildMemoryFts(db);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private hasExistingMainDatabaseFile(): boolean {
|
|
266
|
+
try {
|
|
267
|
+
return fs.existsSync(this.dbPath) && fs.statSync(this.dbPath).size > 0;
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private databaseFileSetExists(): boolean {
|
|
274
|
+
return DATABASE_FILE_SUFFIXES.some((suffix) => fs.existsSync(`${this.dbPath}${suffix}`));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private assertIntegrityOk(
|
|
278
|
+
db: DatabaseLike,
|
|
279
|
+
check: 'quick_check' | 'integrity_check' = 'quick_check',
|
|
280
|
+
context = '',
|
|
281
|
+
): void {
|
|
282
|
+
const rows = db.prepare(`PRAGMA ${check}`).all() as Record<string, unknown>[];
|
|
283
|
+
const messages = rows.map((row) => String(Object.values(row)[0] ?? ''));
|
|
284
|
+
const failures = messages.filter((message) => message.toLowerCase() !== 'ok');
|
|
285
|
+
|
|
286
|
+
if (rows.length === 0 || failures.length > 0) {
|
|
287
|
+
const detail = failures.length > 0 ? failures.slice(0, 5).join('\n') : 'no result rows';
|
|
288
|
+
const suffix = context ? ` ${context}` : '';
|
|
289
|
+
throw new DatabaseCorruptionError(`SQLite ${check} failed${suffix}: ${detail}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private assertForeignKeysOk(db: DatabaseLike): void {
|
|
294
|
+
const rows = db.prepare('PRAGMA foreign_key_check').all() as Record<string, unknown>[];
|
|
295
|
+
if (rows.length > 0) {
|
|
296
|
+
throw new Error(`SQLite foreign_key_check failed after rebuild (${rows.length} violation${rows.length === 1 ? '' : 's'})`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private recoverDatabaseFile(cause?: unknown): DatabaseRecoveryResult {
|
|
301
|
+
const backupBase = this.corruptBackupBase();
|
|
302
|
+
let rebuildError: unknown;
|
|
303
|
+
|
|
304
|
+
if (this.databaseFileSetExists()) {
|
|
305
|
+
try {
|
|
306
|
+
return this.rebuildDatabaseFromReadableRows(backupBase);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
rebuildError = err;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const moved = this.moveDatabaseFilesToBackup(backupBase);
|
|
313
|
+
return {
|
|
314
|
+
strategy: 'recreated-empty',
|
|
315
|
+
backupPaths: moved.map((file) => file.backup),
|
|
316
|
+
error: DatabaseManager.errorMessage(rebuildError ?? cause ?? 'unknown corruption'),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private rebuildDatabaseFromReadableRows(backupBase: string): DatabaseRecoveryResult {
|
|
321
|
+
const tempPath = this.rebuildTempPath();
|
|
322
|
+
this.removeDatabaseFileSet(tempPath);
|
|
323
|
+
|
|
324
|
+
let source: DatabaseLike | null = null;
|
|
325
|
+
let target: DatabaseLike | null = null;
|
|
326
|
+
let recoveredRows: Record<string, number> | undefined;
|
|
327
|
+
let rebuildOk = false;
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
source = new Database(this.dbPath);
|
|
331
|
+
target = new Database(tempPath);
|
|
332
|
+
target.exec('PRAGMA journal_mode = DELETE');
|
|
333
|
+
target.exec('PRAGMA foreign_keys = OFF');
|
|
334
|
+
target.exec(SCHEMA_SQL);
|
|
335
|
+
|
|
336
|
+
recoveredRows = this.copyRecoverableRows(source, target);
|
|
337
|
+
this.rebuildFtsTables(target);
|
|
338
|
+
this.assertForeignKeysOk(target);
|
|
339
|
+
this.assertIntegrityOk(target, 'quick_check', 'after corruption rebuild');
|
|
340
|
+
rebuildOk = true;
|
|
341
|
+
} finally {
|
|
342
|
+
if (source) this.safeClose(source);
|
|
343
|
+
if (target) this.safeClose(target);
|
|
344
|
+
if (!rebuildOk) this.removeDatabaseFileSet(tempPath);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const moved = this.swapRebuiltDatabase(tempPath, backupBase);
|
|
348
|
+
this.removeDatabaseFileSet(tempPath);
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
strategy: 'rebuilt',
|
|
352
|
+
backupPaths: moved.map((file) => file.backup),
|
|
353
|
+
recoveredRows,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private copyRecoverableRows(source: DatabaseLike, target: DatabaseLike): Record<string, number> {
|
|
358
|
+
return {
|
|
359
|
+
extension_metadata: this.copyExtensionMetadata(source, target),
|
|
360
|
+
sessions: this.copySessions(source, target),
|
|
361
|
+
messages: this.copyMessages(source, target),
|
|
362
|
+
session_files: this.copySessionFiles(source, target),
|
|
363
|
+
memories: this.copyMemories(source, target),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private copyExtensionMetadata(source: DatabaseLike, target: DatabaseLike): number {
|
|
368
|
+
const insert = target.prepare('INSERT OR REPLACE INTO extension_metadata (key, value) VALUES (?, ?)');
|
|
369
|
+
let copied = 0;
|
|
370
|
+
|
|
371
|
+
for (const row of this.readTableRows(source, 'extension_metadata', ['key', 'value'])) {
|
|
372
|
+
if (typeof row.key !== 'string' || typeof row.value !== 'string') continue;
|
|
373
|
+
insert.run(row.key, row.value);
|
|
374
|
+
copied++;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return copied;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private copySessions(source: DatabaseLike, target: DatabaseLike): number {
|
|
381
|
+
const insert = target.prepare(`
|
|
382
|
+
INSERT OR IGNORE INTO sessions (id, project, cwd, started_at, ended_at, message_count)
|
|
383
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
384
|
+
`);
|
|
385
|
+
let copied = 0;
|
|
386
|
+
|
|
387
|
+
for (const row of this.readTableRows(source, 'sessions', ['id', 'project', 'cwd', 'started_at', 'ended_at', 'message_count'])) {
|
|
388
|
+
if (typeof row.id !== 'string' || typeof row.cwd !== 'string' || typeof row.started_at !== 'string') continue;
|
|
389
|
+
const project = typeof row.project === 'string' && row.project ? row.project : (path.basename(row.cwd) || 'unknown');
|
|
390
|
+
insert.run(
|
|
391
|
+
row.id,
|
|
392
|
+
project,
|
|
393
|
+
row.cwd,
|
|
394
|
+
row.started_at,
|
|
395
|
+
this.nullableString(row.ended_at),
|
|
396
|
+
this.integerOr(row.message_count, 0),
|
|
397
|
+
);
|
|
398
|
+
copied++;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return copied;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private copyMessages(source: DatabaseLike, target: DatabaseLike): number {
|
|
405
|
+
const insert = target.prepare(`
|
|
406
|
+
INSERT OR IGNORE INTO messages (id, session_id, role, content, timestamp, tool_calls)
|
|
407
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
408
|
+
`);
|
|
409
|
+
let copied = 0;
|
|
410
|
+
|
|
411
|
+
for (const row of this.readTableRows(source, 'messages', ['id', 'session_id', 'role', 'content', 'timestamp', 'tool_calls'])) {
|
|
412
|
+
if (
|
|
413
|
+
typeof row.id !== 'string'
|
|
414
|
+
|| typeof row.session_id !== 'string'
|
|
415
|
+
|| (row.role !== 'user' && row.role !== 'assistant' && row.role !== 'system')
|
|
416
|
+
|| typeof row.content !== 'string'
|
|
417
|
+
|| typeof row.timestamp !== 'string'
|
|
418
|
+
) {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
insert.run(row.id, row.session_id, row.role, row.content, row.timestamp, this.nullableString(row.tool_calls));
|
|
423
|
+
copied++;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return copied;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private copySessionFiles(source: DatabaseLike, target: DatabaseLike): number {
|
|
430
|
+
const insert = target.prepare(`
|
|
431
|
+
INSERT OR IGNORE INTO session_files (path, session_id, size, mtime_ms, indexed_at)
|
|
432
|
+
VALUES (?, ?, ?, ?, ?)
|
|
433
|
+
`);
|
|
434
|
+
let copied = 0;
|
|
435
|
+
|
|
436
|
+
for (const row of this.readTableRows(source, 'session_files', ['path', 'session_id', 'size', 'mtime_ms', 'indexed_at'])) {
|
|
437
|
+
if (typeof row.path !== 'string' || typeof row.session_id !== 'string') continue;
|
|
438
|
+
insert.run(
|
|
439
|
+
row.path,
|
|
440
|
+
row.session_id,
|
|
441
|
+
this.integerOr(row.size, 0),
|
|
442
|
+
this.integerOr(row.mtime_ms, 0),
|
|
443
|
+
typeof row.indexed_at === 'string' ? row.indexed_at : new Date(0).toISOString(),
|
|
444
|
+
);
|
|
445
|
+
copied++;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return copied;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private copyMemories(source: DatabaseLike, target: DatabaseLike): number {
|
|
452
|
+
const insert = target.prepare(`
|
|
453
|
+
INSERT OR IGNORE INTO memories (id, project, target, category, content, failure_reason, tool_state, corrected_to, created, last_referenced)
|
|
454
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
455
|
+
`);
|
|
456
|
+
let copied = 0;
|
|
457
|
+
|
|
458
|
+
for (const row of this.readTableRows(source, 'memories', [
|
|
459
|
+
'id',
|
|
460
|
+
'project',
|
|
461
|
+
'target',
|
|
462
|
+
'category',
|
|
463
|
+
'content',
|
|
464
|
+
'failure_reason',
|
|
465
|
+
'tool_state',
|
|
466
|
+
'corrected_to',
|
|
467
|
+
'created',
|
|
468
|
+
'last_referenced',
|
|
469
|
+
])) {
|
|
470
|
+
const id = this.integerOr(row.id, NaN);
|
|
471
|
+
if (!Number.isFinite(id) || typeof row.content !== 'string') continue;
|
|
472
|
+
|
|
473
|
+
const targetName = typeof row.target === 'string' && MEMORY_TARGETS.has(row.target) ? row.target : 'memory';
|
|
474
|
+
const category = typeof row.category === 'string' && MEMORY_CATEGORIES.has(row.category) ? row.category : null;
|
|
475
|
+
const created = typeof row.created === 'string' ? row.created : new Date(0).toISOString();
|
|
476
|
+
const lastReferenced = typeof row.last_referenced === 'string' ? row.last_referenced : created;
|
|
477
|
+
|
|
478
|
+
insert.run(
|
|
479
|
+
id,
|
|
480
|
+
this.nullableString(row.project),
|
|
481
|
+
targetName,
|
|
482
|
+
category,
|
|
483
|
+
row.content,
|
|
484
|
+
this.nullableString(row.failure_reason),
|
|
485
|
+
this.nullableString(row.tool_state),
|
|
486
|
+
this.nullableString(row.corrected_to),
|
|
487
|
+
created,
|
|
488
|
+
lastReferenced,
|
|
489
|
+
);
|
|
490
|
+
copied++;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return copied;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private readTableRows(source: DatabaseLike, table: string, desiredColumns: string[]): Iterable<Record<string, unknown>> {
|
|
497
|
+
const columns = this.getColumnNames(source, table);
|
|
498
|
+
const selected = desiredColumns.filter((column) => columns.has(column));
|
|
499
|
+
if (selected.length === 0) return [];
|
|
500
|
+
|
|
501
|
+
const sql = `SELECT ${selected.map(quoteIdentifier).join(', ')} FROM ${quoteIdentifier(table)} NOT INDEXED`;
|
|
502
|
+
const statement = source.prepare(sql);
|
|
503
|
+
if (statement.iterate) {
|
|
504
|
+
return statement.iterate() as Iterable<Record<string, unknown>>;
|
|
505
|
+
}
|
|
506
|
+
return statement.all() as Record<string, unknown>[];
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private getColumnNames(db: DatabaseLike, table: string): Set<string> {
|
|
510
|
+
const rows = db.prepare(`PRAGMA table_info(${quoteIdentifier(table)})`).all() as { name?: unknown }[];
|
|
511
|
+
return new Set(rows.map((row) => row.name).filter((name): name is string => typeof name === 'string'));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private nullableString(value: unknown): string | null {
|
|
515
|
+
return typeof value === 'string' ? value : null;
|
|
516
|
+
}
|
|
132
517
|
|
|
133
|
-
|
|
518
|
+
private integerOr(value: unknown, fallback: number): number {
|
|
519
|
+
if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value);
|
|
520
|
+
if (typeof value === 'bigint') return Number(value);
|
|
521
|
+
if (typeof value === 'string' && value.trim()) {
|
|
522
|
+
const parsed = Number.parseInt(value, 10);
|
|
523
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
524
|
+
}
|
|
525
|
+
return fallback;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private rebuildFtsTables(db: DatabaseLike): void {
|
|
529
|
+
db.exec("INSERT INTO message_fts(message_fts) VALUES('rebuild')");
|
|
530
|
+
db.exec("INSERT INTO memory_fts(memory_fts) VALUES('rebuild')");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private corruptBackupBase(): string {
|
|
534
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
535
|
+
const nonce = Math.random().toString(16).slice(2, 8);
|
|
536
|
+
return `${this.dbPath}.corrupt-${stamp}-${process.pid}-${nonce}`;
|
|
134
537
|
}
|
|
135
538
|
|
|
136
|
-
private
|
|
539
|
+
private rebuildTempPath(): string {
|
|
540
|
+
const stamp = Date.now();
|
|
541
|
+
const nonce = Math.random().toString(16).slice(2, 8);
|
|
542
|
+
return `${this.dbPath}.rebuild-${process.pid}-${stamp}-${nonce}.tmp`;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private swapRebuiltDatabase(tempPath: string, backupBase: string): MovedDatabaseFile[] {
|
|
546
|
+
const moved = this.moveDatabaseFilesToBackup(backupBase);
|
|
547
|
+
try {
|
|
548
|
+
fs.renameSync(tempPath, this.dbPath);
|
|
549
|
+
return moved;
|
|
550
|
+
} catch (err) {
|
|
551
|
+
this.restoreMovedDatabaseFiles(moved);
|
|
552
|
+
this.removeDatabaseFileSet(tempPath);
|
|
553
|
+
throw err;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private moveDatabaseFilesToBackup(backupBase: string): MovedDatabaseFile[] {
|
|
558
|
+
const moved: MovedDatabaseFile[] = [];
|
|
559
|
+
for (const suffix of DATABASE_FILE_SUFFIXES) {
|
|
560
|
+
const original = `${this.dbPath}${suffix}`;
|
|
561
|
+
if (!fs.existsSync(original)) continue;
|
|
562
|
+
|
|
563
|
+
const backup = `${backupBase}${suffix}`;
|
|
564
|
+
fs.rmSync(backup, { force: true });
|
|
565
|
+
fs.renameSync(original, backup);
|
|
566
|
+
moved.push({ original, backup });
|
|
567
|
+
}
|
|
568
|
+
return moved;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private restoreMovedDatabaseFiles(moved: MovedDatabaseFile[]): void {
|
|
572
|
+
for (const file of [...moved].reverse()) {
|
|
573
|
+
try {
|
|
574
|
+
if (!fs.existsSync(file.backup)) continue;
|
|
575
|
+
fs.rmSync(file.original, { force: true });
|
|
576
|
+
fs.renameSync(file.backup, file.original);
|
|
577
|
+
} catch {
|
|
578
|
+
// Best effort. The backup path remains available if restoration fails.
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private removeDatabaseFileSet(basePath: string): void {
|
|
584
|
+
for (const suffix of DATABASE_FILE_SUFFIXES) {
|
|
585
|
+
fs.rmSync(`${basePath}${suffix}`, { force: true });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
private safeClose(db: DatabaseLike): void {
|
|
590
|
+
try { db.close(); } catch { /* best effort */ }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private isLegacySchemaError(err: unknown): boolean {
|
|
137
594
|
if (!(err instanceof Error)) return false;
|
|
138
595
|
const msg = err.message.toLowerCase();
|
|
139
|
-
return msg.includes('no such column: category')
|
|
596
|
+
return msg.includes('no such column: category')
|
|
597
|
+
|| msg.includes('memories(category)')
|
|
598
|
+
|| msg.includes('no such column: project')
|
|
599
|
+
|| msg.includes('sessions(project)')
|
|
600
|
+
|| msg.includes('memories(project)');
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private ensureLegacySchemaColumns(db: DatabaseLike): void {
|
|
604
|
+
this.ensureMemoriesColumns(db);
|
|
605
|
+
this.ensureSessionsColumns(db);
|
|
140
606
|
}
|
|
141
607
|
|
|
142
608
|
private ensureMemoriesColumns(db: DatabaseLike): void {
|
|
143
609
|
const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='memories'").get() as { name: string } | undefined;
|
|
144
610
|
if (!tableExists) return;
|
|
145
611
|
|
|
146
|
-
const
|
|
147
|
-
const names = new Set(columns.map((c) => c.name));
|
|
612
|
+
const names = this.getColumnNames(db, 'memories');
|
|
148
613
|
|
|
614
|
+
if (!names.has('project')) {
|
|
615
|
+
db.exec('ALTER TABLE memories ADD COLUMN project TEXT');
|
|
616
|
+
}
|
|
149
617
|
if (!names.has('category')) {
|
|
150
618
|
db.exec('ALTER TABLE memories ADD COLUMN category TEXT');
|
|
151
619
|
}
|
|
@@ -160,6 +628,40 @@ export class DatabaseManager {
|
|
|
160
628
|
}
|
|
161
629
|
}
|
|
162
630
|
|
|
631
|
+
private ensureSessionsColumns(db: DatabaseLike): void {
|
|
632
|
+
const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'").get() as { name: string } | undefined;
|
|
633
|
+
if (!tableExists) return;
|
|
634
|
+
|
|
635
|
+
const names = this.getColumnNames(db, 'sessions');
|
|
636
|
+
if (!names.has('project')) {
|
|
637
|
+
db.exec('ALTER TABLE sessions ADD COLUMN project TEXT');
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
this.backfillSessionsProject(db);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private backfillSessionsProject(db: DatabaseLike): void {
|
|
644
|
+
const names = this.getColumnNames(db, 'sessions');
|
|
645
|
+
if (!names.has('project') || !names.has('cwd') || !names.has('id')) return;
|
|
646
|
+
|
|
647
|
+
const rows = db.prepare('SELECT id, cwd, project FROM sessions').all() as Array<{
|
|
648
|
+
id?: unknown;
|
|
649
|
+
cwd?: unknown;
|
|
650
|
+
project?: unknown;
|
|
651
|
+
}>;
|
|
652
|
+
const update = db.prepare('UPDATE sessions SET project = ? WHERE id = ?');
|
|
653
|
+
|
|
654
|
+
for (const row of rows) {
|
|
655
|
+
if (typeof row.id !== 'string') continue;
|
|
656
|
+
if (typeof row.project === 'string' && row.project.trim()) continue;
|
|
657
|
+
|
|
658
|
+
const project = typeof row.cwd === 'string' && row.cwd.trim()
|
|
659
|
+
? (path.basename(row.cwd) || 'unknown')
|
|
660
|
+
: 'unknown';
|
|
661
|
+
update.run(project, row.id);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
163
665
|
private migrateLegacyMemoriesTargetConstraint(db: DatabaseLike): void {
|
|
164
666
|
const tableSqlRow = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='memories'").get() as { sql?: string } | undefined;
|
|
165
667
|
const tableSql = tableSqlRow?.sql ?? '';
|
|
@@ -254,7 +756,7 @@ export class DatabaseManager {
|
|
|
254
756
|
close(): void {
|
|
255
757
|
if (this.db) {
|
|
256
758
|
try { this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* best effort */ }
|
|
257
|
-
this.db.close();
|
|
759
|
+
try { this.db.close(); } catch { /* best effort — close may throw on a corrupt handle */ }
|
|
258
760
|
this.db = null;
|
|
259
761
|
}
|
|
260
762
|
}
|
|
@@ -43,6 +43,10 @@ export interface IncrementalIndexOptions {
|
|
|
43
43
|
* @returns IndexResult with count of messages indexed
|
|
44
44
|
*/
|
|
45
45
|
export function indexSession(dbManager: DatabaseManager, session: ParsedSession): IndexResult {
|
|
46
|
+
return dbManager.withCorruptionRecovery(() => indexSessionOnce(dbManager, session));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function indexSessionOnce(dbManager: DatabaseManager, session: ParsedSession): IndexResult {
|
|
46
50
|
const db = dbManager.getDb();
|
|
47
51
|
|
|
48
52
|
const existingSession = db.prepare('SELECT id FROM sessions WHERE id = ?').get(session.id) as { id: string } | undefined;
|
|
@@ -210,6 +214,10 @@ export function indexCurrentSession(dbManager: DatabaseManager, sessionManager:
|
|
|
210
214
|
}
|
|
211
215
|
|
|
212
216
|
export function indexLiveSession(dbManager: DatabaseManager, sessionManager: SessionManagerSnapshot): IndexResult | null {
|
|
217
|
+
return dbManager.withCorruptionRecovery(() => indexLiveSessionOnce(dbManager, sessionManager));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function indexLiveSessionOnce(dbManager: DatabaseManager, sessionManager: SessionManagerSnapshot): IndexResult | null {
|
|
213
221
|
const sessionFile = sessionManager.getSessionFile?.();
|
|
214
222
|
if (sessionFile && fs.existsSync(sessionFile)) {
|
|
215
223
|
const session = parseSessionFile(sessionFile);
|