pi-memory-stone 0.1.0

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.
@@ -0,0 +1,620 @@
1
+ /**
2
+ * Database module: connection management, migrations, CRUD helpers.
3
+ * Uses node:sqlite (DatabaseSync) for synchronous SQLite operations.
4
+ */
5
+
6
+ import { DatabaseSync } from "node:sqlite";
7
+ import { existsSync, mkdirSync } from "node:fs";
8
+ import { dirname } from "node:path";
9
+ import { createHash, randomUUID } from "node:crypto";
10
+ import { redactSecrets } from "../privacy/index.js";
11
+ import {
12
+ MIGRATIONS,
13
+ SCHEMA_VERSION,
14
+ type RecordKind,
15
+ type RecordScope,
16
+ type RecordStatus,
17
+ type SourceStatus,
18
+ type JobStatus,
19
+ type FileAction,
20
+ } from "./schema.js";
21
+
22
+ // ─── Paths ──────────────────────────────────────────────────────────
23
+
24
+ export function getDbPath(): string {
25
+ return process.env.PI_MEMORY_STONE_DB_PATH
26
+ ?? `${process.env.HOME || process.env.USERPROFILE || "/tmp"}/.pi/agent/memory/memory.db`;
27
+ }
28
+
29
+ export function getDbDir(): string {
30
+ return dirname(getDbPath());
31
+ }
32
+
33
+ export const DB_PATH = getDbPath();
34
+ export const DB_DIR = getDbDir();
35
+
36
+ // ─── Singleton ──────────────────────────────────────────────────────
37
+
38
+ let _db: DatabaseSync | null = null;
39
+
40
+ export function getDb(): DatabaseSync {
41
+ if (!_db) {
42
+ const dbDir = getDbDir();
43
+ if (!existsSync(dbDir)) {
44
+ mkdirSync(dbDir, { recursive: true });
45
+ }
46
+ _db = new DatabaseSync(getDbPath());
47
+ _db.exec("PRAGMA journal_mode = WAL");
48
+ _db.exec("PRAGMA busy_timeout = 5000");
49
+ _db.exec("PRAGMA foreign_keys = ON");
50
+ runMigrations(_db);
51
+ }
52
+ return _db;
53
+ }
54
+
55
+ export function closeDb(): void {
56
+ if (_db) {
57
+ try {
58
+ _db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
59
+ } catch {}
60
+ try {
61
+ _db.close();
62
+ } catch {}
63
+ _db = null;
64
+ }
65
+ }
66
+
67
+ // ─── Migrations ─────────────────────────────────────────────────────
68
+
69
+ function runMigrations(db: DatabaseSync): void {
70
+ // Ensure migration table exists
71
+ db.exec(
72
+ `CREATE TABLE IF NOT EXISTS schema_migrations (
73
+ version INTEGER PRIMARY KEY,
74
+ applied_at INTEGER NOT NULL,
75
+ name TEXT NOT NULL
76
+ )`
77
+ );
78
+
79
+ const applied = db
80
+ .prepare("SELECT version FROM schema_migrations ORDER BY version")
81
+ .all() as { version: number }[];
82
+ const appliedVersions = new Set(applied.map((r) => r.version));
83
+
84
+ for (const migration of MIGRATIONS) {
85
+ if (!appliedVersions.has(migration.version)) {
86
+ db.exec("BEGIN");
87
+ try {
88
+ for (const stmt of migration.sql
89
+ .split(";")
90
+ .map((s) => s.trim())
91
+ .filter(Boolean)) {
92
+ db.exec(stmt + ";");
93
+ }
94
+ db.prepare(
95
+ "INSERT INTO schema_migrations (version, applied_at, name) VALUES (?, ?, ?)"
96
+ ).run(migration.version, Date.now(), migration.name);
97
+ db.exec("COMMIT");
98
+ } catch (err) {
99
+ db.exec("ROLLBACK");
100
+ throw err;
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ // ─── ID generation ──────────────────────────────────────────────────
107
+
108
+ export function contentHash(text: string, kind: string): string {
109
+ return createHash("sha256").update(kind + ":" + text).digest("hex").slice(0, 16);
110
+ }
111
+
112
+ function recordIdentityHash(
113
+ text: string,
114
+ kind: RecordKind,
115
+ scope: RecordScope,
116
+ projectId: string | null | undefined,
117
+ ): string {
118
+ const visibilityKey = scope === "global" ? "global" : (projectId ?? "unknown-project");
119
+ return createHash("sha256")
120
+ .update([kind, scope, visibilityKey, text].join("\0"))
121
+ .digest("hex")
122
+ .slice(0, 16);
123
+ }
124
+
125
+ export function newId(): string {
126
+ return randomUUID().replace(/-/g, "").slice(0, 16);
127
+ }
128
+
129
+ // ─── Sessions ───────────────────────────────────────────────────────
130
+
131
+ export interface SessionRow {
132
+ id: string;
133
+ session_file: string;
134
+ cwd: string | null;
135
+ repo_root: string | null;
136
+ project_id: string | null;
137
+ session_name: string | null;
138
+ created_at: number;
139
+ updated_at: number;
140
+ source_status: SourceStatus;
141
+ file_mtime: number | null;
142
+ file_size: number | null;
143
+ schema_version: number;
144
+ }
145
+
146
+ export function upsertSession(session: {
147
+ id: string;
148
+ session_file: string;
149
+ cwd?: string | null;
150
+ repo_root?: string | null;
151
+ project_id?: string | null;
152
+ session_name?: string | null;
153
+ file_mtime?: number | null;
154
+ file_size?: number | null;
155
+ }): void {
156
+ const db = getDb();
157
+ const now = Date.now();
158
+ const existing = db
159
+ .prepare("SELECT id FROM sessions WHERE session_file = ?")
160
+ .get(session.session_file) as { id: string } | undefined;
161
+
162
+ if (existing) {
163
+ db.prepare(`
164
+ UPDATE sessions SET
165
+ cwd = ?, repo_root = ?, project_id = ?, session_name = ?,
166
+ updated_at = ?, source_status = 'active',
167
+ file_mtime = ?, file_size = ?, schema_version = ?
168
+ WHERE session_file = ?
169
+ `).run(
170
+ session.cwd ?? null,
171
+ session.repo_root ?? null,
172
+ session.project_id ?? null,
173
+ session.session_name ?? null,
174
+ now,
175
+ session.file_mtime ?? null,
176
+ session.file_size ?? null,
177
+ SCHEMA_VERSION,
178
+ session.session_file,
179
+ );
180
+ } else {
181
+ db.prepare(`
182
+ INSERT INTO sessions (id, session_file, cwd, repo_root, project_id, session_name, created_at, updated_at, source_status, file_mtime, file_size, schema_version)
183
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
184
+ `).run(
185
+ session.id,
186
+ session.session_file,
187
+ session.cwd ?? null,
188
+ session.repo_root ?? null,
189
+ session.project_id ?? null,
190
+ session.session_name ?? null,
191
+ now,
192
+ now,
193
+ session.file_mtime ?? null,
194
+ session.file_size ?? null,
195
+ SCHEMA_VERSION,
196
+ );
197
+ }
198
+ }
199
+
200
+ export function getSession(sessionFile: string): SessionRow | undefined {
201
+ const db = getDb();
202
+ return (
203
+ (db.prepare("SELECT * FROM sessions WHERE session_file = ?").get(sessionFile) as SessionRow | undefined) ??
204
+ undefined
205
+ );
206
+ }
207
+
208
+ // ─── Records ────────────────────────────────────────────────────────
209
+
210
+ export interface RecordRow {
211
+ id: string;
212
+ kind: RecordKind;
213
+ scope: RecordScope;
214
+ project_id: string | null;
215
+ session_id: string | null;
216
+ session_file: string | null;
217
+ branch_leaf_id: string | null;
218
+ entry_id_start: string | null;
219
+ entry_id_end: string | null;
220
+ text: string;
221
+ tags: string | null;
222
+ status: RecordStatus;
223
+ confidence: number;
224
+ importance: number;
225
+ created_at: number;
226
+ updated_at: number;
227
+ superseded_by: string | null;
228
+ derived_from_memory_refs: string | null;
229
+ }
230
+
231
+ export function upsertRecord(record: {
232
+ kind: RecordKind;
233
+ scope?: RecordScope;
234
+ project_id?: string | null;
235
+ session_id?: string | null;
236
+ session_file?: string | null;
237
+ branch_leaf_id?: string | null;
238
+ entry_id_start?: string | null;
239
+ entry_id_end?: string | null;
240
+ text: string;
241
+ tags?: string | null;
242
+ status?: RecordStatus;
243
+ confidence?: number;
244
+ importance?: number;
245
+ superseded_by?: string | null;
246
+ derived_from_memory_refs?: string | null;
247
+ }): string {
248
+ const db = getDb();
249
+ const now = Date.now();
250
+ const scope = record.scope ?? "project";
251
+ const projectId = scope === "global" ? null : (record.project_id ?? null);
252
+ const redactedText = redactSecrets(record.text);
253
+ const redactedTags = record.tags ? redactSecrets(record.tags) : null;
254
+ const id = recordIdentityHash(redactedText, record.kind, scope, projectId);
255
+ const existing = db.prepare("SELECT id, status FROM records WHERE id = ?").get(id) as
256
+ | { id: string; status: RecordStatus }
257
+ | undefined;
258
+
259
+ if (existing) {
260
+ db.prepare(`
261
+ UPDATE records SET
262
+ scope = ?, project_id = ?, session_id = ?, session_file = ?,
263
+ branch_leaf_id = ?, entry_id_start = ?, entry_id_end = ?,
264
+ text = ?, tags = ?, confidence = ?, importance = ?,
265
+ updated_at = ?, status = ?, superseded_by = ?, derived_from_memory_refs = ?
266
+ WHERE id = ?
267
+ `).run(
268
+ scope,
269
+ projectId,
270
+ record.session_id ?? null,
271
+ record.session_file ?? null,
272
+ record.branch_leaf_id ?? null,
273
+ record.entry_id_start ?? null,
274
+ record.entry_id_end ?? null,
275
+ redactedText,
276
+ redactedTags,
277
+ record.confidence ?? 1.0,
278
+ record.importance ?? 0.5,
279
+ now,
280
+ record.status ?? existing.status,
281
+ record.superseded_by ?? null,
282
+ record.derived_from_memory_refs ?? null,
283
+ id,
284
+ );
285
+ } else {
286
+ db.prepare(`
287
+ INSERT INTO records (id, kind, scope, project_id, session_id, session_file,
288
+ branch_leaf_id, entry_id_start, entry_id_end, text, tags, status,
289
+ confidence, importance, created_at, updated_at, superseded_by, derived_from_memory_refs)
290
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
291
+ `).run(
292
+ id,
293
+ record.kind,
294
+ scope,
295
+ projectId,
296
+ record.session_id ?? null,
297
+ record.session_file ?? null,
298
+ record.branch_leaf_id ?? null,
299
+ record.entry_id_start ?? null,
300
+ record.entry_id_end ?? null,
301
+ redactedText,
302
+ redactedTags,
303
+ record.status ?? "active",
304
+ record.confidence ?? 1.0,
305
+ record.importance ?? 0.5,
306
+ now,
307
+ now,
308
+ record.superseded_by ?? null,
309
+ record.derived_from_memory_refs ?? null,
310
+ );
311
+ }
312
+
313
+ // Rebuild FTS: delete then re-insert
314
+ db.prepare("DELETE FROM record_fts WHERE rowid = (SELECT rowid FROM records WHERE id = ?)").run(id);
315
+ const row = db.prepare("SELECT rowid FROM records WHERE id = ?").get(id) as { rowid: number };
316
+ if (row) {
317
+ db.prepare("INSERT INTO record_fts(rowid, text, tags) VALUES (?, ?, ?)").run(
318
+ row.rowid,
319
+ redactedText,
320
+ redactedTags ?? "",
321
+ );
322
+ }
323
+
324
+ return id;
325
+ }
326
+
327
+ export function getRecord(id: string): RecordRow | undefined {
328
+ const db = getDb();
329
+ return (
330
+ (db.prepare("SELECT * FROM records WHERE id = ?").get(id) as RecordRow | undefined) ?? undefined
331
+ );
332
+ }
333
+
334
+ export function searchRecordsFts(
335
+ query: string,
336
+ limit = 20,
337
+ kindFilter?: RecordKind[],
338
+ scopeFilter?: RecordScope[],
339
+ projectId?: string,
340
+ excludeStatuses?: RecordStatus[],
341
+ ): (RecordRow & { rank: number })[] {
342
+ const db = getDb();
343
+
344
+ // Sanitize FTS query: escape special chars, split into terms
345
+ const terms = query
346
+ .replace(/[^\w\s-]/g, " ")
347
+ .split(/\s+/)
348
+ .filter(Boolean)
349
+ .map((t) => `"${t}"`)
350
+ .join(" OR ");
351
+
352
+ if (!terms) return [];
353
+
354
+ const results = db
355
+ .prepare(
356
+ `SELECT r.*, fts.rank as rank
357
+ FROM record_fts fts
358
+ JOIN records r ON r.rowid = fts.rowid
359
+ WHERE record_fts MATCH ?
360
+ ORDER BY rank
361
+ LIMIT ?`
362
+ )
363
+ .all(terms, limit) as unknown as (RecordRow & { rank: number })[];
364
+
365
+ // Apply post-filters
366
+ return results.filter((r) => {
367
+ if (kindFilter && !kindFilter.includes(r.kind)) return false;
368
+ if (scopeFilter && !scopeFilter.includes(r.scope)) return false;
369
+ if (projectId !== undefined && r.project_id !== projectId) return false;
370
+ if (excludeStatuses && excludeStatuses.includes(r.status)) return false;
371
+ return true;
372
+ });
373
+ }
374
+
375
+ // ─── File Activity ──────────────────────────────────────────────────
376
+
377
+ export interface FileActivityRow {
378
+ id: string;
379
+ record_id: string | null;
380
+ project_id: string | null;
381
+ path: string;
382
+ action: FileAction;
383
+ entry_id: string | null;
384
+ }
385
+
386
+ export function insertFileActivity(activity: {
387
+ record_id?: string | null;
388
+ project_id?: string | null;
389
+ path: string;
390
+ action: FileAction;
391
+ entry_id?: string | null;
392
+ }): void {
393
+ const db = getDb();
394
+ const id = newId();
395
+ db.prepare(`
396
+ INSERT INTO file_activity (id, record_id, project_id, path, action, entry_id)
397
+ VALUES (?, ?, ?, ?, ?, ?)
398
+ `).run(id, activity.record_id ?? null, activity.project_id ?? null, activity.path, activity.action, activity.entry_id ?? null);
399
+ }
400
+
401
+ export function getFileActivityByRecord(recordId: string): FileActivityRow[] {
402
+ const db = getDb();
403
+ return db
404
+ .prepare("SELECT * FROM file_activity WHERE record_id = ?")
405
+ .all(recordId) as unknown as FileActivityRow[];
406
+ }
407
+
408
+ // ─── Index State ────────────────────────────────────────────────────
409
+
410
+ export interface IndexStateRow {
411
+ session_file: string;
412
+ session_id: string | null;
413
+ last_indexed_entry_id: string | null;
414
+ last_indexed_entry_timestamp: string | null;
415
+ file_mtime: number | null;
416
+ file_size: number | null;
417
+ branch_leaf_id: string | null;
418
+ schema_version: number;
419
+ }
420
+
421
+ export function getIndexState(sessionFile: string): IndexStateRow | undefined {
422
+ const db = getDb();
423
+ return (
424
+ (db.prepare("SELECT * FROM index_state WHERE session_file = ?").get(sessionFile) as
425
+ | IndexStateRow
426
+ | undefined) ?? undefined
427
+ );
428
+ }
429
+
430
+ export function upsertIndexState(state: {
431
+ session_file: string;
432
+ session_id?: string | null;
433
+ last_indexed_entry_id?: string | null;
434
+ last_indexed_entry_timestamp?: string | null;
435
+ file_mtime?: number | null;
436
+ file_size?: number | null;
437
+ branch_leaf_id?: string | null;
438
+ }): void {
439
+ const db = getDb();
440
+ const existing = db
441
+ .prepare("SELECT session_file FROM index_state WHERE session_file = ?")
442
+ .get(state.session_file);
443
+
444
+ if (existing) {
445
+ db.prepare(`
446
+ UPDATE index_state SET
447
+ session_id = ?, last_indexed_entry_id = ?, last_indexed_entry_timestamp = ?,
448
+ file_mtime = ?, file_size = ?, branch_leaf_id = ?, schema_version = ?
449
+ WHERE session_file = ?
450
+ `).run(
451
+ state.session_id ?? null,
452
+ state.last_indexed_entry_id ?? null,
453
+ state.last_indexed_entry_timestamp ?? null,
454
+ state.file_mtime ?? null,
455
+ state.file_size ?? null,
456
+ state.branch_leaf_id ?? null,
457
+ SCHEMA_VERSION,
458
+ state.session_file,
459
+ );
460
+ } else {
461
+ db.prepare(`
462
+ INSERT INTO index_state (session_file, session_id, last_indexed_entry_id, last_indexed_entry_timestamp, file_mtime, file_size, branch_leaf_id, schema_version)
463
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
464
+ `).run(
465
+ state.session_file,
466
+ state.session_id ?? null,
467
+ state.last_indexed_entry_id ?? null,
468
+ state.last_indexed_entry_timestamp ?? null,
469
+ state.file_mtime ?? null,
470
+ state.file_size ?? null,
471
+ state.branch_leaf_id ?? null,
472
+ SCHEMA_VERSION,
473
+ );
474
+ }
475
+ }
476
+
477
+ // ─── Injections ─────────────────────────────────────────────────────
478
+
479
+ export interface InjectionRow {
480
+ id: string;
481
+ session_id: string | null;
482
+ turn_entry_id: string | null;
483
+ prompt_hash: string | null;
484
+ injected_refs: string | null;
485
+ packet: string | null;
486
+ reasons: string | null;
487
+ created_at: number;
488
+ }
489
+
490
+ export function insertInjection(injection: {
491
+ session_id?: string | null;
492
+ turn_entry_id?: string | null;
493
+ prompt_hash?: string | null;
494
+ injected_refs?: string | null;
495
+ packet?: string | null;
496
+ reasons?: string | null;
497
+ }): string {
498
+ const db = getDb();
499
+ const id = newId();
500
+ const now = Date.now();
501
+ db.prepare(`
502
+ INSERT INTO injections (id, session_id, turn_entry_id, prompt_hash, injected_refs, packet, reasons, created_at)
503
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
504
+ `).run(
505
+ id,
506
+ injection.session_id ?? null,
507
+ injection.turn_entry_id ?? null,
508
+ injection.prompt_hash ?? null,
509
+ injection.injected_refs ?? null,
510
+ injection.packet ?? null,
511
+ injection.reasons ?? null,
512
+ now,
513
+ );
514
+ return id;
515
+ }
516
+
517
+ export function getLastInjection(sessionId: string): InjectionRow | undefined {
518
+ const db = getDb();
519
+ return (
520
+ (db
521
+ .prepare("SELECT * FROM injections WHERE session_id = ? ORDER BY created_at DESC LIMIT 1")
522
+ .get(sessionId) as InjectionRow | undefined) ?? undefined
523
+ );
524
+ }
525
+
526
+ export function getInjectionsBySession(sessionId: string, limit = 10): InjectionRow[] {
527
+ const db = getDb();
528
+ return db
529
+ .prepare("SELECT * FROM injections WHERE session_id = ? ORDER BY created_at DESC LIMIT ?")
530
+ .all(sessionId, limit) as unknown as InjectionRow[];
531
+ }
532
+
533
+ // ─── Jobs ───────────────────────────────────────────────────────────
534
+
535
+ export interface JobRow {
536
+ id: string;
537
+ type: string;
538
+ payload: string | null;
539
+ status: JobStatus;
540
+ attempts: number;
541
+ last_error: string | null;
542
+ created_at: number;
543
+ updated_at: number;
544
+ }
545
+
546
+ export function createJob(type: string, payload?: unknown): string {
547
+ const db = getDb();
548
+ const id = newId();
549
+ const now = Date.now();
550
+ db.prepare(`
551
+ INSERT INTO jobs (id, type, payload, status, attempts, created_at, updated_at)
552
+ VALUES (?, ?, ?, 'pending', 0, ?, ?)
553
+ `).run(id, type, payload ? JSON.stringify(payload) : null, now, now);
554
+ return id;
555
+ }
556
+
557
+ export function getPendingJobs(type?: string, limit = 10): JobRow[] {
558
+ const db = getDb();
559
+ if (type) {
560
+ return db
561
+ .prepare("SELECT * FROM jobs WHERE status = 'pending' AND type = ? ORDER BY created_at LIMIT ?")
562
+ .all(type, limit) as unknown as JobRow[];
563
+ }
564
+ return db
565
+ .prepare("SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at LIMIT ?")
566
+ .all(limit) as unknown as JobRow[];
567
+ }
568
+
569
+ export function updateJobStatus(id: string, status: JobStatus, error?: string): void {
570
+ const db = getDb();
571
+ const now = Date.now();
572
+ if (error) {
573
+ db.prepare(`
574
+ UPDATE jobs SET status = ?, last_error = ?, attempts = attempts + 1, updated_at = ?
575
+ WHERE id = ?
576
+ `).run(status, error, now, id);
577
+ } else {
578
+ db.prepare("UPDATE jobs SET status = ?, updated_at = ? WHERE id = ?").run(status, now, id);
579
+ }
580
+ }
581
+
582
+ // ─── Stats ──────────────────────────────────────────────────────────
583
+
584
+ export function getStats(): {
585
+ totalRecords: number;
586
+ totalSessions: number;
587
+ totalFileActivity: number;
588
+ recordsByKind: Record<string, number>;
589
+ } {
590
+ const db = getDb();
591
+ const totalRecords = (db.prepare("SELECT COUNT(*) as c FROM records WHERE status = 'active'").get() as { c: number }).c;
592
+ const totalSessions = (db.prepare("SELECT COUNT(*) as c FROM sessions WHERE source_status = 'active'").get() as { c: number }).c;
593
+ const totalFileActivity = (db.prepare("SELECT COUNT(*) as c FROM file_activity").get() as { c: number }).c;
594
+ const kindRows = db.prepare("SELECT kind, COUNT(*) as c FROM records WHERE status = 'active' GROUP BY kind").all() as { kind: string; c: number }[];
595
+ const recordsByKind: Record<string, number> = {};
596
+ for (const r of kindRows) {
597
+ recordsByKind[r.kind] = r.c;
598
+ }
599
+ return { totalRecords, totalSessions, totalFileActivity, recordsByKind };
600
+ }
601
+
602
+ // ─── Forgetting ─────────────────────────────────────────────────────
603
+
604
+ export function softForgetRecord(id: string): boolean {
605
+ const db = getDb();
606
+ const result = db.prepare("UPDATE records SET status = 'soft_forgotten', updated_at = ? WHERE id = ?").run(Date.now(), id);
607
+ return result.changes > 0;
608
+ }
609
+
610
+ export function hardDeleteRecord(id: string): boolean {
611
+ const db = getDb();
612
+ const row = db.prepare("SELECT rowid FROM records WHERE id = ?").get(id) as { rowid: number } | undefined;
613
+ if (row) {
614
+ db.prepare("DELETE FROM record_fts WHERE rowid = ?").run(row.rowid);
615
+ }
616
+ db.prepare("DELETE FROM file_activity WHERE record_id = ?").run(id);
617
+ db.prepare("DELETE FROM injections WHERE ',' || COALESCE(injected_refs, '') || ',' LIKE ?").run(`%,${id},%`);
618
+ const result = db.prepare("DELETE FROM records WHERE id = ?").run(id);
619
+ return result.changes > 0;
620
+ }