pi-hermes-memory 0.2.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/README.md CHANGED
@@ -305,6 +305,8 @@ Create `~/.pi/agent/hermes-memory-config.json`:
305
305
  {
306
306
  "memoryCharLimit": 2200,
307
307
  "userCharLimit": 1375,
308
+ "projectCharLimit": 2200,
309
+ "memoryDir": "~/.pi/agent/memory",
308
310
  "nudgeInterval": 10,
309
311
  "nudgeToolCalls": 15,
310
312
  "reviewEnabled": true,
@@ -320,6 +322,8 @@ Create `~/.pi/agent/hermes-memory-config.json`:
320
322
  |---|---|---|
321
323
  | `memoryCharLimit` | `2200` | Max characters in MEMORY.md |
322
324
  | `userCharLimit` | `1375` | Max characters in USER.md |
325
+ | `projectCharLimit` | `2200` | Max characters in project-scoped MEMORY.md |
326
+ | `memoryDir` | `~/.pi/agent/memory` | Custom directory for memory files |
323
327
  | `nudgeInterval` | `10` | Turns between auto-reviews |
324
328
  | `nudgeToolCalls` | `15` | Tool calls between auto-reviews (OR with turns) |
325
329
  | `reviewEnabled` | `true` | Enable/disable background learning loop |
package/docs/0.2/TASKS.md CHANGED
@@ -118,8 +118,8 @@ _Done when: v0.2.0 is tagged and released with updated docs._
118
118
  - [x] Update `docs/ROADMAP.md` — v0.2 roadmap documented (`d5b7518`)
119
119
  - [x] `npm run check` passes with zero errors (`c6317dd`)
120
120
  - [x] `npm test` — all 218 tests pass (`83e7c46`)
121
- - [ ] Bump `package.json` version to `0.2.0`
122
- - [ ] Tag v0.2.0 release
121
+ - [x] Bump `package.json` version to `0.2.0`
122
+ - [x] Tag v0.2.0 release
123
123
 
124
124
  ---
125
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hermes-memory",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Your Pi agent remembers everything across sessions — your preferences, your stack, your corrections, and even how it solved problems. Zero-config install, works immediately. Persistent memory + procedural skills + auto-correction detection + security-first content scanning.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "scripts": {
19
19
  "check": "tsc --noEmit",
20
- "test": "npx tsx --test 'tests/**/*.test.ts' --test-concurrency=1"
20
+ "test": "tests/run-all.sh"
21
21
  },
22
22
  "keywords": [
23
23
  "pi-package",
@@ -38,11 +38,12 @@
38
38
  "url": "https://github.com/chandra447/pi-hermes-memory"
39
39
  },
40
40
  "peerDependencies": {
41
- "@mariozechner/pi-coding-agent": "*"
41
+ "@mariozechner/pi-coding-agent": ">=0.70.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@mariozechner/pi-ai": "^0.70.0",
45
45
  "@mariozechner/pi-coding-agent": "^0.70.0",
46
+ "tsx": "^4.21.0",
46
47
  "typebox": "^1.1.33",
47
48
  "typescript": "^6.0.3"
48
49
  }
