pi-hermes-memory 0.7.18 → 0.7.19
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/index.ts +6 -1
- package/src/store/schema.ts +11 -0
- package/src/store/session-indexer.ts +145 -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.19",
|
|
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,
|
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";
|
|
@@ -247,6 +247,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
247
247
|
const sessionData = parseSessionFile(sessionFile);
|
|
248
248
|
if (sessionData) {
|
|
249
249
|
indexSession(dbManager, sessionData);
|
|
250
|
+
// Keep session_files metadata in sync with the final on-disk state.
|
|
251
|
+
// Pi appends the closing session entry on shutdown after the last
|
|
252
|
+
// message_end, so without this upsert the stored size/mtime would be
|
|
253
|
+
// stale and the next startup would re-parse this file unnecessarily.
|
|
254
|
+
upsertSessionFileMetadata(dbManager, sessionFile, sessionData.id);
|
|
250
255
|
}
|
|
251
256
|
}
|
|
252
257
|
} catch {
|
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
|
/**
|
|
@@ -201,12 +213,78 @@ export function indexLiveSession(dbManager: DatabaseManager, sessionManager: Ses
|
|
|
201
213
|
const sessionFile = sessionManager.getSessionFile?.();
|
|
202
214
|
if (sessionFile && fs.existsSync(sessionFile)) {
|
|
203
215
|
const session = parseSessionFile(sessionFile);
|
|
204
|
-
if (session)
|
|
216
|
+
if (session) {
|
|
217
|
+
const result = indexSession(dbManager, session);
|
|
218
|
+
upsertSessionFileMetadata(dbManager, sessionFile, session.id);
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
205
221
|
}
|
|
206
222
|
|
|
207
223
|
return indexCurrentSession(dbManager, sessionManager);
|
|
208
224
|
}
|
|
209
225
|
|
|
226
|
+
function getSessionFileMetadata(filePath: string): SessionFileMetadata {
|
|
227
|
+
const stat = fs.statSync(filePath);
|
|
228
|
+
return { path: filePath, size: stat.size, mtimeMs: Math.trunc(stat.mtimeMs) };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function getStoredSessionFileMetadata(dbManager: DatabaseManager, filePath: string): { size: number; mtime_ms: number } | undefined {
|
|
232
|
+
return dbManager.getDb().prepare('SELECT size, mtime_ms FROM session_files WHERE path = ?').get(filePath) as { size: number; mtime_ms: number } | undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function storedSessionFileMatches(dbManager: DatabaseManager, metadata: SessionFileMetadata): boolean {
|
|
236
|
+
const row = getStoredSessionFileMetadata(dbManager, metadata.path);
|
|
237
|
+
return Boolean(row && row.size === metadata.size && row.mtime_ms === metadata.mtimeMs);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function upsertSessionFileMetadata(
|
|
241
|
+
dbManager: DatabaseManager,
|
|
242
|
+
filePath: string,
|
|
243
|
+
sessionId: string,
|
|
244
|
+
metadata = getSessionFileMetadata(filePath),
|
|
245
|
+
indexedAt = new Date(),
|
|
246
|
+
): void {
|
|
247
|
+
const db = dbManager.getDb();
|
|
248
|
+
db.prepare(`
|
|
249
|
+
INSERT INTO session_files (path, session_id, size, mtime_ms, indexed_at)
|
|
250
|
+
VALUES (?, ?, ?, ?, ?)
|
|
251
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
252
|
+
session_id = excluded.session_id,
|
|
253
|
+
size = excluded.size,
|
|
254
|
+
mtime_ms = excluded.mtime_ms,
|
|
255
|
+
indexed_at = excluded.indexed_at
|
|
256
|
+
`).run(metadata.path, sessionId, metadata.size, metadata.mtimeMs, indexedAt.toISOString());
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function emptyBulkIndexResult(): BulkIndexResult {
|
|
260
|
+
return {
|
|
261
|
+
sessionsProcessed: 0,
|
|
262
|
+
sessionsIndexed: 0,
|
|
263
|
+
sessionsSkipped: 0,
|
|
264
|
+
messagesIndexed: 0,
|
|
265
|
+
errors: [],
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function indexSessionFile(dbManager: DatabaseManager, file: string, result: BulkIndexResult): void {
|
|
270
|
+
result.sessionsProcessed++;
|
|
271
|
+
|
|
272
|
+
const session = parseSessionFile(file);
|
|
273
|
+
if (!session) {
|
|
274
|
+
result.errors.push(`Failed to parse: ${file}`);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const indexResult = indexSession(dbManager, session);
|
|
279
|
+
upsertSessionFileMetadata(dbManager, file, session.id);
|
|
280
|
+
if (indexResult.skipped) {
|
|
281
|
+
result.sessionsSkipped++;
|
|
282
|
+
} else {
|
|
283
|
+
result.sessionsIndexed++;
|
|
284
|
+
result.messagesIndexed += indexResult.messagesIndexed;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
210
288
|
/**
|
|
211
289
|
* Index all sessions from disk.
|
|
212
290
|
*
|
|
@@ -221,36 +299,69 @@ export function indexAllSessions(
|
|
|
221
299
|
projectDir?: string
|
|
222
300
|
): BulkIndexResult {
|
|
223
301
|
const files = getSessionFiles(sessionsDir, projectDir);
|
|
224
|
-
const result
|
|
225
|
-
sessionsProcessed: 0,
|
|
226
|
-
sessionsIndexed: 0,
|
|
227
|
-
sessionsSkipped: 0,
|
|
228
|
-
messagesIndexed: 0,
|
|
229
|
-
errors: [],
|
|
230
|
-
};
|
|
302
|
+
const result = emptyBulkIndexResult();
|
|
231
303
|
|
|
232
304
|
for (const file of files) {
|
|
233
|
-
result.sessionsProcessed++;
|
|
234
|
-
|
|
235
305
|
try {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
306
|
+
indexSessionFile(dbManager, file, result);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
result.errors.push(`Error indexing ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
241
314
|
|
|
242
|
-
|
|
243
|
-
|
|
315
|
+
/**
|
|
316
|
+
* Incrementally index session JSONL files without matching stored metadata.
|
|
317
|
+
*
|
|
318
|
+
* This is intentionally cheaper than indexAllSessions() for startup backfill:
|
|
319
|
+
* files with matching stored size/mtime metadata are skipped, and all other
|
|
320
|
+
* files are parsed under the startup cap.
|
|
321
|
+
*/
|
|
322
|
+
export function indexChangedSessions(
|
|
323
|
+
dbManager: DatabaseManager,
|
|
324
|
+
sessionsDir: string,
|
|
325
|
+
options: IncrementalIndexOptions = {},
|
|
326
|
+
): BulkIndexResult {
|
|
327
|
+
const files = getSessionFiles(sessionsDir, options.projectDir);
|
|
328
|
+
const maxFilesToIndex = options.maxFilesToIndex ?? 50;
|
|
329
|
+
const result = emptyBulkIndexResult();
|
|
330
|
+
|
|
331
|
+
// Gather the changed set first, then sort newest-first before applying the
|
|
332
|
+
// cap. Crash recovery is the primary value of startup backfill (the live
|
|
333
|
+
// message_end path missed the session's final state), and crashed sessions
|
|
334
|
+
// are the most recently modified files. Sorting newest-first ensures they
|
|
335
|
+
// are indexed on the very next startup instead of waiting behind old
|
|
336
|
+
// historical files that fill the per-startup cap in filesystem order.
|
|
337
|
+
const changed: SessionFileMetadata[] = [];
|
|
338
|
+
for (const file of files) {
|
|
339
|
+
try {
|
|
340
|
+
const metadata = getSessionFileMetadata(file);
|
|
341
|
+
if (storedSessionFileMatches(dbManager, metadata)) {
|
|
244
342
|
result.sessionsSkipped++;
|
|
245
|
-
|
|
246
|
-
result.sessionsIndexed++;
|
|
247
|
-
result.messagesIndexed += indexResult.messagesIndexed;
|
|
343
|
+
continue;
|
|
248
344
|
}
|
|
345
|
+
changed.push(metadata);
|
|
249
346
|
} catch (err) {
|
|
250
347
|
result.errors.push(`Error indexing ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
251
348
|
}
|
|
252
349
|
}
|
|
253
350
|
|
|
351
|
+
changed.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
352
|
+
|
|
353
|
+
for (const metadata of changed) {
|
|
354
|
+
if (result.sessionsProcessed >= maxFilesToIndex) {
|
|
355
|
+
result.reachedLimit = true;
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
indexSessionFile(dbManager, metadata.path, result);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
result.errors.push(`Error indexing ${metadata.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
254
365
|
return result;
|
|
255
366
|
}
|
|
256
367
|
|
|
@@ -277,20 +388,28 @@ function isRecentBackfillTimestamp(value: string | null, nowMs: number): boolean
|
|
|
277
388
|
/**
|
|
278
389
|
* Determine whether a background session backfill should run.
|
|
279
390
|
*
|
|
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.
|
|
391
|
+
* The check stays cheap: it compares file counts and stored file size/mtime
|
|
392
|
+
* metadata. Full JSONL parsing is left to the scheduled incremental backfill.
|
|
284
393
|
*/
|
|
285
394
|
export function needsBackfill(dbManager: DatabaseManager, sessionsDir: string, now = new Date()): boolean {
|
|
286
395
|
const db = dbManager.getDb();
|
|
287
|
-
const
|
|
396
|
+
const files = getSessionFiles(sessionsDir);
|
|
288
397
|
const indexed = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number };
|
|
289
398
|
|
|
290
|
-
if (
|
|
399
|
+
if (files.length > indexed.count) {
|
|
291
400
|
return true;
|
|
292
401
|
}
|
|
293
402
|
|
|
403
|
+
for (const file of files) {
|
|
404
|
+
try {
|
|
405
|
+
const metadata = getSessionFileMetadata(file);
|
|
406
|
+
if (storedSessionFileMatches(dbManager, metadata)) continue;
|
|
407
|
+
return true;
|
|
408
|
+
} catch {
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
294
413
|
return !isRecentBackfillTimestamp(getLastBackfillTimestamp(dbManager), now.getTime());
|
|
295
414
|
}
|
|
296
415
|
|