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
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the retrieval/ranking module.
|
|
3
|
+
* Run with: NODE_OPTIONS="--experimental-sqlite" tsx --test test/ranking.test.ts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, before, after } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { existsSync, unlinkSync, mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
buildSearchQuery,
|
|
13
|
+
rankAndFilter,
|
|
14
|
+
buildInjectionPacket,
|
|
15
|
+
formatInjectionForLlm,
|
|
16
|
+
} from "../src/retrieval/index.js";
|
|
17
|
+
import {
|
|
18
|
+
getDb,
|
|
19
|
+
closeDb,
|
|
20
|
+
upsertRecord,
|
|
21
|
+
getDbPath,
|
|
22
|
+
softForgetRecord,
|
|
23
|
+
getRecord,
|
|
24
|
+
hardDeleteRecord,
|
|
25
|
+
insertInjection,
|
|
26
|
+
getLastInjection,
|
|
27
|
+
} from "../src/db/index.js";
|
|
28
|
+
import type { RecordRow } from "../src/db/index.js";
|
|
29
|
+
import type { RecordKind, RecordScope } from "../src/db/schema.js";
|
|
30
|
+
|
|
31
|
+
// ─── DB cleanup ─────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const testMemoryDir = mkdtempSync(join(tmpdir(), "pi-memory-stone-ranking-"));
|
|
34
|
+
process.env.PI_MEMORY_STONE_DB_PATH = join(testMemoryDir, "memory.db");
|
|
35
|
+
|
|
36
|
+
function cleanDb() {
|
|
37
|
+
const dbPath = getDbPath();
|
|
38
|
+
closeDb();
|
|
39
|
+
// Small delay to ensure file handles are released
|
|
40
|
+
const files = [dbPath, dbPath + "-wal", dbPath + "-shm"];
|
|
41
|
+
for (const f of files) {
|
|
42
|
+
try { if (existsSync(f)) unlinkSync(f); } catch {}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Seed data helper ───────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
function seedTestRecords() {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const records: Array<{
|
|
51
|
+
kind: RecordKind;
|
|
52
|
+
scope: RecordScope;
|
|
53
|
+
project_id: string | null;
|
|
54
|
+
text: string;
|
|
55
|
+
tags?: string;
|
|
56
|
+
created_at: number;
|
|
57
|
+
}> = [
|
|
58
|
+
{
|
|
59
|
+
kind: "decision",
|
|
60
|
+
scope: "project",
|
|
61
|
+
project_id: "/home/test-project",
|
|
62
|
+
text: "Decided to use TypeScript for the frontend. Reasoning: better type safety and tooling support.",
|
|
63
|
+
tags: "typescript,frontend,decision",
|
|
64
|
+
created_at: now - 1000,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
kind: "preference",
|
|
68
|
+
scope: "project",
|
|
69
|
+
project_id: "/home/test-project",
|
|
70
|
+
text: "User prefers 2-space indentation for all TypeScript files.",
|
|
71
|
+
tags: "formatting,preference",
|
|
72
|
+
created_at: now - 2000,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
kind: "error_resolution",
|
|
76
|
+
scope: "project",
|
|
77
|
+
project_id: "/home/test-project",
|
|
78
|
+
text: "Tool: bash\nError: Module not found: @earendil-works/pi-coding-agent\nResolution: Run npm install in the extension directory.",
|
|
79
|
+
tags: "error,resolve",
|
|
80
|
+
created_at: now - 5000,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
kind: "task",
|
|
84
|
+
scope: "project",
|
|
85
|
+
project_id: "/home/test-project",
|
|
86
|
+
text: "TODO: Add unit tests for the authentication module.",
|
|
87
|
+
tags: "task,todo",
|
|
88
|
+
created_at: now - 10000,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
kind: "preference",
|
|
92
|
+
scope: "global",
|
|
93
|
+
project_id: null,
|
|
94
|
+
text: "User always wants answers in concise bullet-point format.",
|
|
95
|
+
tags: "formatting,global",
|
|
96
|
+
created_at: now - 3000,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
kind: "decision",
|
|
100
|
+
scope: "project",
|
|
101
|
+
project_id: "/home/other-project",
|
|
102
|
+
text: "Decided to use PostgreSQL instead of MySQL for the other project.",
|
|
103
|
+
tags: "database,decision",
|
|
104
|
+
created_at: now - 6000,
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
for (const r of records) {
|
|
109
|
+
upsertRecord({
|
|
110
|
+
kind: r.kind,
|
|
111
|
+
scope: r.scope,
|
|
112
|
+
project_id: r.project_id,
|
|
113
|
+
text: r.text,
|
|
114
|
+
tags: r.tags,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── Tests ──────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
describe("buildSearchQuery", () => {
|
|
122
|
+
it("uses user prompt text", () => {
|
|
123
|
+
const query = buildSearchQuery("How should I format TypeScript files?");
|
|
124
|
+
assert.ok(query.includes("How should I format"));
|
|
125
|
+
assert.ok(query.includes("TypeScript files"));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("includes recent file basenames", () => {
|
|
129
|
+
const query = buildSearchQuery("Fix auth bug", [
|
|
130
|
+
"src/auth/login.ts",
|
|
131
|
+
"src/auth/signup.ts",
|
|
132
|
+
]);
|
|
133
|
+
assert.ok(query.includes("login.ts"));
|
|
134
|
+
assert.ok(query.includes("signup.ts"));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("truncates long prompts", () => {
|
|
138
|
+
const longPrompt = "A".repeat(500);
|
|
139
|
+
const query = buildSearchQuery(longPrompt);
|
|
140
|
+
assert.ok(query.length <= 250);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("rankAndFilter", () => {
|
|
145
|
+
before(() => {
|
|
146
|
+
cleanDb();
|
|
147
|
+
seedTestRecords();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
after(() => {
|
|
151
|
+
cleanDb();
|
|
152
|
+
rmSync(testMemoryDir, { recursive: true, force: true });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("boosts same-project records", () => {
|
|
156
|
+
const db = getDb();
|
|
157
|
+
const records = db
|
|
158
|
+
.prepare(
|
|
159
|
+
`SELECT r.*, 0 as rank FROM records r
|
|
160
|
+
WHERE r.text LIKE '%TypeScript%' AND r.status = 'active'`,
|
|
161
|
+
)
|
|
162
|
+
.all() as unknown as (RecordRow & { rank: number })[];
|
|
163
|
+
|
|
164
|
+
const ranked = rankAndFilter(records, "/home/test-project", false);
|
|
165
|
+
|
|
166
|
+
const sameProject = ranked.filter(
|
|
167
|
+
(r) => r.record.project_id === "/home/test-project",
|
|
168
|
+
);
|
|
169
|
+
assert.ok(sameProject.length > 0);
|
|
170
|
+
assert.ok(sameProject.some((r) => r.reasons.includes("same-project")));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("filters out cross-project records when crossProjectEnabled is false", () => {
|
|
174
|
+
const db = getDb();
|
|
175
|
+
const records = db
|
|
176
|
+
.prepare(
|
|
177
|
+
`SELECT r.*, 0 as rank FROM records r
|
|
178
|
+
WHERE r.text LIKE '%PostgreSQL%' AND r.status = 'active'`,
|
|
179
|
+
)
|
|
180
|
+
.all() as unknown as (RecordRow & { rank: number })[];
|
|
181
|
+
|
|
182
|
+
const ranked = rankAndFilter(records, "/home/test-project", false);
|
|
183
|
+
|
|
184
|
+
const otherProject = ranked.filter(
|
|
185
|
+
(r) => r.record.project_id === "/home/other-project",
|
|
186
|
+
);
|
|
187
|
+
assert.equal(otherProject.length, 0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("filters out global-scope records from other projects when cross-project is disabled", () => {
|
|
191
|
+
upsertRecord({
|
|
192
|
+
kind: "preference",
|
|
193
|
+
scope: "global",
|
|
194
|
+
project_id: "/home/other-project",
|
|
195
|
+
text: "Other project global preference about bullet lists",
|
|
196
|
+
tags: "global,other-project",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const db = getDb();
|
|
200
|
+
const records = db
|
|
201
|
+
.prepare(
|
|
202
|
+
`SELECT r.*, 0 as rank FROM records r
|
|
203
|
+
WHERE r.text LIKE '%Other project global preference%'`,
|
|
204
|
+
)
|
|
205
|
+
.all() as unknown as (RecordRow & { rank: number })[];
|
|
206
|
+
|
|
207
|
+
const ranked = rankAndFilter(records, "/home/test-project", false);
|
|
208
|
+
assert.equal(ranked.length, 0);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("includes global-scope records from other projects when cross-project", () => {
|
|
212
|
+
const db = getDb();
|
|
213
|
+
const records = db
|
|
214
|
+
.prepare(
|
|
215
|
+
`SELECT r.*, 0 as rank FROM records r
|
|
216
|
+
WHERE r.text LIKE '%bullet%' AND r.status = 'active'`,
|
|
217
|
+
)
|
|
218
|
+
.all() as unknown as (RecordRow & { rank: number })[];
|
|
219
|
+
|
|
220
|
+
const ranked = rankAndFilter(records, "/home/test-project", true);
|
|
221
|
+
|
|
222
|
+
const globalRecords = ranked.filter((r) => r.record.scope === "global");
|
|
223
|
+
assert.ok(globalRecords.length > 0);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("preserves soft-forgotten status on duplicate upsert", () => {
|
|
227
|
+
const id = upsertRecord({
|
|
228
|
+
kind: "decision",
|
|
229
|
+
scope: "project",
|
|
230
|
+
project_id: "/home/test-project",
|
|
231
|
+
text: "Remember not to resurrect this duplicate memory",
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
softForgetRecord(id);
|
|
235
|
+
upsertRecord({
|
|
236
|
+
kind: "decision",
|
|
237
|
+
scope: "project",
|
|
238
|
+
project_id: "/home/test-project",
|
|
239
|
+
text: "Remember not to resurrect this duplicate memory",
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
assert.equal(getRecord(id)?.status, "soft_forgotten");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("keeps duplicate text separate across projects", () => {
|
|
246
|
+
const id1 = upsertRecord({
|
|
247
|
+
kind: "decision",
|
|
248
|
+
scope: "project",
|
|
249
|
+
project_id: "/home/test-project",
|
|
250
|
+
text: "Use the same deployment checklist everywhere",
|
|
251
|
+
});
|
|
252
|
+
const id2 = upsertRecord({
|
|
253
|
+
kind: "decision",
|
|
254
|
+
scope: "project",
|
|
255
|
+
project_id: "/home/other-project",
|
|
256
|
+
text: "Use the same deployment checklist everywhere",
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
assert.notEqual(id1, id2);
|
|
260
|
+
assert.equal(getRecord(id1)?.project_id, "/home/test-project");
|
|
261
|
+
assert.equal(getRecord(id2)?.project_id, "/home/other-project");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("redacts secrets at the record storage boundary", () => {
|
|
265
|
+
const id = upsertRecord({
|
|
266
|
+
kind: "preference",
|
|
267
|
+
scope: "project",
|
|
268
|
+
project_id: "/home/test-project",
|
|
269
|
+
text: "Store password=superSecret123 for later",
|
|
270
|
+
tags: "token=abcdef0123456789",
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const record = getRecord(id);
|
|
274
|
+
assert.ok(record);
|
|
275
|
+
assert.ok(record.text.includes("[REDACTED:password]"));
|
|
276
|
+
assert.ok(!record.text.includes("superSecret123"));
|
|
277
|
+
assert.ok(record.tags?.includes("[REDACTED:token]"));
|
|
278
|
+
assert.ok(!record.tags?.includes("abcdef0123456789"));
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("hard deletion removes injection audit rows that reference the record", () => {
|
|
282
|
+
const id = upsertRecord({
|
|
283
|
+
kind: "decision",
|
|
284
|
+
scope: "project",
|
|
285
|
+
project_id: "/home/test-project",
|
|
286
|
+
text: "Delete this audit-visible memory",
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
insertInjection({
|
|
290
|
+
session_id: "session-with-deleted-memory",
|
|
291
|
+
injected_refs: id,
|
|
292
|
+
packet: `Memory text for ${id}: Delete this audit-visible memory`,
|
|
293
|
+
});
|
|
294
|
+
assert.ok(getLastInjection("session-with-deleted-memory"));
|
|
295
|
+
|
|
296
|
+
hardDeleteRecord(id);
|
|
297
|
+
assert.equal(getLastInjection("session-with-deleted-memory"), undefined);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("filters out non-active records", () => {
|
|
301
|
+
// Create a soft-forgotten record
|
|
302
|
+
upsertRecord({
|
|
303
|
+
kind: "decision",
|
|
304
|
+
scope: "project",
|
|
305
|
+
project_id: "/home/test-project",
|
|
306
|
+
text: "Forgotten decision about architecture",
|
|
307
|
+
status: "soft_forgotten",
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const db = getDb();
|
|
311
|
+
const records = db
|
|
312
|
+
.prepare(
|
|
313
|
+
`SELECT r.*, 0 as rank FROM records r
|
|
314
|
+
WHERE r.text LIKE '%Forgotten decision%'`,
|
|
315
|
+
)
|
|
316
|
+
.all() as unknown as (RecordRow & { rank: number })[];
|
|
317
|
+
|
|
318
|
+
const ranked = rankAndFilter(records, "/home/test-project", false);
|
|
319
|
+
assert.equal(ranked.length, 0);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("buildInjectionPacket", () => {
|
|
324
|
+
it("builds a structured injection packet", () => {
|
|
325
|
+
const results = [
|
|
326
|
+
{
|
|
327
|
+
record: {
|
|
328
|
+
id: "abc123",
|
|
329
|
+
kind: "decision" as RecordKind,
|
|
330
|
+
text: "Decided to use TypeScript",
|
|
331
|
+
project_id: "/home/test-project",
|
|
332
|
+
created_at: Date.now() - 1000,
|
|
333
|
+
} as RecordRow,
|
|
334
|
+
score: 0.95,
|
|
335
|
+
reasons: ["same-project"],
|
|
336
|
+
},
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
const packet = buildInjectionPacket(results);
|
|
340
|
+
assert.ok(packet.header.includes("Memory: loaded"));
|
|
341
|
+
assert.equal(packet.items.length, 1);
|
|
342
|
+
assert.equal(packet.items[0].ref, "abc123");
|
|
343
|
+
assert.ok(packet.items[0].text.includes("TypeScript"));
|
|
344
|
+
assert.ok(packet.footer.includes("memory-forget"));
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("adds stale hints for old records", () => {
|
|
348
|
+
const thirtyDaysAgo = Date.now() - 31 * 24 * 60 * 60 * 1000;
|
|
349
|
+
const results = [
|
|
350
|
+
{
|
|
351
|
+
record: {
|
|
352
|
+
id: "old123",
|
|
353
|
+
kind: "decision" as RecordKind,
|
|
354
|
+
text: "Old decision",
|
|
355
|
+
project_id: "/home/test-project",
|
|
356
|
+
created_at: thirtyDaysAgo,
|
|
357
|
+
} as RecordRow,
|
|
358
|
+
score: 0.5,
|
|
359
|
+
reasons: [],
|
|
360
|
+
},
|
|
361
|
+
];
|
|
362
|
+
|
|
363
|
+
const packet = buildInjectionPacket(results);
|
|
364
|
+
assert.ok(packet.items[0].staleHint);
|
|
365
|
+
assert.ok(packet.items[0].staleHint!.includes("stale"));
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
describe("formatInjectionForLlm", () => {
|
|
370
|
+
it("formats packet for LLM consumption", () => {
|
|
371
|
+
const packet = {
|
|
372
|
+
header: "Memory: loaded 2 items",
|
|
373
|
+
items: [
|
|
374
|
+
{ ref: "abc", kind: "decision", text: "Use TypeScript" },
|
|
375
|
+
{ ref: "def", kind: "preference", text: "2-space indent" },
|
|
376
|
+
],
|
|
377
|
+
footer: "Use /memory-forget to remove",
|
|
378
|
+
recordCount: 2,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const formatted = formatInjectionForLlm(packet, 1000);
|
|
382
|
+
assert.ok(formatted.includes("Memory: loaded"));
|
|
383
|
+
assert.ok(formatted.includes("[decision ref=abc]"));
|
|
384
|
+
assert.ok(formatted.includes("[preference ref=def]"));
|
|
385
|
+
assert.ok(formatted.includes("/memory-forget"));
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("respects token budget", () => {
|
|
389
|
+
const longText = "x".repeat(5000);
|
|
390
|
+
const packet = {
|
|
391
|
+
header: "Header",
|
|
392
|
+
items: [{ ref: "abc", kind: "decision", text: longText }],
|
|
393
|
+
footer: "Footer",
|
|
394
|
+
recordCount: 1,
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const formatted = formatInjectionForLlm(packet, 10);
|
|
398
|
+
assert.ok(
|
|
399
|
+
formatted.length < 200,
|
|
400
|
+
`Expected <200 chars but got ${formatted.length}`,
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"types": ["node"],
|
|
10
|
+
"allowImportingTsExtensions": false
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*.ts", "test/**/*.ts"]
|
|
13
|
+
}
|