package/src/config.ts CHANGED
@@ -5,6 +5,7 @@ import type { MemoryConfig } from "./types.js";
5
5
  import {
6
6
  DEFAULT_MEMORY_CHAR_LIMIT,
7
7
  DEFAULT_USER_CHAR_LIMIT,
8
+ DEFAULT_PROJECT_CHAR_LIMIT,
8
9
  DEFAULT_NUDGE_INTERVAL,
9
10
  DEFAULT_FLUSH_MIN_TURNS,
10
11
  DEFAULT_NUDGE_TOOL_CALLS,
@@ -13,6 +14,7 @@ import {
13
14
  const DEFAULT_CONFIG: MemoryConfig = {
14
15
  memoryCharLimit: DEFAULT_MEMORY_CHAR_LIMIT,
15
16
  userCharLimit: DEFAULT_USER_CHAR_LIMIT,
17
+ projectCharLimit: DEFAULT_PROJECT_CHAR_LIMIT,
16
18
  nudgeInterval: DEFAULT_NUDGE_INTERVAL,
17
19
  reviewEnabled: true,
18
20
  flushOnCompact: true,
@@ -47,6 +49,8 @@ export function loadConfig(): MemoryConfig {
47
49
  if (typeof parsed.autoConsolidate === "boolean") config.autoConsolidate = parsed.autoConsolidate;
48
50
  if (typeof parsed.correctionDetection === "boolean") config.correctionDetection = parsed.correctionDetection;
49
51
  if (typeof parsed.nudgeToolCalls === "number") config.nudgeToolCalls = parsed.nudgeToolCalls;
52
+ if (typeof parsed.projectCharLimit === "number") config.projectCharLimit = parsed.projectCharLimit;
53
+ if (typeof parsed.memoryDir === "string") config.memoryDir = parsed.memoryDir;
50
54
  return config;
51
55
  }
52
56
  } catch {
package/src/constants.ts CHANGED
@@ -12,6 +12,8 @@ export const DEFAULT_MEMORY_CHAR_LIMIT = 2200;
12
12
  export const DEFAULT_USER_CHAR_LIMIT = 1375;
13
13
 
14
14
  // ─── Learning loop defaults ───
15
+ export const DEFAULT_PROJECT_CHAR_LIMIT = 2200;
16
+
15
17
  export const DEFAULT_NUDGE_INTERVAL = 10;
16
18
  export const DEFAULT_FLUSH_MIN_TURNS = 6;
17
19
  export const DEFAULT_NUDGE_TOOL_CALLS = 15;
@@ -35,9 +37,10 @@ PRIORITY: User preferences and corrections > environment facts > procedural know
35
37
 
36
38
  Do NOT save task progress, session outcomes, completed-work logs, or temporary TODO state.
37
39
 
38
- TWO TARGETS:
40
+ THREE TARGETS:
39
41
  - 'user': who the user is -- name, role, preferences, communication style, pet peeves
40
- - 'memory': your notes -- environment facts, project conventions, tool quirks, lessons learned
42
+ - 'memory': your global notes -- environment facts, tool quirks, lessons learned (shared across all projects)
43
+ - 'project': project-specific notes -- architecture decisions, API quirks, team norms, codebase conventions (scoped to current project)
41
44
 
42
45
  ACTIONS: add (new entry), replace (update existing -- old_text identifies it), remove (delete -- old_text identifies it).`;
43
46
 
@@ -16,6 +16,7 @@ import { getMessageText } from "../types.js";
16
16
  export function setupBackgroundReview(
17
17
  pi: ExtensionAPI,
18
18
  store: MemoryStore,
19
+ projectStore: MemoryStore | null,
19
20
  config: MemoryConfig,
20
21
  ): void {
21
22
  let turnsSinceReview = 0;
@@ -35,17 +36,17 @@ export function setupBackgroundReview(
35
36
  if (!config.reviewEnabled) return;
36
37
  if (reviewInProgress) return;
37
38
 
38
- // Count tool-use entries from the branch for tool-call-aware nudge
39
+ // Count tool calls from this turn's message only (not cumulative branch scan
40
+ // otherwise the counter resets to 0 at review, then immediately re-counts all
41
+ // historical tool calls and re-triggers on every subsequent turn).
39
42
  try {
40
- const branch = ctx.sessionManager.getBranch();
41
- for (const entry of branch) {
42
- if (entry.type === "message" && entry.message?.role === "assistant") {
43
- const content = entry.message?.content;
44
- if (Array.isArray(content)) {
45
- for (const block of content) {
46
- if (block && typeof block === "object" && block.type === "toolCall") {
47
- toolCallsSinceReview++;
48
- }
43
+ const msg = event.message;
44
+ if (msg?.role === "assistant") {
45
+ const content = msg?.content;
46
+ if (Array.isArray(content)) {
47
+ for (const block of content) {
48
+ if (block && typeof block === "object" && block.type === "toolCall") {
49
+ toolCallsSinceReview++;
49
50
  }
50
51
  }
51
52
  }
@@ -82,6 +83,7 @@ export function setupBackgroundReview(
82
83
 
83
84
  const currentMemory = store.getMemoryEntries().join("\n§\n");
84
85
  const currentUser = store.getUserEntries().join("\n§\n");
86
+ const currentProject = projectStore ? projectStore.getMemoryEntries().join("\n§\n") : null;
85
87
 
86
88
  const reviewPrompt = [
87
89
  COMBINED_REVIEW_PROMPT,
@@ -91,12 +93,23 @@ export function setupBackgroundReview(
91
93
  "",
92
94
  "--- Current User Profile ---",
93
95
  currentUser || "(empty)",
96
+ ];
97
+
98
+ if (currentProject !== null) {
99
+ reviewPrompt.push(
100
+ "",
101
+ "--- Current Project Memory ---",
102
+ currentProject || "(empty)",
103
+ );
104
+ }
105
+
106
+ reviewPrompt.push(
94
107
  "",
95
108
  "--- Conversation to Review ---",
96
109
  parts.join("\n\n"),
97
- ].join("\n");
110
+ );
98
111
 
99
- const result = await pi.exec("pi", ["-p", "--no-session", reviewPrompt], {
112
+ const result = await pi.exec("pi", ["-p", "--no-session", reviewPrompt.join("\n")], {
100
113
  signal: ctx.signal,
101
114
  timeout: 60000,
102
115
  });
@@ -57,6 +57,7 @@ export function isCorrection(text: string): boolean {
57
57
  export function setupCorrectionDetector(
58
58
  pi: ExtensionAPI,
59
59
  store: MemoryStore,
60
+ projectStore: MemoryStore | null,
60
61
  config: MemoryConfig,
61
62
  ): void {
62
63
  if (!config.correctionDetection) return;
@@ -109,6 +110,7 @@ export function setupCorrectionDetector(
109
110
 
110
111
  const currentMemory = store.getMemoryEntries().join(ENTRY_DELIMITER);
111
112
  const currentUser = store.getUserEntries().join(ENTRY_DELIMITER);
113
+ const currentProject = projectStore ? projectStore.getMemoryEntries().join(ENTRY_DELIMITER) : null;
112
114
 
113
115
  const prompt = [
114
116
  CORRECTION_SAVE_PROMPT,
@@ -118,12 +120,23 @@ export function setupCorrectionDetector(
118
120
  "",
119
121
  "--- Current User Profile ---",
120
122
  currentUser || "(empty)",
123
+ ];
124
+
125
+ if (currentProject !== null) {
126
+ prompt.push(
127
+ "",
128
+ "--- Current Project Memory ---",
129
+ currentProject || "(empty)",
130
+ );
131
+ }
132
+
133
+ prompt.push(
121
134
  "",
122
135
  "--- Recent Conversation ---",
123
136
  recentParts.join("\n\n"),
124
- ].join("\n");
137
+ );
125
138
 
126
- const result = await pi.exec("pi", ["-p", "--no-session", prompt], {
139
+ const result = await pi.exec("pi", ["-p", "--no-session", prompt.join("\n")], {
127
140
  signal: ctx.signal,
128
141
  timeout: 30000,
129
142
  });
@@ -5,12 +5,13 @@
5
5
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
6
  import { MemoryStore } from "../store/memory-store.js";
7
7
 
8
- export function registerInsightsCommand(pi: ExtensionAPI, store: MemoryStore): void {
8
+ export function registerInsightsCommand(pi: ExtensionAPI, store: MemoryStore, projectStore: MemoryStore | null, projectName: string): void {
9
9
  pi.registerCommand("memory-insights", {
10
10
  description: "Show what's stored in persistent memory",
11
11
  handler: async (_args, ctx) => {
12
12
  const memoryEntries = store.getMemoryEntries();
13
13
  const userEntries = store.getUserEntries();
14
+ const projectEntries = projectStore ? projectStore.getMemoryEntries() : null;
14
15
 
15
16
  const lines: string[] = [];
16
17
  lines.push("");
@@ -51,6 +52,24 @@ export function registerInsightsCommand(pi: ExtensionAPI, store: MemoryStore): v
51
52
  }
52
53
  lines.push("");
53
54
 
55
+ // Project section
56
+ if (projectEntries !== null) {
57
+ lines.push(` 📁 PROJECT MEMORY: ${projectName}`);
58
+ lines.push(" " + "─".repeat(44));
59
+ if (projectEntries.length === 0) {
60
+ lines.push(" (empty)");
61
+ } else {
62
+ for (let i = 0; i < projectEntries.length; i++) {
63
+ const preview =
64
+ projectEntries[i].length > 100
65
+ ? projectEntries[i].slice(0, 100) + "..."
66
+ : projectEntries[i];
67
+ lines.push(` ${i + 1}. ${preview}`);
68
+ }
69
+ }
70
+ lines.push("");
71
+ }
72
+
54
73
  ctx.ui.notify(lines.join("\n"), "info");
55
74
  },
56
75
  });
@@ -13,6 +13,7 @@ import { getMessageText } from "../types.js";
13
13
  export function setupSessionFlush(
14
14
  pi: ExtensionAPI,
15
15
  store: MemoryStore,
16
+ projectStore: MemoryStore | null,
16
17
  config: MemoryConfig,
17
18
  ): void {
18
19
  let userTurnCount = 0;
@@ -20,24 +20,24 @@ export function setupSkillAutoTrigger(
20
20
  ): void {
21
21
  let triggeredThisSession = false;
22
22
 
23
+ // Accumulate tool calls across turns (reset on trigger)
24
+ let toolCallCount = 0;
25
+ const toolTypes = new Set<string>();
26
+
23
27
  pi.on("turn_end", async (event, ctx) => {
24
28
  if (triggeredThisSession) return;
25
29
 
26
- // Count tool-use entries from this turn's branch
27
- let toolCallCount = 0;
28
- const toolTypes = new Set<string>();
29
-
30
+ // Count tool calls from this turn's message only (not cumulative branch scan —
31
+ // otherwise the counter accumulates historical tool calls and fires prematurely).
30
32
  try {
31
- const branch = ctx.sessionManager.getBranch();
32
- for (const entry of branch) {
33
- if (entry.type === "message" && entry.message?.role === "assistant") {
34
- const content = entry.message?.content;
35
- if (Array.isArray(content)) {
36
- for (const block of content) {
37
- if (block && typeof block === "object" && block.type === "toolCall") {
38
- toolCallCount++;
39
- if ((block as { name?: string }).name) toolTypes.add((block as { name: string }).name);
40
- }
33
+ const msg = event.message;
34
+ if (msg?.role === "assistant") {
35
+ const content = msg?.content;
36
+ if (Array.isArray(content)) {
37
+ for (const block of content) {
38
+ if (block && typeof block === "object" && block.type === "toolCall") {
39
+ toolCallCount++;
40
+ if ((block as { name?: string }).name) toolTypes.add((block as { name: string }).name);
41
41
  }
42
42
  }
43
43
  }
package/src/index.ts CHANGED
@@ -37,22 +37,37 @@ import { loadConfig } from "./config.js";
37
37
  export default function (pi: ExtensionAPI) {
38
38
  const config = loadConfig();
39
39
 
40
- const memoryDir = config.memoryDir ?? path.join(os.homedir(), ".pi", "agent", "memory");
40
+ const globalDir = config.memoryDir ?? path.join(os.homedir(), ".pi", "agent", "memory");
41
41
  const store = new MemoryStore(config);
42
- const skillStore = new SkillStore(path.join(memoryDir, "skills"));
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;
43
55
 
44
56
  // ── 1. Load memory from disk on session start ──
45
57
  pi.on("session_start", async (_event, _ctx) => {
46
58
  await store.loadFromDisk();
59
+ if (projectStore) await projectStore.loadFromDisk();
47
60
  });
48
61
 
49
- // ── 2. Inject frozen snapshot + skill index into system prompt ──
62
+ // ── 2. Inject frozen snapshot + skill index + project memory into system prompt ──
50
63
  pi.on("before_agent_start", async (event, _ctx) => {
51
64
  const memoryBlock = store.formatForSystemPrompt();
52
65
  const skillIndex = await skillStore.formatIndexForSystemPrompt();
66
+ const projectBlock = projectStore ? projectStore.formatProjectBlock(projectName) : "";
53
67
 
54
68
  const parts: string[] = [];
55
69
  if (memoryBlock) parts.push(memoryBlock);
70
+ if (projectBlock) parts.push(projectBlock);
56
71
  if (skillIndex) parts.push(skillIndex);
57
72
 
58
73
  if (parts.length > 0) {
@@ -62,17 +77,17 @@ export default function (pi: ExtensionAPI) {
62
77
  }
63
78
  });
64
79
 
65
- // ── 3. Register the memory tool ──
66
- registerMemoryTool(pi, store);
80
+ // ── 3. Register the memory tool (with project store) ──
81
+ registerMemoryTool(pi, store, projectStore);
67
82
 
68
83
  // ── 4. Register the skill tool ──
69
84
  registerSkillTool(pi, skillStore);
70
85
 
71
86
  // ── 5. Setup background learning loop (with tool-call-aware nudge) ──
72
- setupBackgroundReview(pi, store, config);
87
+ setupBackgroundReview(pi, store, projectStore, config);
73
88
 
74
89
  // ── 6. Setup session-end flush ──
75
- setupSessionFlush(pi, store, config);
90
+ setupSessionFlush(pi, store, projectStore, config);
76
91
 
77
92
  // ── 7. Setup auto-consolidation (inject consolidator into store) ──
78
93
  store.setConsolidator(async (target, signal) => {
@@ -81,12 +96,12 @@ export default function (pi: ExtensionAPI) {
81
96
  registerConsolidateCommand(pi, store);
82
97
 
83
98
  // ── 8. Setup correction detection ──
84
- setupCorrectionDetector(pi, store, config);
99
+ setupCorrectionDetector(pi, store, projectStore, config);
85
100
 
86
101
  // ── 9. Setup skill auto-trigger ──
87
102
  setupSkillAutoTrigger(pi, store, skillStore, config);
88
103
 
89
104
  // ── 10. Register commands ──
90
- registerInsightsCommand(pi, store);
105
+ registerInsightsCommand(pi, store, projectStore, projectName);
91
106
  registerSkillsCommand(pi, skillStore);
92
107
  }
@@ -106,11 +106,23 @@ export class MemoryStore {
106
106
  if (newTotal > limit) {
107
107
  // Auto-consolidate if configured and consolidator available
108
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;
109
112
  try {
110
113
  const result = await this.consolidator(target, signal);
111
114
  if (result.consolidated) {
112
115
  // CRITICAL: reload from disk — child process modified files, our arrays are stale
113
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
+ }
114
126
  // Retry the add with fresh data
115
127
  return this.add(target, content, signal);
116
128
  }
@@ -127,12 +139,12 @@ export class MemoryStore {
127
139
 
128
140
  entries.push(content);
129
141
  this.setEntries(target, entries);
130
- this.saveToDisk(target);
142
+ await this.saveToDisk(target);
131
143
 
132
144
  return this.successResponse(target, "Entry added.");
133
145
  }
134
146
 
135
- replace(target: "memory" | "user", oldText: string, newContent: string): MemoryResult {
147
+ async replace(target: "memory" | "user", oldText: string, newContent: string): Promise<MemoryResult> {
136
148
  oldText = oldText.trim();
137
149
  newContent = newContent.trim();
138
150
  if (!oldText) return { success: false, error: "old_text cannot be empty." };
@@ -167,12 +179,12 @@ export class MemoryStore {
167
179
 
168
180
  entries[idx] = newContent;
169
181
  this.setEntries(target, entries);
170
- this.saveToDisk(target);
182
+ await this.saveToDisk(target);
171
183
 
172
184
  return this.successResponse(target, "Entry replaced.");
173
185
  }
174
186
 
175
- remove(target: "memory" | "user", oldText: string): MemoryResult {
187
+ async remove(target: "memory" | "user", oldText: string): Promise<MemoryResult> {
176
188
  oldText = oldText.trim();
177
189
  if (!oldText) return { success: false, error: "old_text cannot be empty." };
178
190
 
@@ -191,7 +203,7 @@ export class MemoryStore {
191
203
  const idx = entries.indexOf(matches[0]);
192
204
  entries.splice(idx, 1);
193
205
  this.setEntries(target, entries);
194
- this.saveToDisk(target);
206
+ await this.saveToDisk(target);
195
207
 
196
208
  return this.successResponse(target, "Entry removed.");
197
209
  }
@@ -205,6 +217,14 @@ export class MemoryStore {
205
217
  return parts.join("\n\n");
206
218
  }
207
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
+
208
228
  getMemoryEntries(): string[] {
209
229
  return [...this.memoryEntries];
210
230
  }
@@ -247,6 +267,18 @@ export class MemoryStore {
247
267
  return `${separator}\n${header}\n${separator}\n${content}`;
248
268
  }
249
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
+
250
282
  private async readFile(filePath: string): Promise<string[]> {
251
283
  try {
252
284
  const raw = await fs.readFile(filePath, "utf-8");
@@ -258,23 +290,22 @@ export class MemoryStore {
258
290
  }
259
291
 
260
292
  /** Atomic write: temp file + fs.rename() — same crash-safety as Hermes. */
261
- private saveToDisk(target: "memory" | "user"): void {
293
+ private async saveToDisk(target: "memory" | "user"): Promise<void> {
262
294
  const filePath = this.pathFor(target);
263
295
  const entries = this.entriesFor(target);
264
296
  const content = entries.length ? entries.join(ENTRY_DELIMITER) : "";
265
297
 
266
- // Fire-and-forget atomic write
267
- fs.mkdtemp(path.join(os.tmpdir(), "pi-memory-")).then((tmpDir) => {
268
- const tmpPath = path.join(tmpDir, "write.tmp");
269
- return fs
270
- .writeFile(tmpPath, content, "utf-8")
271
- .then(() => fs.rename(tmpPath, filePath))
272
- .catch(async () => {
273
- try { await fs.unlink(tmpPath); } catch { /* ignore */ }
274
- })
275
- .finally(async () => {
276
- try { await fs.rmdir(tmpDir); } catch { /* ignore */ }
277
- });
278
- });
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
+ }
279
310
  }
280
311
  }
@@ -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 = await 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,
package/src/types.ts CHANGED
@@ -9,6 +9,8 @@ export interface MemoryConfig {
9
9
  memoryCharLimit: number;
10
10
  /** Max chars for USER.md (user profile). Default: 1375 */
11
11
  userCharLimit: number;
12
+ /** Max chars for project-level MEMORY.md. Default: 2200 */
13
+ projectCharLimit: number;
12
14
  /** Turns between background auto-reviews. Default: 10 */
13
15
  nudgeInterval: number;
14
16
  /** Enable background learning loop. Default: true */