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.
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/package.json +23 -0
- package/src/commands/index.ts +234 -0
- package/src/config/index.ts +108 -0
- package/src/db/index.ts +620 -0
- package/src/db/schema.ts +161 -0
- package/src/index.ts +197 -0
- package/src/indexing/index.ts +207 -0
- package/src/indexing/parser.ts +374 -0
- package/src/privacy/index.ts +167 -0
- package/src/retrieval/index.ts +219 -0
- package/src/tools/index.ts +257 -0
- package/test/indexing.test.ts +97 -0
- package/test/parser.test.ts +261 -0
- package/test/privacy.test.ts +120 -0
- package/test/ranking.test.ts +403 -0
- package/tsconfig.json +13 -0
package/src/db/index.ts
ADDED
|
@@ -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
|
+
}
|