pi-hermes-memory 0.1.0 → 0.2.1

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/src/index.ts CHANGED
@@ -7,48 +7,101 @@
7
7
  * 1. Persistent Memory — MEMORY.md + USER.md that survive across sessions
8
8
  * 2. Background Learning Loop — auto-saves notable facts every N turns
9
9
  * 3. Session-End Flush — saves memories before compaction/shutdown
10
- * 4. /memory-insightsshows what's stored
10
+ * 4. Auto-Consolidationmerges memory when full instead of erroring
11
+ * 5. Correction Detection — immediate save on user corrections
12
+ * 6. Procedural Skills — SKILL.md files for reusable procedures
13
+ * 7. Tool-Call-Aware Nudge — review triggers on tool call count too
14
+ * 8. /memory-insights — shows what's stored
15
+ * 9. /memory-skills — lists procedural skills
16
+ * 10. /memory-consolidate — manual consolidation trigger
11
17
  *
12
- * See PLAN.md for full architecture and Hermes source references.
18
+ * See docs/ROADMAP.md for full roadmap and Hermes competitive analysis.
13
19
  */
14
20
 
21
+ import * as path from "node:path";
22
+ import * as os from "node:os";
15
23
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
16
24
  import { MemoryStore } from "./store/memory-store.js";
25
+ import { SkillStore } from "./store/skill-store.js";
17
26
  import { registerMemoryTool } from "./tools/memory-tool.js";
27
+ import { registerSkillTool } from "./tools/skill-tool.js";
18
28
  import { setupBackgroundReview } from "./handlers/background-review.js";
19
29
  import { setupSessionFlush } from "./handlers/session-flush.js";
20
30
  import { registerInsightsCommand } from "./handlers/insights.js";
31
+ import { triggerConsolidation, registerConsolidateCommand } from "./handlers/auto-consolidate.js";
32
+ import { setupCorrectionDetector } from "./handlers/correction-detector.js";
33
+ import { setupSkillAutoTrigger } from "./handlers/skill-auto-trigger.js";
34
+ import { registerSkillsCommand } from "./handlers/skills-command.js";
21
35
  import { loadConfig } from "./config.js";
22
36
 
