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,161 @@
1
+ /**
2
+ * Database schema for pi-memory-stone.
3
+ * Versioned migrations applied sequentially.
4
+ */
5
+
6
+ export const SCHEMA_VERSION = 1;
7
+
8
+ export interface Migration {
9
+ version: number;
10
+ name: string;
11
+ sql: string;
12
+ }
13
+
14
+ export const MIGRATIONS: Migration[] = [
15
+ {
16
+ version: 1,
17
+ name: "initial-schema",
18
+ sql: `
19
+ -- Track indexed sessions
20
+ CREATE TABLE IF NOT EXISTS sessions (
21
+ id TEXT PRIMARY KEY,
22
+ session_file TEXT UNIQUE,
23
+ cwd TEXT,
24
+ repo_root TEXT,
25
+ project_id TEXT,
26
+ session_name TEXT,
27
+ created_at INTEGER NOT NULL,
28
+ updated_at INTEGER NOT NULL,
29
+ source_status TEXT NOT NULL DEFAULT 'active',
30
+ file_mtime INTEGER,
31
+ file_size INTEGER,
32
+ schema_version INTEGER NOT NULL DEFAULT 1
33
+ );
34
+
35
+ -- Structured memory records
36
+ CREATE TABLE IF NOT EXISTS records (
37
+ id TEXT PRIMARY KEY,
38
+ kind TEXT NOT NULL,
39
+ scope TEXT NOT NULL DEFAULT 'project',
40
+ project_id TEXT,
41
+ session_id TEXT,
42
+ session_file TEXT,
43
+ branch_leaf_id TEXT,
44
+ entry_id_start TEXT,
45
+ entry_id_end TEXT,
46
+ text TEXT NOT NULL,
47
+ tags TEXT,
48
+ status TEXT NOT NULL DEFAULT 'active',
49
+ confidence REAL NOT NULL DEFAULT 1.0,
50
+ importance REAL NOT NULL DEFAULT 0.5,
51
+ created_at INTEGER NOT NULL,
52
+ updated_at INTEGER NOT NULL,
53
+ superseded_by TEXT,
54
+ derived_from_memory_refs TEXT
55
+ );
56
+
57
+ -- Full-text search index (contentless — manually synced)
58
+ CREATE VIRTUAL TABLE IF NOT EXISTS record_fts USING fts5(
59
+ text,
60
+ tags
61
+ );
62
+
63
+ -- File activity tracking
64
+ CREATE TABLE IF NOT EXISTS file_activity (
65
+ id TEXT PRIMARY KEY,
66
+ record_id TEXT,
67
+ project_id TEXT,
68
+ path TEXT NOT NULL,
69
+ action TEXT NOT NULL,
70
+ entry_id TEXT
71
+ );
72
+
73
+ -- Injection audit log
74
+ CREATE TABLE IF NOT EXISTS injections (
75
+ id TEXT PRIMARY KEY,
76
+ session_id TEXT,
77
+ turn_entry_id TEXT,
78
+ prompt_hash TEXT,
79
+ injected_refs TEXT,
80
+ packet TEXT,
81
+ reasons TEXT,
82
+ created_at INTEGER NOT NULL
83
+ );
84
+
85
+ -- Per-session-file indexing progress
86
+ CREATE TABLE IF NOT EXISTS index_state (
87
+ session_file TEXT PRIMARY KEY,
88
+ session_id TEXT,
89
+ last_indexed_entry_id TEXT,
90
+ last_indexed_entry_timestamp TEXT,
91
+ file_mtime INTEGER,
92
+ file_size INTEGER,
93
+ branch_leaf_id TEXT,
94
+ schema_version INTEGER NOT NULL DEFAULT 1
95
+ );
96
+
97
+ -- Background job queue
98
+ CREATE TABLE IF NOT EXISTS jobs (
99
+ id TEXT PRIMARY KEY,
100
+ type TEXT NOT NULL,
101
+ payload TEXT,
102
+ status TEXT NOT NULL DEFAULT 'pending',
103
+ attempts INTEGER NOT NULL DEFAULT 0,
104
+ last_error TEXT,
105
+ created_at INTEGER NOT NULL,
106
+ updated_at INTEGER NOT NULL
107
+ );
108
+
109
+ -- Schema migration tracking
110
+ CREATE TABLE IF NOT EXISTS schema_migrations (
111
+ version INTEGER PRIMARY KEY,
112
+ applied_at INTEGER NOT NULL,
113
+ name TEXT NOT NULL
114
+ );
115
+
116
+ -- Indexes
117
+ CREATE INDEX IF NOT EXISTS idx_records_project ON records(project_id);
118
+ CREATE INDEX IF NOT EXISTS idx_records_kind ON records(kind);
119
+ CREATE INDEX IF NOT EXISTS idx_records_scope ON records(scope);
120
+ CREATE INDEX IF NOT EXISTS idx_records_session ON records(session_id);
121
+ CREATE INDEX IF NOT EXISTS idx_records_status ON records(status);
122
+ CREATE INDEX IF NOT EXISTS idx_file_activity_record ON file_activity(record_id);
123
+ CREATE INDEX IF NOT EXISTS idx_file_activity_path ON file_activity(path);
124
+ CREATE INDEX IF NOT EXISTS idx_file_activity_project ON file_activity(project_id);
125
+ CREATE INDEX IF NOT EXISTS idx_injections_session ON injections(session_id);
126
+ `,
127
+ },
128
+ ];
129
+
130
+ /** Record kinds */
131
+ export const RECORD_KINDS = [
132
+ "session_summary",
133
+ "turn_summary",
134
+ "decision",
135
+ "preference",
136
+ "task",
137
+ "error_resolution",
138
+ "file_activity",
139
+ ] as const;
140
+
141
+ export type RecordKind = (typeof RECORD_KINDS)[number];
142
+
143
+ /** Record scopes */
144
+ export const RECORD_SCOPES = ["project", "global"] as const;
145
+ export type RecordScope = (typeof RECORD_SCOPES)[number];
146
+
147
+ /** Record statuses */
148
+ export const RECORD_STATUSES = ["active", "soft_forgotten", "hard_forgotten", "superseded"] as const;
149
+ export type RecordStatus = (typeof RECORD_STATUSES)[number];
150
+
151
+ /** Source statuses */
152
+ export const SOURCE_STATUSES = ["active", "missing", "archived"] as const;
153
+ export type SourceStatus = (typeof SOURCE_STATUSES)[number];
154
+
155
+ /** Job statuses */
156
+ export const JOB_STATUSES = ["pending", "running", "done", "failed"] as const;
157
+ export type JobStatus = (typeof JOB_STATUSES)[number];
158
+
159
+ /** File action types */
160
+ export const FILE_ACTIONS = ["read", "write", "edit", "bash", "delete"] as const;
161
+ export type FileAction = (typeof FILE_ACTIONS)[number];
package/src/index.ts ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * pi-memory-stone — Global pi extension for session memory.
3
+ *
4
+ * Preserves and retrieves useful memory across pi sessions.
5
+ * Builds a searchable SQLite+FTS5 index with backreferences to session entries.
6
+ *
7
+ * Vertical slice MVP:
8
+ * - SQLite schema + migrations
9
+ * - Deterministic turn_summary and file_activity capture on agent_end
10
+ * - FTS5 search
11
+ * - /memory-status, /memory-search, /memory-last commands
12
+ * - memory_search, memory_open, memory_remember, memory_forget tools
13
+ * - Conservative same-project before_agent_start injection
14
+ */
15
+
16
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ import { registerCommands } from "./commands/index.js";
18
+ import { registerTools } from "./tools/index.js";
19
+ import { indexSessionOnAgentEnd } from "./indexing/index.js";
20
+ import { retrieve, buildInjectionPacket, formatInjectionForLlm } from "./retrieval/index.js";
21
+ import { getProjectId, getConfig, clearProjectCache } from "./config/index.js";
22
+ import { closeDb } from "./db/index.js";
23
+ import { createHash } from "node:crypto";
24
+
25
+ // ─── Session-scoped state ───────────────────────────────────────────
26
+
27
+ /** Track injected refs per turn to prevent feedback loops */
28
+ const injectedRefsThisSession: Set<string> = new Set();
29
+
30
+ /** Whether memory injection is temporarily disabled for this session */
31
+ let sessionEnabled = true;
32
+
33
+ // ─── Extension entry point ─────────────────────────────────────────
34
+
35
+ export default function (pi: ExtensionAPI) {
36
+ // ── Register commands ──────────────────────────────────────────
37
+
38
+ registerCommands(pi);
39
+
40
+ // ── Register tools ─────────────────────────────────────────────
41
+
42
+ registerTools(pi);
43
+
44
+ // ── agent_end: index session turn ──────────────────────────────
45
+
46
+ pi.on("agent_end", async (event, ctx) => {
47
+ try {
48
+ const { recordsCreated, errors } = await indexSessionOnAgentEnd(ctx, event);
49
+
50
+ if (errors.length > 0 && ctx.hasUI) {
51
+ // Log errors but don't flood UI
52
+ for (const err of errors.slice(0, 2)) {
53
+ console.error("[pi-memory-stone] indexing error:", err);
54
+ }
55
+ }
56
+
57
+ // Optionally notify on first indexing
58
+ if (recordsCreated > 0 && ctx.hasUI) {
59
+ // Silent by default; uncomment for verbose mode:
60
+ // ctx.ui.setStatus("memory-stone", `Indexed ${recordsCreated} records`);
61
+ }
62
+ } catch (err) {
63
+ console.error("[pi-memory-stone] agent_end handler error:", err);
64
+ }
65
+ });
66
+
67
+ // ── before_agent_start: inject relevant memories ───────────────
68
+
69
+ pi.on("before_agent_start", async (event, ctx) => {
70
+ try {
71
+ // Check if memory is enabled
72
+ const config = getConfig(ctx.cwd);
73
+ if (!config.enabled) return;
74
+
75
+ // Check for session toggle entries before honoring the cached toggle so
76
+ // /memory-on can re-enable injection after /memory-off in the same session.
77
+ for (const entry of ctx.sessionManager.getBranch()) {
78
+ if (
79
+ entry.type === "custom" &&
80
+ entry.customType === "memory-stone:session-toggle"
81
+ ) {
82
+ const data = entry.data as { enabled?: boolean } | undefined;
83
+ if (typeof data?.enabled === "boolean") {
84
+ sessionEnabled = data.enabled;
85
+ }
86
+ }
87
+ }
88
+
89
+ if (!sessionEnabled) return;
90
+
91
+ const prompt = event.prompt || "";
92
+ if (!prompt.trim()) return;
93
+
94
+ // Hash prompt to detect repeated injections
95
+ const promptHash = createHash("sha256").update(prompt).digest("hex").slice(0, 12);
96
+
97
+ const projectId = getProjectId(ctx.cwd);
98
+
99
+ // Build search query
100
+ const results = retrieve(prompt, projectId, [], {
101
+ limit: config.maxInjectedRecords,
102
+ crossProjectEnabled: config.crossProjectEnabled,
103
+ });
104
+
105
+ // Filter: skip already-injected refs
106
+ const newResults = results.filter((r) => !injectedRefsThisSession.has(r.record.id));
107
+
108
+ // Score threshold
109
+ const thresholdResults = newResults.filter((r) => r.score >= config.scoreThreshold);
110
+
111
+ if (thresholdResults.length === 0) return;
112
+
113
+ // Build and format injection packet
114
+ const packet = buildInjectionPacket(thresholdResults);
115
+ const formatted = formatInjectionForLlm(packet, config.maxInjectedTokens);
116
+
117
+ // Track injected refs (prevent feedback loop)
118
+ for (const r of thresholdResults) {
119
+ injectedRefsThisSession.add(r.record.id);
120
+ }
121
+
122
+ // Log injection to DB
123
+ const { insertInjection } = await import("./db/index.js");
124
+ insertInjection({
125
+ session_id: ctx.sessionManager.getSessionId(),
126
+ turn_entry_id: ctx.sessionManager.getLeafId() ?? undefined,
127
+ prompt_hash: promptHash,
128
+ injected_refs: thresholdResults.map((r) => r.record.id).join(","),
129
+ packet: formatted,
130
+ reasons: thresholdResults.map((r) => r.reasons.join(";")).join(" | "),
131
+ });
132
+
133
+ // Inject as a non-context audit custom entry (separate from LLM context)
134
+ // but also as a system prompt addition for the LLM
135
+ const systemPromptAddition = [
136
+ "",
137
+ "--- Memory Stone Context ---",
138
+ formatted,
139
+ "--- End Memory Stone Context ---",
140
+ ].join("\n");
141
+
142
+ return {
143
+ systemPrompt: (event.systemPrompt || "") + systemPromptAddition,
144
+ };
145
+ } catch (err) {
146
+ console.error("[pi-memory-stone] before_agent_start handler error:", err);
147
+ }
148
+ });
149
+
150
+ // ── session_start: restore state ───────────────────────────────
151
+
152
+ pi.on("session_start", async (_event, ctx) => {
153
+ try {
154
+ // Clear session-scoped state
155
+ injectedRefsThisSession.clear();
156
+ sessionEnabled = true;
157
+
158
+ // Restore session toggle from branch
159
+ for (const entry of ctx.sessionManager.getBranch()) {
160
+ if (
161
+ entry.type === "custom" &&
162
+ entry.customType === "memory-stone:session-toggle"
163
+ ) {
164
+ const data = entry.data as { enabled?: boolean } | undefined;
165
+ if (typeof data?.enabled === "boolean") {
166
+ sessionEnabled = data.enabled;
167
+ }
168
+ }
169
+ }
170
+
171
+ // Clear project ID cache on session change
172
+ clearProjectCache();
173
+ } catch (err) {
174
+ console.error("[pi-memory-stone] session_start handler error:", err);
175
+ }
176
+ });
177
+
178
+ // ── session_shutdown: cleanup ──────────────────────────────────
179
+
180
+ pi.on("session_shutdown", async (_event, _ctx) => {
181
+ try {
182
+ // Best-effort flush — DB is WAL mode so writes are already durable.
183
+ // Cleanup any pending state.
184
+ injectedRefsThisSession.clear();
185
+ } catch (err) {
186
+ console.error("[pi-memory-stone] session_shutdown handler error:", err);
187
+ }
188
+ });
189
+
190
+ // ── Cleanup when extension is unloaded ─────────────────────────
191
+
192
+ // Note: pi doesn't have an explicit unload hook, but session_shutdown
193
+ // fires before reload. For process exit, Node handles file descriptor cleanup.
194
+ process.on("exit", () => {
195
+ closeDb();
196
+ });
197
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Indexing engine: processes session entries on agent_end and stores structured records.
3
+ * Deterministic-only for MVP; LLM extraction deferred.
4
+ */
5
+
6
+ import { statSync } from "node:fs";
7
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ upsertSession,
10
+ upsertRecord,
11
+ insertFileActivity,
12
+ getIndexState,
13
+ upsertIndexState,
14
+ contentHash,
15
+ } from "../db/index.js";
16
+ import { parseEntries, turnsToRecords, type ParsedFileActivity } from "./parser.js";
17
+ import { getProjectId } from "../config/index.js";
18
+
19
+ // ─── Types for agent_end data ───────────────────────────────────────
20
+
21
+ type AgentEndEvent = unknown;
22
+
23
+ // ─── Index a session from agent_end ─────────────────────────────────
24
+
25
+ export async function indexSessionOnAgentEnd(
26
+ ctx: ExtensionContext,
27
+ _event: AgentEndEvent,
28
+ ): Promise<{ recordsCreated: number; errors: string[] }> {
29
+ const errors: string[] = [];
30
+ let recordsCreated = 0;
31
+
32
+ try {
33
+ const sessionManager = ctx.sessionManager;
34
+ const sessionFile = sessionManager.getSessionFile();
35
+
36
+ // Non-persisted sessions: skip indexing (plan: do not index ephemeral)
37
+ if (!sessionFile) {
38
+ return { recordsCreated: 0, errors: [] };
39
+ }
40
+
41
+ const sessionId = sessionManager.getSessionId();
42
+ const projectId = getProjectId(ctx.cwd);
43
+ const cwd = ctx.cwd;
44
+ const branch = sessionManager.getBranch() as unknown as Parameters<typeof parseEntries>[0];
45
+ const leafId = sessionManager.getLeafId();
46
+
47
+ // Get file info
48
+ let fileMtime: number | null = null;
49
+ let fileSize: number | null = null;
50
+ try {
51
+ const stat = statSync(sessionFile);
52
+ fileMtime = stat.mtimeMs;
53
+ fileSize = stat.size;
54
+ } catch {
55
+ // File may not exist (in-memory session), that's fine
56
+ }
57
+
58
+ // Check index state
59
+ const prevState = getIndexState(sessionFile);
60
+ const lastIndexedId = prevState?.last_indexed_entry_id ?? null;
61
+
62
+ // Find new entries since last index. Prefer timestamps so this stays correct
63
+ // even if SessionManager branch ordering changes; fall back to root-to-leaf slicing.
64
+ const lastIndexedTimestamp = prevState?.last_indexed_entry_timestamp
65
+ ? Date.parse(prevState.last_indexed_entry_timestamp)
66
+ : NaN;
67
+ const lastIndexedIndex = lastIndexedId
68
+ ? branch.findIndex((entry) => entry.id === lastIndexedId)
69
+ : -1;
70
+ let newEntries = branch;
71
+ if (lastIndexedId) {
72
+ if (Number.isFinite(lastIndexedTimestamp)) {
73
+ const timestampEntries = branch.filter((entry) => {
74
+ const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
75
+ return Number.isFinite(ts) && ts > lastIndexedTimestamp;
76
+ });
77
+ newEntries = timestampEntries.length > 0 || lastIndexedIndex < 0
78
+ ? timestampEntries
79
+ : branch.slice(lastIndexedIndex + 1);
80
+ } else {
81
+ newEntries = lastIndexedIndex >= 0 ? branch.slice(lastIndexedIndex + 1) : branch;
82
+ }
83
+ }
84
+
85
+ if (newEntries.length === 0) {
86
+ // Update session metadata even if no new entries
87
+ upsertSession({
88
+ id: sessionId,
89
+ session_file: sessionFile,
90
+ cwd,
91
+ project_id: projectId,
92
+ file_mtime: fileMtime,
93
+ file_size: fileSize,
94
+ });
95
+ upsertIndexState({
96
+ session_file: sessionFile,
97
+ session_id: sessionId,
98
+ last_indexed_entry_id: prevState?.last_indexed_entry_id ?? undefined,
99
+ last_indexed_entry_timestamp: new Date().toISOString(),
100
+ file_mtime: fileMtime,
101
+ file_size: fileSize,
102
+ branch_leaf_id: leafId ?? undefined,
103
+ });
104
+ return { recordsCreated: 0, errors: [] };
105
+ }
106
+
107
+ // Parse new entries
108
+ const { turns, fileActivities } = parseEntries(newEntries);
109
+
110
+ // Convert to records
111
+ const recordPayloads = turnsToRecords(turns, projectId, sessionId, sessionFile);
112
+
113
+ // Upsert session
114
+ upsertSession({
115
+ id: sessionId,
116
+ session_file: sessionFile,
117
+ cwd,
118
+ project_id: projectId,
119
+ file_mtime: fileMtime,
120
+ file_size: fileSize,
121
+ });
122
+
123
+ // Store records
124
+ for (const payload of recordPayloads) {
125
+ try {
126
+ const recordId = upsertRecord({
127
+ kind: payload.kind,
128
+ scope: payload.scope,
129
+ project_id: projectId,
130
+ session_id: sessionId,
131
+ session_file: sessionFile,
132
+ branch_leaf_id: leafId ?? undefined,
133
+ entry_id_start: payload.entryIdStart,
134
+ entry_id_end: payload.entryIdEnd,
135
+ text: payload.text,
136
+ tags: payload.tags,
137
+ });
138
+ recordsCreated++;
139
+ } catch (err) {
140
+ errors.push(`Failed to store record: ${err instanceof Error ? err.message : String(err)}`);
141
+ }
142
+ }
143
+
144
+ // Store file activity
145
+ for (const fa of fileActivities) {
146
+ try {
147
+ insertFileActivity({
148
+ record_id: contentHash(
149
+ `file:${fa.path}:${fa.action}`,
150
+ "file_activity",
151
+ ),
152
+ project_id: projectId,
153
+ path: fa.path,
154
+ action: fa.action,
155
+ entry_id: fa.entryId,
156
+ });
157
+ } catch (err) {
158
+ // Non-critical: file activity insertion failure is logged but not fatal
159
+ errors.push(`Failed to store file activity: ${err instanceof Error ? err.message : String(err)}`);
160
+ }
161
+ }
162
+
163
+ // Update index state
164
+ const lastEntry = newEntries.reduce((latest, entry) => {
165
+ const latestTs = latest?.timestamp ? Date.parse(latest.timestamp) : NaN;
166
+ const entryTs = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
167
+ if (!Number.isFinite(latestTs) && !Number.isFinite(entryTs)) return entry;
168
+ if (!Number.isFinite(latestTs)) return entry;
169
+ if (!Number.isFinite(entryTs)) return latest;
170
+ return entryTs > latestTs ? entry : latest;
171
+ }, newEntries[0]);
172
+ upsertIndexState({
173
+ session_file: sessionFile,
174
+ session_id: sessionId,
175
+ last_indexed_entry_id: lastEntry?.id ?? prevState?.last_indexed_entry_id ?? undefined,
176
+ last_indexed_entry_timestamp: lastEntry?.timestamp ?? new Date().toISOString(),
177
+ file_mtime: fileMtime,
178
+ file_size: fileSize,
179
+ branch_leaf_id: leafId ?? undefined,
180
+ });
181
+
182
+ } catch (err) {
183
+ errors.push(`Indexing failed: ${err instanceof Error ? err.message : String(err)}`);
184
+ }
185
+
186
+ return { recordsCreated, errors };
187
+ }
188
+
189
+ // ─── Backfill (for /memory-backfill command) ────────────────────────
190
+
191
+ export async function backfillSession(
192
+ sessionFile: string,
193
+ ctx: ExtensionContext,
194
+ ): Promise<{ recordsCreated: number; errors: string[] }> {
195
+ const errors: string[] = [];
196
+ let recordsCreated = 0;
197
+
198
+ try {
199
+ // For backfill we need raw JSONL access. We use SessionManager.open for that.
200
+ // But in the MVP, we process via agent_end. Backfill is deferred.
201
+ // This is a placeholder for future implementation.
202
+ } catch (err) {
203
+ errors.push(`Backfill failed: ${err instanceof Error ? err.message : String(err)}`);
204
+ }
205
+
206
+ return { recordsCreated, errors };
207
+ }