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 +21 -0
- package/README.md +138 -0
- package/compression-agent.ts +292 -0
- package/config.ts +63 -0
- package/context-injection.ts +92 -0
- package/index.ts +463 -0
- package/mode-config.ts +159 -0
- package/observation-store.ts +817 -0
- package/observer-agent.ts +266 -0
- package/observer.ts +11 -0
- package/package.json +37 -0
- package/privacy.ts +69 -0
- package/project.ts +64 -0
- package/tools.ts +258 -0
- package/xml-parser.ts +133 -0
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
|
+
}
|