pi-mem 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 George Bashi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # pi-mem
2
+
3
+ Persistent memory extension for [pi](https://github.com/badlogic/pi-mono). Automatically captures what pi does during sessions, compresses observations into searchable memories, and injects relevant context into future sessions.
4
+
5
+ ## Features
6
+
7
+ - **Automatic observation capture** — hooks into `tool_result` events to record tool executions
8
+ - **LLM-powered observation extraction** — extracts structured facts, narrative, concepts, and file references from tool output
9
+ - **Session summaries** — compresses observations into searchable memories using checkpoint summarization
10
+ - **Vector + full-text search** — LanceDB-backed semantic and keyword search across all memories
11
+ - **Context injection** — automatically loads relevant past memories at session start
12
+ - **Memory tools** — `search`, `timeline`, `get_observations`, and `save_memory` tools for the LLM
13
+ - **Privacy controls** — `<private>` tags to exclude sensitive content
14
+ - **Project awareness** — scopes memories per project (from git remote), supports cross-project search
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pi install npm:pi-mem
20
+ ```
21
+
22
+ Or to try without installing:
23
+
24
+ ```bash
25
+ pi -e npm:pi-mem
26
+ ```
27
+
28
+ ## Configuration
29
+
30
+ Create `~/.pi/agent/pi-mem.json` or `~/.pi-mem/config.json` (optional — all settings have sensible defaults):
31
+
32
+ ```json
33
+ {
34
+ "enabled": true,
35
+ "autoInject": true,
36
+ "maxObservationLength": 4000,
37
+ "summaryModel": "anthropic/claude-haiku-3",
38
+ "indexSize": 10,
39
+ "tokenBudget": 2000,
40
+ "embeddingProvider": "openai",
41
+ "embeddingModel": "text-embedding-3-small",
42
+ "embeddingDims": 1536
43
+ }
44
+ ```
45
+
46
+ | Setting | Default | Description |
47
+ |---------|---------|-------------|
48
+ | `enabled` | `true` | Enable/disable the extension |
49
+ | `autoInject` | `true` | Automatically inject past memories at session start |
50
+ | `maxObservationLength` | `4000` | Max characters per tool output observation |
51
+ | `summaryModel` | (current model) | Model to use for session summarization |
52
+ | `observerModel` | (falls back to summaryModel) | Model for per-tool observation extraction |
53
+ | `thinkingLevel` | (current level) | Thinking level for LLM calls |
54
+ | `indexSize` | `10` | Max entries in the project memory index |
55
+ | `tokenBudget` | `2000` | Max tokens for injected context |
56
+ | `embeddingProvider` | (none) | Pi provider name for embeddings. Must support OpenAI-compatible `/v1/embeddings` |
57
+ | `embeddingModel` | `text-embedding-3-small` | Embedding model name |
58
+ | `embeddingDims` | `1536` | Embedding vector dimensions (must match the model) |
59
+
60
+ ### Embedding Setup
61
+
62
+ For vector/semantic search, configure an embedding provider. The provider must support the OpenAI-compatible `/v1/embeddings` endpoint. Add the provider name from your `~/.pi/agent/models.json`:
63
+
64
+ ```json
65
+ {
66
+ "embeddingProvider": "openai",
67
+ "embeddingModel": "text-embedding-3-small",
68
+ "embeddingDims": 1536
69
+ }
70
+ ```
71
+
72
+ Without an embedding provider, full-text search still works.
73
+
74
+ ## Data Storage
75
+
76
+ All data is stored in `~/.pi-mem/`:
77
+
78
+ ```
79
+ ~/.pi-mem/
80
+ ├── lancedb/ # Observation store (LanceDB)
81
+ └── config.json # User preferences (optional)
82
+ ```
83
+
84
+ ## Commands
85
+
86
+ - `/mem` — Show current memory status (project, observation count, vector DB status)
87
+
88
+ ## Tools (available to the LLM)
89
+
90
+ ### search
91
+
92
+ Search past observations and summaries with full-text search:
93
+
94
+ ```
95
+ search({ query: "authentication flow" })
96
+ search({ query: "authentication", project: "my-app", limit: 5 })
97
+ ```
98
+
99
+ ### timeline
100
+
101
+ Get chronological context around a specific observation:
102
+
103
+ ```
104
+ timeline({ anchor: "abc12345" })
105
+ timeline({ query: "auth bug", depth_before: 5, depth_after: 5 })
106
+ ```
107
+
108
+ ### get_observations
109
+
110
+ Fetch full details for specific observation IDs:
111
+
112
+ ```
113
+ get_observations({ ids: ["abc12345", "def67890"] })
114
+ ```
115
+
116
+ ### save_memory
117
+
118
+ Explicitly save important information:
119
+
120
+ ```
121
+ save_memory({
122
+ text: "Decided to use PostgreSQL for ACID transactions",
123
+ title: "Database choice",
124
+ concepts: ["decision", "architecture"]
125
+ })
126
+ ```
127
+
128
+ ## Privacy
129
+
130
+ Wrap sensitive content in `<private>` tags in tool output — it will be stripped before observation:
131
+
132
+ ```
133
+ API key is <private>sk-abc123</private>
134
+ ```
135
+
136
+ ## License
137
+
138
+ MIT
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Compression agent for pi-mem.
3
+ * Spawns a headless pi sub-agent to compress observations into structured summaries.
4
+ */
5
+
6
+ import { spawn, type ChildProcess } from "node:child_process";
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import * as os from "node:os";
10
+ import type { PiMemConfig } from "./config.js";
11
+
12
+ /** Observation shape as passed from index.ts to the summarizer */
13
+ export interface Observation {
14
+ timestamp: string;
15
+ toolName: string;
16
+ input: Record<string, unknown>;
17
+ output: string;
18
+ cwd: string;
19
+ }
20
+
21
+ const DEBUG_LOG_PATH = path.join(os.homedir(), ".pi-mem", "debug-summarize.log");
22
+
23
+ function debugLog(msg: string) {
24
+ try { fs.appendFileSync(DEBUG_LOG_PATH, `[${new Date().toISOString()}] ${msg}\n`); } catch {}
25
+ }
26
+
27
+ export interface SessionSummary {
28
+ request: string;
29
+ investigated: string;
30
+ learned: string;
31
+ completed: string;
32
+ nextSteps: string;
33
+ filesRead: string[];
34
+ filesModified: string[];
35
+ concepts: string[];
36
+ }
37
+
38
+ export interface SummarizeContext {
39
+ /** Current session model */
40
+ model: any;
41
+ /** Current session thinking level */
42
+ thinkingLevel: string;
43
+ /** Pre-collected file paths (overrides LLM extraction) */
44
+ filesRead?: string[];
45
+ /** Pre-collected file paths (overrides LLM extraction) */
46
+ filesModified?: string[];
47
+ }
48
+
49
+ function killProcess(proc: ChildProcess): void {
50
+ try { proc.kill("SIGTERM"); } catch {}
51
+ setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 2000);
52
+ }
53
+
54
+ /**
55
+ * Run a pi sub-agent and return the response text.
56
+ */
57
+ function runSubAgent(
58
+ prompt: string,
59
+ systemPrompt: string,
60
+ model: string,
61
+ thinkingLevel: string,
62
+ ): Promise<{ ok: true; response: string } | { ok: false; error: string }> {
63
+ return new Promise((resolve) => {
64
+ const proc = spawn("pi", [
65
+ "--mode", "json",
66
+ "-p",
67
+ "--no-session",
68
+ "--no-tools",
69
+ "--system-prompt", systemPrompt,
70
+ "--model", model,
71
+ "--thinking", thinkingLevel,
72
+ prompt,
73
+ ], {
74
+ stdio: ["ignore", "pipe", "pipe"],
75
+ env: { ...process.env, PI_MEM_SUB_AGENT: "1" },
76
+ });
77
+
78
+ let buffer = "";
79
+ let lastAssistantText = "";
80
+ let stderr = "";
81
+
82
+ const timeout = setTimeout(() => {
83
+ killProcess(proc);
84
+ resolve({ ok: false, error: "Summarization timeout (30s)" });
85
+ }, 30_000);
86
+
87
+ const processLine = (line: string) => {
88
+ if (!line.trim()) return;
89
+ try {
90
+ const event = JSON.parse(line);
91
+ if (event.type === "message_end" && event.message?.role === "assistant") {
92
+ for (const part of event.message.content) {
93
+ if (part.type === "text") {
94
+ lastAssistantText = part.text;
95
+ }
96
+ }
97
+ }
98
+ } catch {
99
+ // ignore non-JSON lines
100
+ }
101
+ };
102
+
103
+ proc.stdout!.on("data", (data: Buffer) => {
104
+ buffer += data.toString();
105
+ const lines = buffer.split("\n");
106
+ buffer = lines.pop() || "";
107
+ for (const line of lines) processLine(line);
108
+ });
109
+
110
+ proc.stderr!.on("data", (data: Buffer) => {
111
+ stderr += data.toString();
112
+ });
113
+
114
+ proc.on("close", (code) => {
115
+ clearTimeout(timeout);
116
+ if (buffer.trim()) processLine(buffer);
117
+
118
+ if (lastAssistantText) {
119
+ resolve({ ok: true, response: lastAssistantText });
120
+ } else if (code !== 0) {
121
+ resolve({ ok: false, error: `Sub-agent failed (exit ${code}): ${stderr.trim().slice(0, 500) || "(no output)"}` });
122
+ } else {
123
+ resolve({ ok: false, error: "Sub-agent returned no response" });
124
+ }
125
+ });
126
+
127
+ proc.on("error", (err) => {
128
+ clearTimeout(timeout);
129
+ resolve({ ok: false, error: `Failed to spawn pi: ${err.message}` });
130
+ });
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Summarize observations using an LLM compression agent.
136
+ * Falls back to raw observation extraction on failure.
137
+ */
138
+ export async function summarize(
139
+ observations: Observation[],
140
+ config: PiMemConfig,
141
+ context: SummarizeContext,
142
+ ): Promise<SessionSummary> {
143
+ // Resolve model: config override → current session model
144
+ const model = config.summaryModel
145
+ || (context.model ? `${context.model.provider}/${context.model.id}` : undefined);
146
+
147
+ // Resolve thinking level: config override → current session thinking level
148
+ const thinkingLevel = config.thinkingLevel || context.thinkingLevel || "medium";
149
+
150
+ if (!model) {
151
+ debugLog("No model available, using fallback");
152
+ const summary = extractFallbackSummary(observations);
153
+ // Override with pre-collected files even for fallback
154
+ if (context.filesRead) summary.filesRead = context.filesRead;
155
+ if (context.filesModified) summary.filesModified = context.filesModified;
156
+ return summary;
157
+ }
158
+
159
+ // Format observations into prompt — use structured titles and narratives
160
+ // (already LLM-compressed by observer agent, no need to truncate)
161
+ const obsText = observations.map((obs, i) => {
162
+ return `### Observation ${i + 1}: ${obs.toolName} [${obs.timestamp}]
163
+ Title: ${JSON.stringify(obs.input).includes("summary") ? (obs.input as any).summary : obs.toolName}
164
+ Content: ${obs.output}`;
165
+ }).join("\n\n");
166
+
167
+ const prompt = `Compress the following coding session observations into a structured summary.
168
+
169
+ ${obsText}
170
+
171
+ Respond with a structured markdown summary using EXACTLY these section headers:
172
+ ## Request
173
+ ## What Was Investigated
174
+ ## What Was Learned
175
+ ## What Was Completed
176
+ ## Next Steps
177
+ ## Files
178
+ ## Concepts`;
179
+
180
+ debugLog(`--- Starting summarization (${observations.length} obs, model: ${model}, thinking: ${thinkingLevel}) ---`);
181
+
182
+ const result = await runSubAgent(prompt, COMPRESSION_SYSTEM_PROMPT, model, thinkingLevel);
183
+
184
+ let summary: SessionSummary;
185
+ if (result.ok) {
186
+ debugLog(`Summarization succeeded. Response length: ${result.response.length}`);
187
+ summary = parseSummaryResponse(result.response, observations);
188
+ } else {
189
+ debugLog(`Summarization failed: ${result.error}`);
190
+ summary = extractFallbackSummary(observations);
191
+ }
192
+
193
+ // Override LLM file extraction with deterministic pre-collected files
194
+ if (context.filesRead) summary.filesRead = context.filesRead;
195
+ if (context.filesModified) summary.filesModified = context.filesModified;
196
+
197
+ return summary;
198
+ }
199
+
200
+ const COMPRESSION_SYSTEM_PROMPT = `You are a memory compression agent. You observe tool executions from a coding session and produce structured summaries.
201
+
202
+ Your job is to distill raw tool observations into concise, meaningful memory entries.
203
+
204
+ Focus on:
205
+ - What was BUILT, FIXED, or LEARNED — not what the observer is doing
206
+ - Use action verbs: implemented, fixed, deployed, configured, migrated
207
+ - Extract key decisions, patterns, and discoveries
208
+ - List all files touched with their read/modified status
209
+ - Tag with relevant concepts from: bugfix, feature, refactor, discovery, how-it-works, problem-solution, architecture, configuration, testing, deployment, performance, security
210
+
211
+ Skip:
212
+ - Routine operations (empty status checks, simple file listings, package installs)
213
+ - Verbose tool output details
214
+ - Step-by-step narration of what was observed
215
+
216
+ Output format: structured markdown with these exact section headers:
217
+ ## Request
218
+ ## What Was Investigated
219
+ ## What Was Learned
220
+ ## What Was Completed
221
+ ## Next Steps
222
+ ## Files
223
+ ## Concepts`;
224
+
225
+ /**
226
+ * Parse the LLM response into a SessionSummary.
227
+ */
228
+ export function parseSummaryResponse(response: string, observations: Observation[]): SessionSummary {
229
+ const sections: Record<string, string> = {};
230
+ let currentSection = "";
231
+
232
+ for (const line of response.split("\n")) {
233
+ const headerMatch = line.match(/^##\s+(.+)/);
234
+ if (headerMatch) {
235
+ currentSection = headerMatch[1].trim().toLowerCase();
236
+ sections[currentSection] = "";
237
+ } else if (currentSection) {
238
+ sections[currentSection] = (sections[currentSection] + "\n" + line).trim();
239
+ }
240
+ }
241
+
242
+ // Extract files
243
+ const filesText = sections["files"] || "";
244
+ const filesRead: string[] = [];
245
+ const filesModified: string[] = [];
246
+
247
+ for (const line of filesText.split("\n")) {
248
+ const readMatch = line.match(/\*\*Read:\*\*\s*(.+)/i);
249
+ const modMatch = line.match(/\*\*Modified:\*\*\s*(.+)/i);
250
+ if (readMatch) filesRead.push(...readMatch[1].split(",").map((f) => f.trim()).filter(Boolean));
251
+ if (modMatch) filesModified.push(...modMatch[1].split(",").map((f) => f.trim()).filter(Boolean));
252
+ }
253
+
254
+ // Extract concepts
255
+ const conceptsText = sections["concepts"] || "";
256
+ const concepts = conceptsText.split(/[,\n]/).map((c) => c.trim().replace(/^-\s*/, "")).filter(Boolean);
257
+
258
+ return {
259
+ request: sections["request"] || "Unknown request",
260
+ investigated: sections["what was investigated"] || "",
261
+ learned: sections["what was learned"] || "",
262
+ completed: sections["what was completed"] || "",
263
+ nextSteps: sections["next steps"] || "",
264
+ filesRead,
265
+ filesModified,
266
+ concepts,
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Extract a basic summary from observations without LLM help.
272
+ * Uses structured fields (title) from observer-extracted observations.
273
+ */
274
+ function extractFallbackSummary(observations: Observation[]): SessionSummary {
275
+ const toolNames = [...new Set(observations.map((o) => o.toolName))];
276
+ const titles = observations
277
+ .map((o) => (o.input as any).summary || o.toolName)
278
+ .filter(Boolean);
279
+
280
+ return {
281
+ request: "Session with tools: " + toolNames.join(", "),
282
+ investigated: titles.length > 0
283
+ ? titles.slice(0, 10).join("; ")
284
+ : `Used tools: ${toolNames.join(", ")} across ${observations.length} operations`,
285
+ learned: "",
286
+ completed: "",
287
+ nextSteps: "",
288
+ filesRead: [],
289
+ filesModified: [],
290
+ concepts: [],
291
+ };
292
+ }
package/config.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Configuration management for pi-mem.
3
+ * Loads from ~/.pi/agent/pi-mem.json with fallback to ~/.pi-mem/config.json.
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import * as os from "node:os";
9
+
10
+ export interface PiMemConfig {
11
+ enabled: boolean;
12
+ autoInject: boolean;
13
+ maxObservationLength: number;
14
+ /** Model for observation extraction (e.g. "provider/model-id"). Falls back to summaryModel → session model. */
15
+ observerModel?: string;
16
+ /** Model for summarization (e.g. "provider/model-id"). Defaults to the current session model. */
17
+ summaryModel?: string;
18
+ /** Thinking level for summarization (e.g. "medium"). Defaults to current session thinking level. */
19
+ thinkingLevel?: string;
20
+ indexSize: number;
21
+ tokenBudget: number;
22
+ /** Pi provider to use for embeddings (e.g. "openai"). Must support OpenAI-compatible /v1/embeddings. */
23
+ embeddingProvider?: string;
24
+ /** Embedding model name (default: "text-embedding-3-small"). */
25
+ embeddingModel?: string;
26
+ /** Embedding vector dimensions (default: 1536). Must match the model's output dimensions. */
27
+ embeddingDims?: number;
28
+ }
29
+
30
+ const DEFAULTS: PiMemConfig = {
31
+ enabled: true,
32
+ autoInject: true,
33
+ maxObservationLength: 4000,
34
+ indexSize: 10,
35
+ tokenBudget: 2000,
36
+ };
37
+
38
+ export const PI_MEM_DIR = path.join(os.homedir(), ".pi-mem");
39
+
40
+ const CONFIG_PATHS = [
41
+ path.join(os.homedir(), ".pi", "agent", "pi-mem.json"),
42
+ path.join(PI_MEM_DIR, "config.json"),
43
+ ];
44
+
45
+ export function loadConfig(): PiMemConfig {
46
+ for (const configPath of CONFIG_PATHS) {
47
+ try {
48
+ if (fs.existsSync(configPath)) {
49
+ const raw = fs.readFileSync(configPath, "utf-8");
50
+ const userConfig = JSON.parse(raw);
51
+ // Support both "model" and "summaryModel" keys
52
+ if (userConfig.model && !userConfig.summaryModel) {
53
+ userConfig.summaryModel = userConfig.model;
54
+ }
55
+ return { ...DEFAULTS, ...userConfig };
56
+ }
57
+ } catch {
58
+ // Ignore parse errors, try next
59
+ }
60
+ }
61
+
62
+ return { ...DEFAULTS };
63
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Context injection for pi-mem.
3
+ * Queries LanceDB for recent summaries, prompt-aware semantic search,
4
+ * and injects 3-layer workflow guidance.
5
+ */
6
+
7
+ import type { PiMemConfig } from "./config.js";
8
+ import {
9
+ getRecentSummaries,
10
+ semanticSearch,
11
+ type ObservationStore,
12
+ } from "./observation-store.js";
13
+
14
+ /**
15
+ * Estimate token count from text (~4 chars per token).
16
+ */
17
+ function estimateTokens(text: string): number {
18
+ return Math.ceil(text.length / 4);
19
+ }
20
+
21
+ const WORKFLOW_GUIDANCE = `### Memory Search Tools
22
+
23
+ 3-LAYER WORKFLOW (ALWAYS FOLLOW):
24
+ 1. search(query) → Get index with IDs (~50-100 tokens/result)
25
+ 2. timeline(anchor=ID) → Get context around interesting results
26
+ 3. get_observations([IDs]) → Fetch full details ONLY for filtered IDs
27
+ NEVER fetch full details without filtering first. 10x token savings.`;
28
+
29
+ /**
30
+ * Build the injected context for before_agent_start.
31
+ * Returns null if no context is available.
32
+ */
33
+ export async function buildInjectedContext(
34
+ store: ObservationStore | null,
35
+ projectSlug: string,
36
+ config: PiMemConfig,
37
+ userPrompt?: string,
38
+ ): Promise<string | null> {
39
+ if (!config.autoInject) return null;
40
+
41
+ let budget = config.tokenBudget;
42
+ const parts: string[] = [];
43
+
44
+ // 1. Recent summaries index (highest priority)
45
+ if (store?.available) {
46
+ try {
47
+ const summaries = await getRecentSummaries(store, projectSlug, config.indexSize);
48
+ if (summaries.length > 0) {
49
+ const indexSection =
50
+ `## Project Memory (${projectSlug})\n\n` +
51
+ summaries
52
+ .map((s) => `- ${s.timestamp.slice(0, 10)} [${s.session_id}]: ${s.title}`)
53
+ .join("\n");
54
+ const tokens = estimateTokens(indexSection);
55
+ if (tokens <= budget) {
56
+ parts.push(indexSection);
57
+ budget -= tokens;
58
+ }
59
+ }
60
+ } catch {
61
+ // Graceful degradation
62
+ }
63
+ }
64
+
65
+ // 2. Prompt-aware semantic search results (if available)
66
+ if (store?.available && store.embed && userPrompt && budget > 200) {
67
+ try {
68
+ const results = await semanticSearch(store, userPrompt, projectSlug, 2);
69
+ for (const result of results) {
70
+ const maxChars = budget * 4;
71
+ const snippet = `### Relevant: ${result.timestamp.slice(0, 10)} [${result.session_id}]\n${result.narrative.slice(0, maxChars)}`;
72
+ const tokens = estimateTokens(snippet);
73
+ if (tokens > budget) break;
74
+ parts.push(snippet);
75
+ budget -= tokens;
76
+ }
77
+ } catch {
78
+ // Graceful degradation
79
+ }
80
+ }
81
+
82
+ // 3. Workflow guidance (always included if there's budget)
83
+ const guidanceTokens = estimateTokens(WORKFLOW_GUIDANCE);
84
+ if (guidanceTokens <= budget) {
85
+ parts.push(WORKFLOW_GUIDANCE);
86
+ budget -= guidanceTokens;
87
+ }
88
+
89
+ if (parts.length === 0) return null;
90
+
91
+ return parts.join("\n\n");
92
+ }