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,219 @@
1
+ /**
2
+ * Retrieval and ranking module.
3
+ * Hybrid ranking: FTS score + same-project boost + recency decay + kind boost.
4
+ */
5
+
6
+ import type { RecordRow } from "../db/index.js";
7
+ import type { RecordKind, RecordScope } from "../db/schema.js";
8
+ import { searchRecordsFts } from "../db/index.js";
9
+ import { getConfig } from "../config/index.js";
10
+
11
+ // ─── Kind boosts ────────────────────────────────────────────────────
12
+
13
+ const KIND_BOOST: Record<string, number> = {
14
+ decision: 1.5,
15
+ preference: 1.3,
16
+ error_resolution: 1.4,
17
+ task: 1.1,
18
+ turn_summary: 0.9,
19
+ session_summary: 0.8,
20
+ file_activity: 0.5,
21
+ };
22
+
23
+ // ─── Recency decay ──────────────────────────────────────────────────
24
+
25
+ const RECENCY_HALF_LIFE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
26
+
27
+ function recencyDecay(createdAt: number): number {
28
+ const age = Date.now() - createdAt;
29
+ return Math.exp(-Math.log(2) * (age / RECENCY_HALF_LIFE_MS));
30
+ }
31
+
32
+ // ─── Query builder ──────────────────────────────────────────────────
33
+
34
+ export function buildSearchQuery(
35
+ userPrompt: string,
36
+ recentFiles: string[] = [],
37
+ ): string {
38
+ const parts: string[] = [];
39
+
40
+ // User prompt terms (take first ~200 chars)
41
+ parts.push(userPrompt.slice(0, 200));
42
+
43
+ // Recent files (just filenames, not full paths)
44
+ for (const f of recentFiles.slice(0, 5)) {
45
+ const basename = f.split("/").pop() || f;
46
+ parts.push(basename);
47
+ }
48
+
49
+ return parts.join(" ");
50
+ }
51
+
52
+ // ─── Ranking ────────────────────────────────────────────────────────
53
+
54
+ export interface RankedResult {
55
+ record: RecordRow;
56
+ score: number;
57
+ reasons: string[];
58
+ }
59
+
60
+ export function rankAndFilter(
61
+ records: (RecordRow & { rank: number })[],
62
+ currentProjectId: string | null,
63
+ crossProjectEnabled: boolean,
64
+ ): RankedResult[] {
65
+ const results: RankedResult[] = [];
66
+
67
+ for (const rec of records) {
68
+ // Skip non-active records
69
+ if (rec.status !== "active") continue;
70
+
71
+ // Cross-project/global filter. Global records are cross-project by definition
72
+ // and require explicit cross-project retrieval.
73
+ if (rec.scope === "global") {
74
+ if (!crossProjectEnabled) continue;
75
+ } else if (rec.project_id && currentProjectId && rec.project_id !== currentProjectId) {
76
+ continue;
77
+ }
78
+
79
+ // Compute hybrid score
80
+ let score = 1.0 / (1.0 + (rec.rank ?? 0)); // Normalize FTS rank: lower rank = better
81
+
82
+ // Same project boost
83
+ const reasons: string[] = [];
84
+ if (rec.project_id && currentProjectId && rec.project_id === currentProjectId) {
85
+ score *= 1.5;
86
+ reasons.push("same-project");
87
+ }
88
+
89
+ // Global preference boost
90
+ if (rec.scope === "global") {
91
+ score *= 1.2;
92
+ reasons.push("global-preference");
93
+ }
94
+
95
+ // Kind boost
96
+ const kindBoost = KIND_BOOST[rec.kind] ?? 1.0;
97
+ score *= kindBoost;
98
+ if (kindBoost !== 1.0) reasons.push(`kind:${rec.kind}`);
99
+
100
+ // Recency decay
101
+ const decay = recencyDecay(rec.created_at);
102
+ score *= decay;
103
+
104
+ // Confidence multiplier
105
+ score *= rec.confidence;
106
+
107
+ // Importance multiplier
108
+ score *= 0.5 + rec.importance; // Scale: 0.5-1.5
109
+
110
+ results.push({ record: rec, score, reasons });
111
+ }
112
+
113
+ // Sort by score descending, then recency
114
+ results.sort((a, b) => {
115
+ if (b.score !== a.score) return b.score - a.score;
116
+ return b.record.created_at - a.record.created_at;
117
+ });
118
+
119
+ return results;
120
+ }
121
+
122
+ // ─── Full retrieval pipeline ────────────────────────────────────────
123
+
124
+ export function retrieve(
125
+ userPrompt: string,
126
+ currentProjectId: string | null,
127
+ recentFiles: string[] = [],
128
+ opts?: {
129
+ limit?: number;
130
+ crossProjectEnabled?: boolean;
131
+ kindFilter?: RecordKind[];
132
+ scopeFilter?: RecordScope[];
133
+ },
134
+ ): RankedResult[] {
135
+ const config = getConfig();
136
+ const limit = opts?.limit ?? config.maxInjectedRecords;
137
+ const crossProject = opts?.crossProjectEnabled ?? config.crossProjectEnabled;
138
+
139
+ const query = buildSearchQuery(userPrompt, recentFiles);
140
+
141
+ // Get more candidates than needed (ranking will filter)
142
+ const candidates = searchRecordsFts(query, limit * 10, opts?.kindFilter, opts?.scopeFilter);
143
+
144
+ const ranked = rankAndFilter(candidates, currentProjectId, crossProject);
145
+
146
+ // Return top results
147
+ return ranked.slice(0, limit);
148
+ }
149
+
150
+ // ─── Injection packet builder ────────────────────────────────────────
151
+
152
+ export interface InjectionPacket {
153
+ header: string;
154
+ items: Array<{
155
+ ref: string;
156
+ kind: string;
157
+ text: string;
158
+ project?: string;
159
+ staleHint?: string;
160
+ }>;
161
+ footer: string;
162
+ recordCount: number;
163
+ }
164
+
165
+ export function buildInjectionPacket(results: RankedResult[]): InjectionPacket {
166
+ const items: InjectionPacket["items"] = [];
167
+
168
+ for (const r of results) {
169
+ const age = Date.now() - r.record.created_at;
170
+ const ageDays = Math.floor(age / (24 * 60 * 60 * 1000));
171
+ let staleHint: string | undefined;
172
+
173
+ if (ageDays > 30) {
174
+ staleHint = `⚠️ This memory is ${ageDays} days old and may be stale.`;
175
+ } else if (ageDays > 7) {
176
+ staleHint = `📅 From ${ageDays} days ago.`;
177
+ }
178
+
179
+ items.push({
180
+ ref: r.record.id,
181
+ kind: r.record.kind,
182
+ text: r.record.text,
183
+ project: r.record.project_id === null ? "global" : undefined,
184
+ staleHint,
185
+ });
186
+ }
187
+
188
+ return {
189
+ header: `Memory: loaded ${items.length} relevant items from past sessions. These may help provide context.`,
190
+ items,
191
+ footer:
192
+ "Use memory_open with a ref for full text. If any memory is irrelevant or conflicting, mention it and I can /memory-forget <ref> it.",
193
+ recordCount: items.length,
194
+ };
195
+ }
196
+
197
+ export function formatInjectionForLlm(packet: InjectionPacket, maxTokens = 1000): string {
198
+ const lines: string[] = [packet.header, ""];
199
+
200
+ // Rough token estimate: ~4 chars per token
201
+ let charBudget = maxTokens * 4;
202
+ let usedChars = lines.join("\n").length;
203
+
204
+ for (const item of packet.items) {
205
+ const itemHeader = `[${item.kind} ref=${item.ref}]`;
206
+ const itemText = item.text.slice(0, 300); // Truncate per item
207
+ const itemFooter = item.staleHint ? ` ${item.staleHint}` : "";
208
+ const itemLine = `${itemHeader} ${itemText}${itemFooter}`;
209
+
210
+ if (usedChars + itemLine.length > charBudget) break;
211
+ lines.push(itemLine);
212
+ usedChars += itemLine.length + 1;
213
+ }
214
+
215
+ lines.push("");
216
+ lines.push(packet.footer);
217
+
218
+ return lines.join("\n");
219
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Tools: memory_search, memory_open, memory_remember, memory_forget
3
+ */
4
+
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { Type } from "typebox";
7
+ import { StringEnum } from "@earendil-works/pi-ai";
8
+ import { getRecord, softForgetRecord, upsertRecord } from "../db/index.js";
9
+ import { retrieve, buildInjectionPacket, formatInjectionForLlm } from "../retrieval/index.js";
10
+ import { getProjectId, getConfig } from "../config/index.js";
11
+ import type { RecordKind, RecordScope } from "../db/schema.js";
12
+
13
+ export function registerTools(pi: ExtensionAPI): void {
14
+ // ── memory_search ───────────────────────────────────────────────
15
+
16
+ pi.registerTool({
17
+ name: "memory_search",
18
+ label: "Search Memory",
19
+ description:
20
+ "Search your memory stone for relevant records from past pi sessions. Use this to recall past decisions, preferences, tasks, or error resolutions.",
21
+ promptSnippet: "Search memory stone by query, with optional kind/scope/limit filters",
22
+ promptGuidelines: [
23
+ "Use memory_search to recall relevant context from past sessions before making decisions.",
24
+ "Set kind to filter by record type: decision, preference, task, error_resolution, turn_summary.",
25
+ "Set scope to 'global' for cross-project memories, or omit for current project only.",
26
+ ],
27
+ parameters: Type.Object({
28
+ query: Type.String({ description: "Search query text" }),
29
+ kind: Type.Optional(
30
+ StringEnum([
31
+ "decision",
32
+ "preference",
33
+ "task",
34
+ "error_resolution",
35
+ "turn_summary",
36
+ "session_summary",
37
+ ] as const),
38
+ ),
39
+ scope: Type.Optional(StringEnum(["project", "global"] as const)),
40
+ limit: Type.Optional(Type.Number({ description: "Max results (default 5)" })),
41
+ }),
42
+ async execute(toolCallId, params, _signal, _onUpdate, ctx) {
43
+ const projectId = getProjectId(ctx.cwd);
44
+ const config = getConfig(ctx.cwd);
45
+ const limit = params.limit ?? 5;
46
+
47
+ const results = retrieve(params.query, projectId, [], {
48
+ limit,
49
+ crossProjectEnabled: params.scope === "global" || config.crossProjectEnabled,
50
+ kindFilter: params.kind ? [params.kind as RecordKind] : undefined,
51
+ scopeFilter: params.scope ? [params.scope as RecordScope] : undefined,
52
+ });
53
+
54
+ if (results.length === 0) {
55
+ return {
56
+ content: [{ type: "text", text: "No matching memories found." }],
57
+ details: { query: params.query, results: [] },
58
+ };
59
+ }
60
+
61
+ const packet = buildInjectionPacket(results);
62
+ const formatted = formatInjectionForLlm(packet, 2000);
63
+
64
+ return {
65
+ content: [{ type: "text", text: formatted }],
66
+ details: {
67
+ query: params.query,
68
+ results: results.map((r) => ({
69
+ id: r.record.id,
70
+ kind: r.record.kind,
71
+ score: r.score,
72
+ text: r.record.text.slice(0, 200),
73
+ reasons: r.reasons,
74
+ })),
75
+ },
76
+ };
77
+ },
78
+ });
79
+
80
+ // ── memory_open ─────────────────────────────────────────────────
81
+
82
+ pi.registerTool({
83
+ name: "memory_open",
84
+ label: "Open Memory",
85
+ description:
86
+ "Open a specific memory record by its reference ID. Returns the full record text and metadata. The 'ref' can be obtained from memory_search results or injection packets.",
87
+ promptSnippet: "Open specific memory record by ref ID to see full content",
88
+ promptGuidelines: [
89
+ "Use memory_open when you need the full text of a specific memory record referenced in an injection packet or search result.",
90
+ ],
91
+ parameters: Type.Object({
92
+ ref: Type.String({ description: "Memory record reference ID" }),
93
+ }),
94
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
95
+ const record = getRecord(params.ref);
96
+
97
+ if (!record) {
98
+ return {
99
+ content: [{ type: "text", text: `Memory record ${params.ref} not found.` }],
100
+ details: { ref: params.ref, found: false },
101
+ };
102
+ }
103
+
104
+ const currentProjectId = getProjectId(ctx.cwd);
105
+ const visibleInCurrentProject =
106
+ record.scope === "global" || record.project_id === null || record.project_id === currentProjectId;
107
+
108
+ if (record.status !== "active" || !visibleInCurrentProject) {
109
+ return {
110
+ content: [{ type: "text", text: `Memory record ${params.ref} is not available.` }],
111
+ details: { ref: params.ref, found: false, unavailable: true },
112
+ };
113
+ }
114
+
115
+ // Redacted excerpt (already redacted at storage time)
116
+ const lines: string[] = [];
117
+ lines.push(`Memory Record: ${record.id}`);
118
+ lines.push(`Kind: ${record.kind}`);
119
+ lines.push(`Scope: ${record.scope}`);
120
+ lines.push(`Project: ${record.project_id ?? "global"}`);
121
+ lines.push(`Created: ${new Date(record.created_at).toISOString()}`);
122
+ lines.push(`Status: ${record.status}`);
123
+ lines.push("");
124
+ lines.push(record.text);
125
+
126
+ return {
127
+ content: [{ type: "text", text: lines.join("\n") }],
128
+ details: {
129
+ ref: params.ref,
130
+ found: true,
131
+ kind: record.kind,
132
+ scope: record.scope,
133
+ status: record.status,
134
+ created_at: record.created_at,
135
+ },
136
+ };
137
+ },
138
+ });
139
+
140
+ // ── memory_remember ─────────────────────────────────────────────
141
+
142
+ pi.registerTool({
143
+ name: "memory_remember",
144
+ label: "Remember",
145
+ description:
146
+ "Explicitly store a memory record. Only use when the user explicitly asks you to remember something. Default scope is project. Use scope='global' only when the user explicitly says to remember globally.",
147
+ promptSnippet: "Store an explicit memory record when user asks to remember something",
148
+ promptGuidelines: [
149
+ "Use memory_remember only when the user explicitly asks you to remember a decision, preference, or fact.",
150
+ "Default to scope='project'. Only use scope='global' when the user explicitly says 'remember this globally' or 'remember for all projects'.",
151
+ ],
152
+ parameters: Type.Object({
153
+ kind: StringEnum([
154
+ "decision",
155
+ "preference",
156
+ "task",
157
+ "error_resolution",
158
+ "turn_summary",
159
+ "session_summary",
160
+ ] as const),
161
+ text: Type.String({ description: "Memory text to store" }),
162
+ scope: Type.Optional(StringEnum(["project", "global"] as const)),
163
+ tags: Type.Optional(Type.String({ description: "Comma-separated tags" })),
164
+ importance: Type.Optional(Type.Number({ description: "Importance 0-1 (default 0.5)" })),
165
+ }),
166
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
167
+ const projectId = getProjectId(ctx.cwd);
168
+ let scope = params.scope ?? "project";
169
+
170
+ // Safety: never allow global for implementation details, paths, etc.
171
+ const isSensitiveForGlobal =
172
+ /\b(?:password|secret|token|key|\.env|localhost|127\.0\.0\.1|internal|private)\b/i.test(
173
+ params.text,
174
+ );
175
+
176
+ const downgradedToProject = isSensitiveForGlobal && scope === "global";
177
+ if (downgradedToProject) {
178
+ scope = "project";
179
+ }
180
+
181
+ const recordId = upsertRecord({
182
+ kind: params.kind as RecordKind,
183
+ scope: scope as RecordScope,
184
+ project_id: scope === "global" ? null : projectId,
185
+ text: params.text,
186
+ tags: params.tags,
187
+ importance: params.importance ?? 0.5,
188
+ confidence: 1.0,
189
+ });
190
+
191
+ return {
192
+ content: [
193
+ {
194
+ type: "text",
195
+ text: downgradedToProject
196
+ ? `Cannot store as global: text appears to contain sensitive data (secrets, paths, hostnames). Stored as project-scoped instead: [${params.kind}] ${recordId}`
197
+ : `Memory stored: [${params.kind}] ${recordId} (scope: ${scope})`,
198
+ },
199
+ ],
200
+ details: { id: recordId, kind: params.kind, scope, downgradedToProject },
201
+ };
202
+ },
203
+ });
204
+
205
+ // ── memory_forget ───────────────────────────────────────────────
206
+
207
+ pi.registerTool({
208
+ name: "memory_forget",
209
+ label: "Forget Memory",
210
+ description:
211
+ "Soft-forget a memory record by its reference ID. The record is hidden from future searches but can be restored. Use --hard for permanent deletion (requires explicit user confirmation).",
212
+ promptSnippet: "Forget a memory record by ID (soft or hard)",
213
+ promptGuidelines: [
214
+ "Use memory_forget when the user asks to remove or forget a memory reference.",
215
+ "Default to soft forget. Only use hard=true when the user explicitly asks to permanently delete.",
216
+ ],
217
+ parameters: Type.Object({
218
+ ref: Type.String({ description: "Memory record reference ID" }),
219
+ hard: Type.Optional(Type.Boolean({ description: "Permanently delete (default: soft forget)" })),
220
+ }),
221
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
222
+ const record = getRecord(params.ref);
223
+
224
+ if (!record) {
225
+ return {
226
+ content: [{ type: "text", text: `Memory record ${params.ref} not found.` }],
227
+ details: { ref: params.ref, found: false },
228
+ };
229
+ }
230
+
231
+ if (params.hard) {
232
+ // For hard delete via tool, we require the user to explicitly confirm
233
+ // The tool should note this requires user interaction
234
+ return {
235
+ content: [
236
+ {
237
+ type: "text",
238
+ text: `Permanent deletion requires explicit confirmation. Please use /memory-forget ${params.ref} --hard to permanently delete this record.\n\nRecord: [${record.kind}] ${record.text.slice(0, 200)}`,
239
+ },
240
+ ],
241
+ details: { ref: params.ref, requiresConfirmation: true },
242
+ };
243
+ }
244
+
245
+ softForgetRecord(params.ref);
246
+ return {
247
+ content: [
248
+ {
249
+ type: "text",
250
+ text: `Memory record ${params.ref} has been soft-forgotten. It will no longer appear in searches.`,
251
+ },
252
+ ],
253
+ details: { ref: params.ref, forgotten: true, hard: false },
254
+ };
255
+ },
256
+ });
257
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Tests for incremental session indexing.
3
+ */
4
+
5
+ import { describe, it, beforeEach, after } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { existsSync, unlinkSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { indexSessionOnAgentEnd } from "../src/indexing/index.js";
11
+ import { closeDb, getDb, getDbPath } from "../src/db/index.js";
12
+
13
+ const testMemoryDir = mkdtempSync(join(tmpdir(), "pi-memory-stone-indexing-"));
14
+ process.env.PI_MEMORY_STONE_DB_PATH = join(testMemoryDir, "memory.db");
15
+
16
+ function cleanDb() {
17
+ const dbPath = getDbPath();
18
+ closeDb();
19
+ for (const f of [dbPath, dbPath + "-wal", dbPath + "-shm"]) {
20
+ try { if (existsSync(f)) unlinkSync(f); } catch {}
21
+ }
22
+ }
23
+
24
+ function makeUserEntry(id: string, text: string) {
25
+ return {
26
+ type: "message",
27
+ id,
28
+ parentId: null,
29
+ message: {
30
+ role: "user",
31
+ content: text,
32
+ timestamp: Date.now(),
33
+ },
34
+ };
35
+ }
36
+
37
+ function makeAssistantEntry(id: string, text: string) {
38
+ return {
39
+ type: "message",
40
+ id,
41
+ parentId: null,
42
+ message: {
43
+ role: "assistant",
44
+ content: [{ type: "text", text }],
45
+ timestamp: Date.now(),
46
+ },
47
+ };
48
+ }
49
+
50
+ function makeContext(sessionFile: string, branch: unknown[]) {
51
+ return {
52
+ cwd: testMemoryDir,
53
+ sessionManager: {
54
+ getSessionFile: () => sessionFile,
55
+ getSessionId: () => "session-1",
56
+ getBranch: () => branch,
57
+ getLeafId: () => (branch.at(-1) as { id?: string } | undefined)?.id,
58
+ },
59
+ } as any;
60
+ }
61
+
62
+ describe("indexSessionOnAgentEnd", () => {
63
+ beforeEach(() => cleanDb());
64
+
65
+ after(() => {
66
+ cleanDb();
67
+ rmSync(testMemoryDir, { recursive: true, force: true });
68
+ });
69
+
70
+ it("indexes only entries after the last indexed entry when timestamps are absent", async () => {
71
+ const sessionFile = join(testMemoryDir, "session.jsonl");
72
+ writeFileSync(sessionFile, "");
73
+
74
+ const firstBranch = [
75
+ makeUserEntry("001", "First question"),
76
+ makeAssistantEntry("002", "First answer"),
77
+ ];
78
+ const first = await indexSessionOnAgentEnd(makeContext(sessionFile, firstBranch), {});
79
+ assert.equal(first.errors.length, 0);
80
+
81
+ const fullBranch = [
82
+ ...firstBranch,
83
+ makeUserEntry("003", "Second question"),
84
+ makeAssistantEntry("004", "Second answer"),
85
+ ];
86
+ const second = await indexSessionOnAgentEnd(makeContext(sessionFile, fullBranch), {});
87
+ assert.equal(second.errors.length, 0);
88
+
89
+ const rows = getDb()
90
+ .prepare("SELECT text FROM records WHERE kind = 'turn_summary' ORDER BY created_at")
91
+ .all() as Array<{ text: string }>;
92
+
93
+ assert.equal(rows.length, 2);
94
+ assert.ok(rows.some((r) => r.text.includes("First question")));
95
+ assert.ok(rows.some((r) => r.text.includes("Second question")));
96
+ });
97
+ });