23
37
  export default function (pi: ExtensionAPI) {
24
38
  const config = loadConfig();
25
39
 
40
+ const globalDir = config.memoryDir ?? path.join(os.homedir(), ".pi", "agent", "memory");
26
41
  const store = new MemoryStore(config);
42
+ const skillStore = new SkillStore(path.join(globalDir, "skills"));
43
+
44
+ // Detect project name from cwd — skip if running from home directory
45
+ const cwd = process.cwd();
46
+ const homeDir = os.homedir();
47
+ const projectName = path.basename(cwd);
48
+ const hasProject = cwd !== homeDir;
49
+
50
+ // Project-scoped store: ~/.pi/agent/<project_name>/
51
+ // Uses memoryCharLimit overridden to projectCharLimit for the "memory" target
52
+ const projectDir = hasProject ? path.join(homeDir, ".pi", "agent", projectName) : null;
53
+ const projectConfig = { ...config, memoryCharLimit: config.projectCharLimit, memoryDir: projectDir ?? undefined };
54
+ const projectStore = hasProject ? new MemoryStore(projectConfig) : null;
27
55
 
28
56
  // ── 1. Load memory from disk on session start ──
29
57
  pi.on("session_start", async (_event, _ctx) => {
30
58
  await store.loadFromDisk();
59
+ if (projectStore) await projectStore.loadFromDisk();
31
60
  });
32
61
 
33
- // ── 2. Inject frozen snapshot into system prompt ──
62
+ // ── 2. Inject frozen snapshot + skill index + project memory into system prompt ──
34
63
  pi.on("before_agent_start", async (event, _ctx) => {
35
64
  const memoryBlock = store.formatForSystemPrompt();
36
- if (memoryBlock) {
65
+ const skillIndex = await skillStore.formatIndexForSystemPrompt();
66
+ const projectBlock = projectStore ? projectStore.formatProjectBlock(projectName) : "";
67
+
68
+ const parts: string[] = [];
69
+ if (memoryBlock) parts.push(memoryBlock);
70
+ if (projectBlock) parts.push(projectBlock);
71
+ if (skillIndex) parts.push(skillIndex);
72
+
73
+ if (parts.length > 0) {
37
74
  return {
38
- systemPrompt: event.systemPrompt + "\n\n" + memoryBlock,
75
+ systemPrompt: event.systemPrompt + "\n\n" + parts.join("\n\n"),
39
76
  };
40
77
  }
41
78
  });
42
79
 
43
- // ── 3. Register the memory tool ──
44
- registerMemoryTool(pi, store);
80
+ // ── 3. Register the memory tool (with project store) ──
81
+ registerMemoryTool(pi, store, projectStore);
82
+
83
+ // ── 4. Register the skill tool ──
84
+ registerSkillTool(pi, skillStore);
85
+
86
+ // ── 5. Setup background learning loop (with tool-call-aware nudge) ──
87
+ setupBackgroundReview(pi, store, projectStore, config);
88
+
89
+ // ── 6. Setup session-end flush ──
90
+ setupSessionFlush(pi, store, projectStore, config);
91
+
92
+ // ── 7. Setup auto-consolidation (inject consolidator into store) ──
93
+ store.setConsolidator(async (target, signal) => {
94
+ return triggerConsolidation(pi, store, target, signal);
95
+ });
96
+ registerConsolidateCommand(pi, store);
45
97
 
46
- // ── 4. Setup background learning loop ──
47
- setupBackgroundReview(pi, store, config);
98
+ // ── 8. Setup correction detection ──
99
+ setupCorrectionDetector(pi, store, projectStore, config);
48
100
 
49
- // ── 5. Setup session-end flush ──
50
- setupSessionFlush(pi, store, config);
101
+ // ── 9. Setup skill auto-trigger ──
102
+ setupSkillAutoTrigger(pi, store, skillStore, config);
51
103
 
52
- // ── 6. Register insights command ──
53
- registerInsightsCommand(pi, store);
104
+ // ── 10. Register commands ──
105
+ registerInsightsCommand(pi, store, projectStore, projectName);
106
+ registerSkillsCommand(pi, skillStore);
54
107
  }
@@ -22,15 +22,24 @@ import {
22
22
  MEMORY_FILE,
23
23
  USER_FILE,
24
24
  } from "../constants.js";
25
- import type { MemoryConfig, MemoryResult, MemorySnapshot } from "../types.js";
25
+ import type { MemoryConfig, MemoryResult, MemorySnapshot, ConsolidationResult } from "../types.js";
26
26
 
27
27
  export class MemoryStore {
28
28
  private memoryEntries: string[] = [];
29
29
  private userEntries: string[] = [];
30
30
  private snapshot: MemorySnapshot = { memory: "", user: "" };
31
+ private consolidator: ((target: "memory" | "user", signal?: AbortSignal) => Promise<ConsolidationResult>) | null = null;
31
32
 
32
33
  constructor(private config: MemoryConfig) {}
33
34
 
35
+ /**
36
+ * Inject a consolidation function (avoids circular imports).
37
+ * Called from index.ts after both store and pi are available.
38
+ */
39
+ setConsolidator(fn: (target: "memory" | "user", signal?: AbortSignal) => Promise<ConsolidationResult>): void {
40
+ this.consolidator = fn;
41
+ }
42
+
34
43
  // ─── Path helpers ───
35
44
 
36
45
  private get memoryDir(): string {
@@ -79,7 +88,7 @@ export class MemoryStore {
79
88
 
80
89
  // ─── CRUD ───
81
90
 
82
- add(target: "memory" | "user", content: string): MemoryResult {
91
+ async add(target: "memory" | "user", content: string, signal?: AbortSignal): Promise<MemoryResult> {
83
92
  content = content.trim();
84
93
  if (!content) return { success: false, error: "Content cannot be empty." };
85
94
 
@@ -95,6 +104,32 @@ export class MemoryStore {
95
104
 
96
105
  const newTotal = [...entries, content].join(ENTRY_DELIMITER).length;
97
106
  if (newTotal > limit) {
107
+ // Auto-consolidate if configured and consolidator available
108
+ if (this.config.autoConsolidate && this.consolidator) {
109
+ // Track consolidation attempts to prevent infinite recursion
110
+ // when the consolidator fails to free enough space
111
+ const beforeCount = entries.length;
112
+ try {
113
+ const result = await this.consolidator(target, signal);
114
+ if (result.consolidated) {
115
+ // CRITICAL: reload from disk — child process modified files, our arrays are stale
116
+ await this.loadFromDisk();
117
+ // Guard: if consolidation didn't reduce entries, stop recursing
118
+ const afterEntries = this.entriesFor(target);
119
+ const afterCount = afterEntries.length;
120
+ if (afterCount >= beforeCount && afterCount > 0) {
121
+ return {
122
+ success: false,
123
+ error: `Memory at capacity and consolidation did not free enough space. Entry count unchanged at ${afterCount}.`,
124
+ };
125
+ }
126
+ // Retry the add with fresh data
127
+ return this.add(target, content, signal);
128
+ }
129
+ } catch {
130
+ // Consolidation failed — fall through to error
131
+ }
132
+ }
98
133
  const current = this.charCount(target);
99
134
  return {
100
135
  success: false,
@@ -104,12 +139,12 @@ export class MemoryStore {
104
139
 
105
140
  entries.push(content);
106
141
  this.setEntries(target, entries);
107
- this.saveToDisk(target);
142
+ await this.saveToDisk(target);
108
143
 
109
144
  return this.successResponse(target, "Entry added.");
110
145
  }
111
146
 
112
- replace(target: "memory" | "user", oldText: string, newContent: string): MemoryResult {
147
+ async replace(target: "memory" | "user", oldText: string, newContent: string): Promise<MemoryResult> {
113
148
  oldText = oldText.trim();
114
149
  newContent = newContent.trim();
115
150
  if (!oldText) return { success: false, error: "old_text cannot be empty." };
@@ -144,12 +179,12 @@ export class MemoryStore {
144
179
 
145
180
  entries[idx] = newContent;
146
181
  this.setEntries(target, entries);
147
- this.saveToDisk(target);
182
+ await this.saveToDisk(target);
148
183
 
149
184
  return this.successResponse(target, "Entry replaced.");
150
185
  }
151
186
 
152
- remove(target: "memory" | "user", oldText: string): MemoryResult {
187
+ async remove(target: "memory" | "user", oldText: string): Promise<MemoryResult> {
153
188
  oldText = oldText.trim();
154
189
  if (!oldText) return { success: false, error: "old_text cannot be empty." };
155
190
 
@@ -168,7 +203,7 @@ export class MemoryStore {
168
203
  const idx = entries.indexOf(matches[0]);
169
204
  entries.splice(idx, 1);
170
205
  this.setEntries(target, entries);
171
- this.saveToDisk(target);
206
+ await this.saveToDisk(target);
172
207
 
173
208
  return this.successResponse(target, "Entry removed.");
174
209
  }
@@ -182,6 +217,14 @@ export class MemoryStore {
182
217
  return parts.join("\n\n");
183
218
  }
184
219
 
220
+ /**
221
+ * Render a project-specific memory block for system prompt injection.
222
+ * Uses only the memory entries (no user split) with a project-labelled header.
223
+ */
224
+ formatProjectBlock(projectName: string): string {
225
+ return this.renderProjectBlock(projectName, this.memoryEntries);
226
+ }
227
+
185
228
  getMemoryEntries(): string[] {
186
229
  return [...this.memoryEntries];
187
230
  }
@@ -224,6 +267,18 @@ export class MemoryStore {
224
267
  return `${separator}\n${header}\n${separator}\n${content}`;
225
268
  }
226
269
 
270
+ private renderProjectBlock(projectName: string, entries: string[]): string {
271
+ if (!entries.length) return "";
272
+ const limit = this.config.memoryCharLimit;
273
+ const content = entries.join(ENTRY_DELIMITER);
274
+ const current = content.length;
275
+ const pct = limit > 0 ? Math.min(100, Math.floor((current / limit) * 100)) : 0;
276
+
277
+ const header = `PROJECT MEMORY: ${projectName} [${pct}% — ${current}/${limit} chars]`;
278
+ const separator = "═".repeat(46);
279
+ return `${separator}\n${header}\n${separator}\n${content}`;
280
+ }
281
+
227
282
  private async readFile(filePath: string): Promise<string[]> {
228
283
  try {
229
284
  const raw = await fs.readFile(filePath, "utf-8");
@@ -235,23 +290,22 @@ export class MemoryStore {
235
290
  }
236
291
 
237
292
  /** Atomic write: temp file + fs.rename() — same crash-safety as Hermes. */
238
- private saveToDisk(target: "memory" | "user"): void {
293
+ private async saveToDisk(target: "memory" | "user"): Promise<void> {
239
294
  const filePath = this.pathFor(target);
240
295
  const entries = this.entriesFor(target);
241
296
  const content = entries.length ? entries.join(ENTRY_DELIMITER) : "";
242
297
 
243
- // Fire-and-forget atomic write
244
- fs.mkdtemp(path.join(os.tmpdir(), "pi-memory-")).then((tmpDir) => {
245
- const tmpPath = path.join(tmpDir, "write.tmp");
246
- return fs
247
- .writeFile(tmpPath, content, "utf-8")
248
- .then(() => fs.rename(tmpPath, filePath))
249
- .catch(async () => {
250
- try { await fs.unlink(tmpPath); } catch { /* ignore */ }
251
- })
252
- .finally(async () => {
253
- try { await fs.rmdir(tmpDir); } catch { /* ignore */ }
254
- });
255
- });
298
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-memory-"));
299
+ const tmpPath = path.join(tmpDir, "write.tmp");
300
+
301
+ try {
302
+ await fs.writeFile(tmpPath, content, "utf-8");
303
+ await fs.rename(tmpPath, filePath);
304
+ } catch (err) {
305
+ try { await fs.unlink(tmpPath); } catch { /* ignore */ }
306
+ throw err;
307
+ } finally {
308
+ try { await fs.rmdir(tmpDir); } catch { /* ignore */ }
309
+ }
256
310
  }
257
311
  }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * SkillStore — procedural memory stored as SKILL.md files.
3
+ *
4
+ * Skills capture HOW to do something (procedural knowledge), as opposed
5
+ * to MemoryStore which captures WHAT (declarative knowledge).
6
+ *
7
+ * Storage: ~/.pi/agent/memory/skills/<slug>.md
8
+ * Format: YAML-like frontmatter + markdown body (no yaml dependency)
9
+ * Progressive disclosure: index (name+description) in system prompt,
10
+ * full content loaded on demand via skill tool.
11
+ */
12
+
13
+ import * as fs from "node:fs/promises";
14
+ import * as path from "node:path";
15
+ import * as os from "node:os";
16
+ import { scanContent } from "./content-scanner.js";
17
+ import type { SkillIndex, SkillDocument, SkillResult } from "../types.js";
18
+
19
+ // ─── Frontmatter parsing ───
20
+
21
+ function parseFrontmatter(raw: string): { meta: Record<string, string>; body: string } {
22
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
23
+ if (!match) return { meta: {}, body: raw };
24
+
25
+ const meta: Record<string, string> = {};
26
+ for (const line of match[1].split("\n")) {
27
+ const idx = line.indexOf(":");
28
+ if (idx > 0) {
29
+ const key = line.slice(0, idx).trim();
30
+ const value = line.slice(idx + 1).trim();
31
+ meta[key] = value;
32
+ }
33
+ }
34
+ return { meta, body: match[2].trim() };
35
+ }
36
+
37
+ function formatFrontmatter(doc: Omit<SkillDocument, "fileName">): string {
38
+ return [
39
+ "---",
40
+ `name: ${doc.name}`,
41
+ `description: ${doc.description}`,
42
+ `version: ${doc.version}`,
43
+ `created: ${doc.created}`,
44
+ `updated: ${doc.updated}`,
45
+ "---",
46
+ doc.body,
47
+ ].join("\n");
48
+ }
49
+
50
+ // ─── Slugify ───
51
+
52
+ function slugify(name: string): string {
53
+ return name
54
+ .toLowerCase()
55
+ .replace(/[^a-z0-9]+/g, "-")
56
+ .replace(/^-|-$/g, "")
57
+ .slice(0, 64);
58
+ }
59
+
60
+ // ─── SkillStore ───
61
+
62
+ export class SkillStore {
63
+ private skillsDir: string;
64
+
65
+ constructor(skillsDir?: string) {
66
+ this.skillsDir = skillsDir ?? path.join(os.homedir(), ".pi", "agent", "memory", "skills");
67
+ }
68
+
69
+ // ─── Read ───
70
+
71
+ async loadIndex(): Promise<SkillIndex[]> {
72
+ await fs.mkdir(this.skillsDir, { recursive: true });
73
+ const files = await fs.readdir(this.skillsDir);
74
+ const skills: SkillIndex[] = [];
75
+
76
+ for (const file of files) {
77
+ if (!file.endsWith(".md")) continue;
78
+ const doc = await this.loadSkill(file);
79
+ if (doc) {
80
+ skills.push({ fileName: doc.fileName, name: doc.name, description: doc.description });
81
+ }
82
+ }
83
+
84
+ return skills;
85
+ }
86
+
87
+ async loadSkill(fileName: string): Promise<SkillDocument | null> {
88
+ try {
89
+ const raw = await fs.readFile(path.join(this.skillsDir, fileName), "utf-8");
90
+ const { meta, body } = parseFrontmatter(raw);
91
+ if (!meta.name) return null;
92
+ return {
93
+ fileName,
94
+ name: meta.name,
95
+ description: meta.description || "",
96
+ version: parseInt(meta.version || "1", 10),
97
+ created: meta.created || new Date().toISOString().split("T")[0],
98
+ updated: meta.updated || new Date().toISOString().split("T")[0],
99
+ body,
100
+ };
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ // ─── Write ───
107
+
108
+ async create(name: string, description: string, body: string): Promise<SkillResult> {
109
+ name = name.trim();
110
+ description = description.trim();
111
+ body = body.trim();
112
+
113
+ if (!name) return { success: false, error: "Skill name is required." };
114
+ if (!description) return { success: false, error: "Skill description is required." };
115
+ if (!body) return { success: false, error: "Skill body is required." };
116
+
117
+ // Scan content for security
118
+ const scanError = scanContent(name + " " + description + " " + body);
119
+ if (scanError) return { success: false, error: scanError };
120
+
121
+ const slug = slugify(name);
122
+ if (!slug) return { success: false, error: "Skill name produces empty slug." };
123
+
124
+ const fileName = `${slug}.md`;
125
+ const filePath = path.join(this.skillsDir, fileName);
126
+
127
+ // Check if file already exists
128
+ try {
129
+ await fs.access(filePath);
130
+ return {
131
+ success: false,
132
+ error: `Skill '${name}' already exists (file: ${fileName}). Use 'patch' or 'edit' to update it.`,
133
+ };
134
+ } catch {
135
+ // File doesn't exist — good
136
+ }
137
+
138
+ await fs.mkdir(this.skillsDir, { recursive: true });
139
+
140
+ const today = new Date().toISOString().split("T")[0];
141
+ const doc: Omit<SkillDocument, "fileName"> = {
142
+ name,
143
+ description,
144
+ version: 1,
145
+ created: today,
146
+ updated: today,
147
+ body,
148
+ };
149
+
150
+ await this.atomicWrite(fileName, formatFrontmatter(doc));
151
+
152
+ return { success: true, message: `Skill '${name}' created.`, fileName };
153
+ }
154
+
155
+ async patch(fileName: string, section: string, newContent: string): Promise<SkillResult> {
156
+ newContent = newContent.trim();
157
+ if (!newContent) return { success: false, error: "New content is required for patch." };
158
+
159
+ const scanError = scanContent(newContent);
160
+ if (scanError) return { success: false, error: scanError };
161
+
162
+ const doc = await this.loadSkill(fileName);
163
+ if (!doc) return { success: false, error: `Skill file '${fileName}' not found.` };
164
+
165
+ // Replace or append the section in the body
166
+ const sectionHeader = `## ${section}`;
167
+ const lines = doc.body.split("\n");
168
+ let found = false;
169
+ const result: string[] = [];
170
+
171
+ for (let i = 0; i < lines.length; i++) {
172
+ if (lines[i].startsWith(sectionHeader)) {
173
+ // Replace this section — skip old content until next section or end
174
+ result.push(sectionHeader);
175
+ result.push(newContent);
176
+ found = true;
177
+ // Skip lines until next ## header or end
178
+ i++;
179
+ while (i < lines.length && !lines[i].startsWith("## ")) {
180
+ i++;
181
+ }
182
+ // Don't skip the next ## header
183
+ if (i < lines.length) {
184
+ result.push(lines[i]);
185
+ }
186
+ } else {
187
+ result.push(lines[i]);
188
+ }
189
+ }
190
+
191
+ if (!found) {
192
+ // Append the section
193
+ result.push("", sectionHeader, newContent);
194
+ }
195
+
196
+ const today = new Date().toISOString().split("T")[0];
197
+ const updated: Omit<SkillDocument, "fileName"> = {
198
+ name: doc.name,
199
+ description: doc.description,
200
+ version: doc.version + 1,
201
+ created: doc.created,
202
+ updated: today,
203
+ body: result.join("\n").trim(),
204
+ };
205
+
206
+ await this.atomicWrite(fileName, formatFrontmatter(updated));
207
+
208
+ return { success: true, message: `Skill '${doc.name}' section '${section}' updated.`, fileName };
209
+ }
210
+
211
+ async edit(fileName: string, description: string, body: string): Promise<SkillResult> {
212
+ description = description.trim();
213
+ body = body.trim();
214
+
215
+ if (!description && !body) {
216
+ return { success: false, error: "At least one of description or body is required." };
217
+ }
218
+
219
+ const doc = await this.loadSkill(fileName);
220
+ if (!doc) return { success: false, error: `Skill file '${fileName}' not found.` };
221
+
222
+ const newDesc = description || doc.description;
223
+ const newBody = body || doc.body;
224
+
225
+ // Scan combined content
226
+ const scanError = scanContent(newDesc + " " + newBody);
227
+ if (scanError) return { success: false, error: scanError };
228
+
229
+ const today = new Date().toISOString().split("T")[0];
230
+ const updated: Omit<SkillDocument, "fileName"> = {
231
+ name: doc.name,
232
+ description: newDesc,
233
+ version: doc.version + 1,
234
+ created: doc.created,
235
+ updated: today,
236
+ body: newBody,
237
+ };
238
+
239
+ await this.atomicWrite(fileName, formatFrontmatter(updated));
240
+
241
+ return { success: true, message: `Skill '${doc.name}' updated.`, fileName };
242
+ }
243
+
244
+ async delete(fileName: string): Promise<SkillResult> {
245
+ const doc = await this.loadSkill(fileName);
246
+ if (!doc) return { success: false, error: `Skill file '${fileName}' not found.` };
247
+
248
+ await fs.unlink(path.join(this.skillsDir, fileName));
249
+
250
+ return { success: true, message: `Skill '${doc.name}' deleted.`, fileName };
251
+ }
252
+
253
+ // ─── System prompt injection (progressive disclosure) ───
254
+
255
+ async formatIndexForSystemPrompt(): Promise<string> {
256
+ const skills = await this.loadIndex();
257
+ if (skills.length === 0) return "";
258
+
259
+ const lines: string[] = [
260
+ "═".repeat(46),
261
+ `SKILLS (procedural memory) [${skills.length} skills]`,
262
+ "═".repeat(46),
263
+ "Use the 'skill' tool with action 'view' to load full content on demand.",
264
+ "",
265
+ ];
266
+
267
+ for (const skill of skills) {
268
+ lines.push(`• ${skill.name}: ${skill.description}`);
269
+ }
270
+
271
+ return lines.join("\n");
272
+ }
273
+
274
+ // ─── Internal helpers ───
275
+
276
+ /** Atomic write: temp file + rename (same crash-safety as MemoryStore) */
277
+ private async atomicWrite(fileName: string, content: string): Promise<void> {
278
+ const filePath = path.join(this.skillsDir, fileName);
279
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-skill-"));
280
+ const tmpPath = path.join(tmpDir, "write.tmp");
281
+
282
+ try {
283
+ await fs.writeFile(tmpPath, content, "utf-8");
284
+ await fs.rename(tmpPath, filePath);
285
+ } catch (err) {
286
+ try { await fs.unlink(tmpPath); } catch { /* ignore */ }
287
+ throw err;
288
+ } finally {
289
+ try { await fs.rmdir(tmpDir); } catch { /* ignore */ }
290
+ }
291
+ }
292
+ }
@@ -10,7 +10,7 @@ import { StringEnum } from "@mariozechner/pi-ai";
10
10
  import { MemoryStore } from "../store/memory-store.js";
11
11
  import { MEMORY_TOOL_DESCRIPTION } from "../constants.js";
12
12
 
13
- export function registerMemoryTool(pi: ExtensionAPI, store: MemoryStore): void {
13
+ export function registerMemoryTool(pi: ExtensionAPI, store: MemoryStore, projectStore: MemoryStore | null): void {
14
14
  pi.registerTool({
15
15
  name: "memory",
16
16
  label: "Memory",
@@ -24,7 +24,7 @@ export function registerMemoryTool(pi: ExtensionAPI, store: MemoryStore): void {
24
24
  ],
25
25
  parameters: Type.Object({
26
26
  action: StringEnum(["add", "replace", "remove"] as const),
27
- target: StringEnum(["memory", "user"] as const),
27
+ target: StringEnum(["memory", "user", "project"] as const),
28
28
  content: Type.Optional(
29
29
  Type.String({ description: "Entry content for add/replace" })
30
30
  ),
@@ -36,7 +36,21 @@ export function registerMemoryTool(pi: ExtensionAPI, store: MemoryStore): void {
36
36
  ),
37
37
  }),
38
38
  async execute(toolCallId, params, signal, onUpdate, ctx) {
39
- const { action, target, content, old_text } = params;
39
+ const { action, target: rawTarget, content, old_text } = params;
40
+
41
+ // Route 'project' to projectStore (internal target 'memory')
42
+ const target = rawTarget as "memory" | "user";
43
+ const activeStore = rawTarget === "project" ? projectStore : store;
44
+
45
+ if (rawTarget === "project" && !projectStore) {
46
+ return {
47
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: "Project memory is not available (no project detected)." }) }],
48
+ details: {},
49
+ };
50
+ }
51
+
52
+ // After the guard above, activeStore is guaranteed non-null when rawTarget === 'project'
53
+ const store_ = activeStore!;
40
54
 
41
55
  let result;
42
56
  switch (action) {
@@ -55,7 +69,7 @@ export function registerMemoryTool(pi: ExtensionAPI, store: MemoryStore): void {
55
69
  details: {},
56
70
  };
57
71
  }
58
- result = store.add(target, content);
72
+ result = await store_.add(target, content);
59
73
  break;
60
74
 
61
75
  case "replace":
@@ -87,7 +101,7 @@ export function registerMemoryTool(pi: ExtensionAPI, store: MemoryStore): void {
87
101
  details: {},
88
102
  };
89
103
  }
90
- result = store.replace(target, old_text, content);
104
+ result = await store_.replace(target, old_text, content);
91
105
  break;
92
106
 
93
107
  case "remove":
@@ -105,7 +119,7 @@ export function registerMemoryTool(pi: ExtensionAPI, store: MemoryStore): void {
105
119
  details: {},
106
120
  };
107
121
  }
108
- result = store.remove(target, old_text);
122
+ result = await store_.remove(target, old_text);
109
123
  break;
110
124
 
111
125
  default:
@@ -115,6 +129,11 @@ export function registerMemoryTool(pi: ExtensionAPI, store: MemoryStore): void {
115
129
  };
116
130
  }
117
131
 
132
+ // Tag project results so the caller knows the scope
133
+ if (rawTarget === "project" && result.success) {
134
+ (result as any).target = "project";
135
+ }
136
+
118
137
  return {
119
138
  content: [{ type: "text", text: JSON.stringify(result) }],
120
139
  details: result,