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 +4 -0
- package/docs/0.2/TASKS.md +2 -2
- package/package.json +4 -3
- package/src/config.ts +4 -0
- package/src/constants.ts +5 -2
- package/src/handlers/background-review.ts +25 -12
- package/src/handlers/correction-detector.ts +15 -2
- package/src/handlers/insights.ts +20 -1
- package/src/handlers/session-flush.ts +1 -0
- package/src/handlers/skill-auto-trigger.ts +14 -14
- package/src/index.ts +24 -9
- package/src/store/memory-store.ts +50 -19
- package/src/tools/memory-tool.ts +25 -6
- package/src/types.ts +2 -0
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
|
-
- [
|
|
122
|
-
- [
|
|
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.
|
|
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": "
|
|
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
|
-
|
|
40
|
+
THREE TARGETS:
|
|
39
41
|
- 'user': who the user is -- name, role, preferences, communication style, pet peeves
|
|
40
|
-
- 'memory': your notes -- environment facts,
|
|
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
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/src/handlers/insights.ts
CHANGED
|
@@ -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
|
});
|
|
@@ -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
|
|
27
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
}
|
package/src/tools/memory-tool.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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 */
|