pi-hermes-memory 0.7.18 → 0.7.20
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 +1 -1
- package/package.json +1 -1
- package/src/handlers/session-backfill.ts +15 -11
- package/src/handlers/session-live-index.ts +3 -1
- package/src/index.ts +9 -2
- package/src/store/db.ts +462 -5
- package/src/store/schema.ts +11 -0
- package/src/store/session-indexer.ts +153 -26
package/README.md
CHANGED
|
@@ -283,7 +283,7 @@ Search behavior notes:
|
|
|
283
283
|
- Exact phrases can be requested with quotes, for example `"memory search"`.
|
|
284
284
|
- Advanced FTS queries with operators like `OR` still work when you need them.
|
|
285
285
|
|
|
286
|
-
Session history is indexed automatically on session shutdown. To bulk-import existing sessions:
|
|
286
|
+
Session history is indexed automatically during the active session and on session shutdown. Startup also runs a bounded incremental backfill for missed sessions: it compares stored file metadata and only parses files without matching metadata, capped per startup. To bulk-import existing sessions manually:
|
|
287
287
|
|
|
288
288
|
```
|
|
289
289
|
/memory-index-sessions
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hermes-memory",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.20",
|
|
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",
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { DatabaseManager } from '../store/db.js';
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
indexChangedSessions,
|
|
4
4
|
needsBackfill,
|
|
5
5
|
touchBackfillTimestamp,
|
|
6
6
|
type BulkIndexResult,
|
|
7
7
|
} from '../store/session-indexer.js';
|
|
8
8
|
|
|
9
9
|
export const SESSION_BACKFILL_SHUTDOWN_TIMEOUT_MS = 5000;
|
|
10
|
+
export const SESSION_BACKFILL_MAX_FILES = 50;
|
|
10
11
|
|
|
11
12
|
type NotifyLevel = 'info' | 'warning' | 'error';
|
|
12
13
|
type NotifyFn = (message: string, level: NotifyLevel) => void;
|
|
@@ -28,13 +29,15 @@ export interface ScheduleSessionBackfillOptions {
|
|
|
28
29
|
state?: SessionBackfillState;
|
|
29
30
|
setTimeoutFn?: SetTimeoutFn;
|
|
30
31
|
needsBackfillFn?: typeof needsBackfill;
|
|
31
|
-
|
|
32
|
+
indexSessionsFn?: typeof indexChangedSessions;
|
|
33
|
+
maxFilesToIndex?: number;
|
|
32
34
|
touchBackfillTimestampFn?: typeof touchBackfillTimestamp;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
function formatBackfillResult(result: BulkIndexResult): string {
|
|
36
38
|
const errorSuffix = result.errors.length > 0 ? ` (${result.errors.length} file error${result.errors.length === 1 ? '' : 's'})` : '';
|
|
37
|
-
|
|
39
|
+
const limitSuffix = result.reachedLimit ? ' (startup limit reached)' : '';
|
|
40
|
+
return `🧠 Session backfill complete: ${result.sessionsIndexed} indexed, ${result.sessionsSkipped} skipped, ${result.messagesIndexed} messages${errorSuffix}${limitSuffix}.`;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
function notifyBestEffort(notify: NotifyFn | undefined, message: string, level: NotifyLevel): void {
|
|
@@ -46,11 +49,11 @@ function notifyBestEffort(notify: NotifyFn | undefined, message: string, level:
|
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
/**
|
|
49
|
-
* Schedule a best-effort,
|
|
52
|
+
* Schedule a best-effort, bounded incremental backfill of unindexed Pi sessions.
|
|
50
53
|
*
|
|
51
|
-
* The
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
+
* The JSONL parsing work is deferred with setTimeout(0) so session_start can
|
|
55
|
+
* resolve first. The scheduled pass only parses files without matching stored
|
|
56
|
+
* metadata and caps the number of files parsed per startup.
|
|
54
57
|
*
|
|
55
58
|
* @returns true when a backfill task was scheduled; false when it was skipped.
|
|
56
59
|
*/
|
|
@@ -62,7 +65,8 @@ export function scheduleSessionBackfill(
|
|
|
62
65
|
const state = options.state ?? sessionBackfillState;
|
|
63
66
|
const setTimeoutFn = options.setTimeoutFn ?? setTimeout;
|
|
64
67
|
const needsBackfillFn = options.needsBackfillFn ?? needsBackfill;
|
|
65
|
-
const
|
|
68
|
+
const indexSessionsFn = options.indexSessionsFn ?? indexChangedSessions;
|
|
69
|
+
const maxFilesToIndex = options.maxFilesToIndex ?? SESSION_BACKFILL_MAX_FILES;
|
|
66
70
|
const touchBackfillTimestampFn = options.touchBackfillTimestampFn ?? touchBackfillTimestamp;
|
|
67
71
|
|
|
68
72
|
if (state.inProgress) {
|
|
@@ -86,9 +90,9 @@ export function scheduleSessionBackfill(
|
|
|
86
90
|
state.promise = new Promise<void>((resolve) => {
|
|
87
91
|
setTimeoutFn(() => {
|
|
88
92
|
try {
|
|
89
|
-
const result =
|
|
90
|
-
touchBackfillTimestampFn(dbManager);
|
|
91
|
-
notifyBestEffort(options.notify, formatBackfillResult(result), result.errors.length > 0 ? 'warning' : 'info');
|
|
93
|
+
const result = indexSessionsFn(dbManager, sessionsDir, { maxFilesToIndex });
|
|
94
|
+
if (!result.reachedLimit) touchBackfillTimestampFn(dbManager);
|
|
95
|
+
notifyBestEffort(options.notify, formatBackfillResult(result), result.errors.length > 0 || result.reachedLimit ? 'warning' : 'info');
|
|
92
96
|
} catch (err) {
|
|
93
97
|
notifyBestEffort(
|
|
94
98
|
options.notify,
|
|
@@ -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
|
@@ -27,7 +27,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
27
27
|
import { MemoryStore } from "./store/memory-store.js";
|
|
28
28
|
import { SkillStore } from "./store/skill-store.js";
|
|
29
29
|
import { DatabaseManager } from "./store/db.js";
|
|
30
|
-
import { indexSession } from "./store/session-indexer.js";
|
|
30
|
+
import { indexSession, upsertSessionFileMetadata } from "./store/session-indexer.js";
|
|
31
31
|
import { scheduleSessionBackfill, waitForSessionBackfill, SESSION_BACKFILL_SHUTDOWN_TIMEOUT_MS } from "./handlers/session-backfill.js";
|
|
32
32
|
import { scheduleLiveSessionIndex, waitForLiveSessionIndex, SESSION_LIVE_INDEX_SHUTDOWN_TIMEOUT_MS } from "./handlers/session-live-index.js";
|
|
33
33
|
import { parseSessionFile } from "./store/session-parser.js";
|
|
@@ -246,7 +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
|
-
|
|
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
|
+
});
|
|
250
257
|
}
|
|
251
258
|
}
|
|
252
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,24 +152,95 @@ 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;
|
|
106
215
|
|
|
107
|
-
|
|
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
|
+
}
|
|
232
|
+
|
|
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);
|
|
@@ -129,8 +260,334 @@ export class DatabaseManager {
|
|
|
129
260
|
this.ensureMemoriesColumns(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
|
+
}
|
|
132
376
|
|
|
133
|
-
return
|
|
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
|
+
}
|
|
517
|
+
|
|
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}`;
|
|
537
|
+
}
|
|
538
|
+
|
|
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 */ }
|
|
134
591
|
}
|
|
135
592
|
|
|
136
593
|
private isLegacyMemoriesCategoryError(err: unknown): boolean {
|
|
@@ -254,7 +711,7 @@ export class DatabaseManager {
|
|
|
254
711
|
close(): void {
|
|
255
712
|
if (this.db) {
|
|
256
713
|
try { this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* best effort */ }
|
|
257
|
-
this.db.close();
|
|
714
|
+
try { this.db.close(); } catch { /* best effort — close may throw on a corrupt handle */ }
|
|
258
715
|
this.db = null;
|
|
259
716
|
}
|
|
260
717
|
}
|
package/src/store/schema.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Tables:
|
|
5
5
|
* - sessions — Pi session metadata
|
|
6
|
+
* - session_files — indexed JSONL metadata for incremental backfill
|
|
6
7
|
* - messages — all conversation messages
|
|
7
8
|
* - message_fts — FTS5 index for full-text search across messages
|
|
8
9
|
* - memories — extended memory entries (unlimited, searchable)
|
|
@@ -26,6 +27,15 @@ export const SCHEMA_SQL = `
|
|
|
26
27
|
message_count INTEGER DEFAULT 0
|
|
27
28
|
);
|
|
28
29
|
|
|
30
|
+
-- Indexed session file metadata for cheap incremental backfill
|
|
31
|
+
CREATE TABLE IF NOT EXISTS session_files (
|
|
32
|
+
path TEXT PRIMARY KEY,
|
|
33
|
+
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
34
|
+
size INTEGER NOT NULL,
|
|
35
|
+
mtime_ms INTEGER NOT NULL,
|
|
36
|
+
indexed_at TEXT NOT NULL
|
|
37
|
+
);
|
|
38
|
+
|
|
29
39
|
-- All messages from all sessions
|
|
30
40
|
CREATE TABLE IF NOT EXISTS messages (
|
|
31
41
|
id TEXT PRIMARY KEY,
|
|
@@ -102,4 +112,5 @@ export const SCHEMA_SQL = `
|
|
|
102
112
|
CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category);
|
|
103
113
|
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project);
|
|
104
114
|
CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);
|
|
115
|
+
CREATE INDEX IF NOT EXISTS idx_session_files_session_id ON session_files(session_id);
|
|
105
116
|
`;
|
|
@@ -23,6 +23,18 @@ export interface BulkIndexResult {
|
|
|
23
23
|
sessionsSkipped: number;
|
|
24
24
|
messagesIndexed: number;
|
|
25
25
|
errors: string[];
|
|
26
|
+
reachedLimit?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface SessionFileMetadata {
|
|
30
|
+
path: string;
|
|
31
|
+
size: number;
|
|
32
|
+
mtimeMs: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface IncrementalIndexOptions {
|
|
36
|
+
projectDir?: string;
|
|
37
|
+
maxFilesToIndex?: number;
|
|
26
38
|
}
|
|
27
39
|
|
|
28
40
|
/**
|
|
@@ -31,6 +43,10 @@ export interface BulkIndexResult {
|
|
|
31
43
|
* @returns IndexResult with count of messages indexed
|
|
32
44
|
*/
|
|
33
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 {
|
|
34
50
|
const db = dbManager.getDb();
|
|
35
51
|
|
|
36
52
|
const existingSession = db.prepare('SELECT id FROM sessions WHERE id = ?').get(session.id) as { id: string } | undefined;
|
|
@@ -198,15 +214,85 @@ export function indexCurrentSession(dbManager: DatabaseManager, sessionManager:
|
|
|
198
214
|
}
|
|
199
215
|
|
|
200
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 {
|
|
201
221
|
const sessionFile = sessionManager.getSessionFile?.();
|
|
202
222
|
if (sessionFile && fs.existsSync(sessionFile)) {
|
|
203
223
|
const session = parseSessionFile(sessionFile);
|
|
204
|
-
if (session)
|
|
224
|
+
if (session) {
|
|
225
|
+
const result = indexSession(dbManager, session);
|
|
226
|
+
upsertSessionFileMetadata(dbManager, sessionFile, session.id);
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
205
229
|
}
|
|
206
230
|
|
|
207
231
|
return indexCurrentSession(dbManager, sessionManager);
|
|
208
232
|
}
|
|
209
233
|
|
|
234
|
+
function getSessionFileMetadata(filePath: string): SessionFileMetadata {
|
|
235
|
+
const stat = fs.statSync(filePath);
|
|
236
|
+
return { path: filePath, size: stat.size, mtimeMs: Math.trunc(stat.mtimeMs) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function getStoredSessionFileMetadata(dbManager: DatabaseManager, filePath: string): { size: number; mtime_ms: number } | undefined {
|
|
240
|
+
return dbManager.getDb().prepare('SELECT size, mtime_ms FROM session_files WHERE path = ?').get(filePath) as { size: number; mtime_ms: number } | undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function storedSessionFileMatches(dbManager: DatabaseManager, metadata: SessionFileMetadata): boolean {
|
|
244
|
+
const row = getStoredSessionFileMetadata(dbManager, metadata.path);
|
|
245
|
+
return Boolean(row && row.size === metadata.size && row.mtime_ms === metadata.mtimeMs);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function upsertSessionFileMetadata(
|
|
249
|
+
dbManager: DatabaseManager,
|
|
250
|
+
filePath: string,
|
|
251
|
+
sessionId: string,
|
|
252
|
+
metadata = getSessionFileMetadata(filePath),
|
|
253
|
+
indexedAt = new Date(),
|
|
254
|
+
): void {
|
|
255
|
+
const db = dbManager.getDb();
|
|
256
|
+
db.prepare(`
|
|
257
|
+
INSERT INTO session_files (path, session_id, size, mtime_ms, indexed_at)
|
|
258
|
+
VALUES (?, ?, ?, ?, ?)
|
|
259
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
260
|
+
session_id = excluded.session_id,
|
|
261
|
+
size = excluded.size,
|
|
262
|
+
mtime_ms = excluded.mtime_ms,
|
|
263
|
+
indexed_at = excluded.indexed_at
|
|
264
|
+
`).run(metadata.path, sessionId, metadata.size, metadata.mtimeMs, indexedAt.toISOString());
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function emptyBulkIndexResult(): BulkIndexResult {
|
|
268
|
+
return {
|
|
269
|
+
sessionsProcessed: 0,
|
|
270
|
+
sessionsIndexed: 0,
|
|
271
|
+
sessionsSkipped: 0,
|
|
272
|
+
messagesIndexed: 0,
|
|
273
|
+
errors: [],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function indexSessionFile(dbManager: DatabaseManager, file: string, result: BulkIndexResult): void {
|
|
278
|
+
result.sessionsProcessed++;
|
|
279
|
+
|
|
280
|
+
const session = parseSessionFile(file);
|
|
281
|
+
if (!session) {
|
|
282
|
+
result.errors.push(`Failed to parse: ${file}`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const indexResult = indexSession(dbManager, session);
|
|
287
|
+
upsertSessionFileMetadata(dbManager, file, session.id);
|
|
288
|
+
if (indexResult.skipped) {
|
|
289
|
+
result.sessionsSkipped++;
|
|
290
|
+
} else {
|
|
291
|
+
result.sessionsIndexed++;
|
|
292
|
+
result.messagesIndexed += indexResult.messagesIndexed;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
210
296
|
/**
|
|
211
297
|
* Index all sessions from disk.
|
|
212
298
|
*
|
|
@@ -221,36 +307,69 @@ export function indexAllSessions(
|
|
|
221
307
|
projectDir?: string
|
|
222
308
|
): BulkIndexResult {
|
|
223
309
|
const files = getSessionFiles(sessionsDir, projectDir);
|
|
224
|
-
const result
|
|
225
|
-
sessionsProcessed: 0,
|
|
226
|
-
sessionsIndexed: 0,
|
|
227
|
-
sessionsSkipped: 0,
|
|
228
|
-
messagesIndexed: 0,
|
|
229
|
-
errors: [],
|
|
230
|
-
};
|
|
310
|
+
const result = emptyBulkIndexResult();
|
|
231
311
|
|
|
232
312
|
for (const file of files) {
|
|
233
|
-
result.sessionsProcessed++;
|
|
234
|
-
|
|
235
313
|
try {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
314
|
+
indexSessionFile(dbManager, file, result);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
result.errors.push(`Error indexing ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
241
319
|
|
|
242
|
-
|
|
243
|
-
|
|
320
|
+
return result;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Incrementally index session JSONL files without matching stored metadata.
|
|
325
|
+
*
|
|
326
|
+
* This is intentionally cheaper than indexAllSessions() for startup backfill:
|
|
327
|
+
* files with matching stored size/mtime metadata are skipped, and all other
|
|
328
|
+
* files are parsed under the startup cap.
|
|
329
|
+
*/
|
|
330
|
+
export function indexChangedSessions(
|
|
331
|
+
dbManager: DatabaseManager,
|
|
332
|
+
sessionsDir: string,
|
|
333
|
+
options: IncrementalIndexOptions = {},
|
|
334
|
+
): BulkIndexResult {
|
|
335
|
+
const files = getSessionFiles(sessionsDir, options.projectDir);
|
|
336
|
+
const maxFilesToIndex = options.maxFilesToIndex ?? 50;
|
|
337
|
+
const result = emptyBulkIndexResult();
|
|
338
|
+
|
|
339
|
+
// Gather the changed set first, then sort newest-first before applying the
|
|
340
|
+
// cap. Crash recovery is the primary value of startup backfill (the live
|
|
341
|
+
// message_end path missed the session's final state), and crashed sessions
|
|
342
|
+
// are the most recently modified files. Sorting newest-first ensures they
|
|
343
|
+
// are indexed on the very next startup instead of waiting behind old
|
|
344
|
+
// historical files that fill the per-startup cap in filesystem order.
|
|
345
|
+
const changed: SessionFileMetadata[] = [];
|
|
346
|
+
for (const file of files) {
|
|
347
|
+
try {
|
|
348
|
+
const metadata = getSessionFileMetadata(file);
|
|
349
|
+
if (storedSessionFileMatches(dbManager, metadata)) {
|
|
244
350
|
result.sessionsSkipped++;
|
|
245
|
-
|
|
246
|
-
result.sessionsIndexed++;
|
|
247
|
-
result.messagesIndexed += indexResult.messagesIndexed;
|
|
351
|
+
continue;
|
|
248
352
|
}
|
|
353
|
+
changed.push(metadata);
|
|
249
354
|
} catch (err) {
|
|
250
355
|
result.errors.push(`Error indexing ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
251
356
|
}
|
|
252
357
|
}
|
|
253
358
|
|
|
359
|
+
changed.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
360
|
+
|
|
361
|
+
for (const metadata of changed) {
|
|
362
|
+
if (result.sessionsProcessed >= maxFilesToIndex) {
|
|
363
|
+
result.reachedLimit = true;
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
indexSessionFile(dbManager, metadata.path, result);
|
|
368
|
+
} catch (err) {
|
|
369
|
+
result.errors.push(`Error indexing ${metadata.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
254
373
|
return result;
|
|
255
374
|
}
|
|
256
375
|
|
|
@@ -277,20 +396,28 @@ function isRecentBackfillTimestamp(value: string | null, nowMs: number): boolean
|
|
|
277
396
|
/**
|
|
278
397
|
* Determine whether a background session backfill should run.
|
|
279
398
|
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
* the last 24 hours. The count check catches crashed/abnormal sessions; the
|
|
283
|
-
* timestamp check periodically repairs parse errors or manual DB edits.
|
|
399
|
+
* The check stays cheap: it compares file counts and stored file size/mtime
|
|
400
|
+
* metadata. Full JSONL parsing is left to the scheduled incremental backfill.
|
|
284
401
|
*/
|
|
285
402
|
export function needsBackfill(dbManager: DatabaseManager, sessionsDir: string, now = new Date()): boolean {
|
|
286
403
|
const db = dbManager.getDb();
|
|
287
|
-
const
|
|
404
|
+
const files = getSessionFiles(sessionsDir);
|
|
288
405
|
const indexed = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number };
|
|
289
406
|
|
|
290
|
-
if (
|
|
407
|
+
if (files.length > indexed.count) {
|
|
291
408
|
return true;
|
|
292
409
|
}
|
|
293
410
|
|
|
411
|
+
for (const file of files) {
|
|
412
|
+
try {
|
|
413
|
+
const metadata = getSessionFileMetadata(file);
|
|
414
|
+
if (storedSessionFileMatches(dbManager, metadata)) continue;
|
|
415
|
+
return true;
|
|
416
|
+
} catch {
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
294
421
|
return !isRecentBackfillTimestamp(getLastBackfillTimestamp(dbManager), now.getTime());
|
|
295
422
|
}
|
|
296
423
|
|