pi-hermes-memory 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 +288 -0
- package/docs/0.1/TASKS.md +197 -0
- package/docs/PUBLISHING.md +149 -0
- package/docs/ROADMAP.md +272 -0
- package/package.json +46 -0
- package/src/config.ts +49 -0
- package/src/constants.ts +52 -0
- package/src/handlers/background-review.ts +95 -0
- package/src/handlers/insights.ts +57 -0
- package/src/handlers/session-flush.ts +75 -0
- package/src/index.ts +54 -0
- package/src/store/content-scanner.ts +46 -0
- package/src/store/memory-store.ts +257 -0
- package/src/tools/memory-tool.ts +124 -0
- package/src/types.ts +66 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi Hermes Memory Extension
|
|
3
|
+
*
|
|
4
|
+
* Brings Hermes-style persistent memory and a learning loop to any Pi user.
|
|
5
|
+
* After `pi install`, users get:
|
|
6
|
+
*
|
|
7
|
+
* 1. Persistent Memory — MEMORY.md + USER.md that survive across sessions
|
|
8
|
+
* 2. Background Learning Loop — auto-saves notable facts every N turns
|
|
9
|
+
* 3. Session-End Flush — saves memories before compaction/shutdown
|
|
10
|
+
* 4. /memory-insights — shows what's stored
|
|
11
|
+
*
|
|
12
|
+
* See PLAN.md for full architecture and Hermes source references.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
16
|
+
import { MemoryStore } from "./store/memory-store.js";
|
|
17
|
+
import { registerMemoryTool } from "./tools/memory-tool.js";
|
|
18
|
+
import { setupBackgroundReview } from "./handlers/background-review.js";
|
|
19
|
+
import { setupSessionFlush } from "./handlers/session-flush.js";
|
|
20
|
+
import { registerInsightsCommand } from "./handlers/insights.js";
|
|
21
|
+
import { loadConfig } from "./config.js";
|
|
22
|
+
|
|
23
|
+
export default function (pi: ExtensionAPI) {
|
|
24
|
+
const config = loadConfig();
|
|
25
|
+
|
|
26
|
+
const store = new MemoryStore(config);
|
|
27
|
+
|
|
28
|
+
// ── 1. Load memory from disk on session start ──
|
|
29
|
+
pi.on("session_start", async (_event, _ctx) => {
|
|
30
|
+
await store.loadFromDisk();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ── 2. Inject frozen snapshot into system prompt ──
|
|
34
|
+
pi.on("before_agent_start", async (event, _ctx) => {
|
|
35
|
+
const memoryBlock = store.formatForSystemPrompt();
|
|
36
|
+
if (memoryBlock) {
|
|
37
|
+
return {
|
|
38
|
+
systemPrompt: event.systemPrompt + "\n\n" + memoryBlock,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ── 3. Register the memory tool ──
|
|
44
|
+
registerMemoryTool(pi, store);
|
|
45
|
+
|
|
46
|
+
// ── 4. Setup background learning loop ──
|
|
47
|
+
setupBackgroundReview(pi, store, config);
|
|
48
|
+
|
|
49
|
+
// ── 5. Setup session-end flush ──
|
|
50
|
+
setupSessionFlush(pi, store, config);
|
|
51
|
+
|
|
52
|
+
// ── 6. Register insights command ──
|
|
53
|
+
registerInsightsCommand(pi, store);
|
|
54
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content scanner — blocks injection/exfiltration in memory writes.
|
|
3
|
+
* Ported from hermes-agent/tools/memory_tool.py (_MEMORY_THREAT_PATTERNS, _INVISIBLE_CHARS, _scan_memory_content).
|
|
4
|
+
* See PLAN.md → "Hermes Source File Reference Map" for source lines.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const MEMORY_THREAT_PATTERNS: Array<{ pattern: RegExp; id: string }> = [
|
|
8
|
+
{ pattern: /ignore\s+(previous|all|above|prior)\s+instructions/i, id: "prompt_injection" },
|
|
9
|
+
{ pattern: /you\s+are\s+now\s+/i, id: "role_hijack" },
|
|
10
|
+
{ pattern: /do\s+not\s+tell\s+the\s+user/i, id: "deception_hide" },
|
|
11
|
+
{ pattern: /system\s+prompt\s+override/i, id: "sys_prompt_override" },
|
|
12
|
+
{ pattern: /disregard\s+(your|all|any)\s+(instructions|rules|guidelines)/i, id: "disregard_rules" },
|
|
13
|
+
{ pattern: /act\s+as\s+(if|though)\s+you\s+(have\s+no|don'?t\s+have)\s+(restrictions|limits|rules)/i, id: "bypass_restrictions" },
|
|
14
|
+
{ pattern: /curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i, id: "exfil_curl" },
|
|
15
|
+
{ pattern: /wget\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)/i, id: "exfil_wget" },
|
|
16
|
+
{ pattern: /cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass|\.npmrc|\.pypirc)/i, id: "read_secrets" },
|
|
17
|
+
{ pattern: /authorized_keys/i, id: "ssh_backdoor" },
|
|
18
|
+
{ pattern: /\$HOME\/\.ssh|~\/\.ssh/i, id: "ssh_access" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const INVISIBLE_CHARS = new Set([
|
|
22
|
+
'\u200b', '\u200c', '\u200d', '\u2060', '\ufeff',
|
|
23
|
+
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Scan memory content for injection/exfiltration patterns.
|
|
28
|
+
* Returns an error string if blocked, or null if content is safe.
|
|
29
|
+
*/
|
|
30
|
+
export function scanContent(content: string): string | null {
|
|
31
|
+
// Check invisible unicode
|
|
32
|
+
for (const char of content) {
|
|
33
|
+
if (INVISIBLE_CHARS.has(char)) {
|
|
34
|
+
return `Blocked: content contains invisible unicode character U+${char.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')} (possible injection).`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Check threat patterns
|
|
39
|
+
for (const { pattern, id } of MEMORY_THREAT_PATTERNS) {
|
|
40
|
+
if (pattern.test(content)) {
|
|
41
|
+
return `Blocked: content matches threat pattern '${id}'. Memory entries are injected into the system prompt and must not contain injection or exfiltration payloads.`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryStore — core persistent memory with file-backed storage.
|
|
3
|
+
* Ported from hermes-agent/tools/memory_tool.py (MemoryStore class).
|
|
4
|
+
* See PLAN.md → "Hermes Source File Reference Map" for source lines.
|
|
5
|
+
*
|
|
6
|
+
* Design:
|
|
7
|
+
* - Two stores: MEMORY.md (agent notes) and USER.md (user profile)
|
|
8
|
+
* - §-delimited entries with character limits
|
|
9
|
+
* - Frozen snapshot at load time for system prompt (preserves Pi's prompt cache)
|
|
10
|
+
* - Atomic writes via temp file + fs.rename()
|
|
11
|
+
* - Content scanning before any write
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs/promises";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import * as os from "node:os";
|
|
17
|
+
import { scanContent } from "./content-scanner.js";
|
|
18
|
+
import {
|
|
19
|
+
ENTRY_DELIMITER,
|
|
20
|
+
DEFAULT_MEMORY_CHAR_LIMIT,
|
|
21
|
+
DEFAULT_USER_CHAR_LIMIT,
|
|
22
|
+
MEMORY_FILE,
|
|
23
|
+
USER_FILE,
|
|
24
|
+
} from "../constants.js";
|
|
25
|
+
import type { MemoryConfig, MemoryResult, MemorySnapshot } from "../types.js";
|
|
26
|
+
|
|
27
|
+
export class MemoryStore {
|
|
28
|
+
private memoryEntries: string[] = [];
|
|
29
|
+
private userEntries: string[] = [];
|
|
30
|
+
private snapshot: MemorySnapshot = { memory: "", user: "" };
|
|
31
|
+
|
|
32
|
+
constructor(private config: MemoryConfig) {}
|
|
33
|
+
|
|
34
|
+
// ─── Path helpers ───
|
|
35
|
+
|
|
36
|
+
private get memoryDir(): string {
|
|
37
|
+
return this.config.memoryDir ?? path.join(os.homedir(), ".pi", "agent", "memory");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private pathFor(target: "memory" | "user"): string {
|
|
41
|
+
return path.join(this.memoryDir, target === "user" ? USER_FILE : MEMORY_FILE);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private entriesFor(target: "memory" | "user"): string[] {
|
|
45
|
+
return target === "user" ? this.userEntries : this.memoryEntries;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private setEntries(target: "memory" | "user", entries: string[]): void {
|
|
49
|
+
if (target === "user") this.userEntries = entries;
|
|
50
|
+
else this.memoryEntries = entries;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private charLimit(target: "memory" | "user"): number {
|
|
54
|
+
return target === "user" ? this.config.userCharLimit : this.config.memoryCharLimit;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private charCount(target: "memory" | "user"): number {
|
|
58
|
+
const entries = this.entriesFor(target);
|
|
59
|
+
return entries.length ? entries.join(ENTRY_DELIMITER).length : 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Load from disk ───
|
|
63
|
+
|
|
64
|
+
async loadFromDisk(): Promise<void> {
|
|
65
|
+
await fs.mkdir(this.memoryDir, { recursive: true });
|
|
66
|
+
this.memoryEntries = await this.readFile(this.pathFor("memory"));
|
|
67
|
+
this.userEntries = await this.readFile(this.pathFor("user"));
|
|
68
|
+
|
|
69
|
+
// Deduplicate preserving order
|
|
70
|
+
this.memoryEntries = [...new Set(this.memoryEntries)];
|
|
71
|
+
this.userEntries = [...new Set(this.userEntries)];
|
|
72
|
+
|
|
73
|
+
// Capture frozen snapshot for system prompt injection
|
|
74
|
+
this.snapshot = {
|
|
75
|
+
memory: this.renderBlock("memory", this.memoryEntries),
|
|
76
|
+
user: this.renderBlock("user", this.userEntries),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── CRUD ───
|
|
81
|
+
|
|
82
|
+
add(target: "memory" | "user", content: string): MemoryResult {
|
|
83
|
+
content = content.trim();
|
|
84
|
+
if (!content) return { success: false, error: "Content cannot be empty." };
|
|
85
|
+
|
|
86
|
+
const scanError = scanContent(content);
|
|
87
|
+
if (scanError) return { success: false, error: scanError };
|
|
88
|
+
|
|
89
|
+
const entries = this.entriesFor(target);
|
|
90
|
+
const limit = this.charLimit(target);
|
|
91
|
+
|
|
92
|
+
if (entries.includes(content)) {
|
|
93
|
+
return this.successResponse(target, "Entry already exists (no duplicate added).");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const newTotal = [...entries, content].join(ENTRY_DELIMITER).length;
|
|
97
|
+
if (newTotal > limit) {
|
|
98
|
+
const current = this.charCount(target);
|
|
99
|
+
return {
|
|
100
|
+
success: false,
|
|
101
|
+
error: `Memory at ${current}/${limit} chars. Adding this entry (${content.length} chars) would exceed the limit. Replace or remove existing entries first.`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
entries.push(content);
|
|
106
|
+
this.setEntries(target, entries);
|
|
107
|
+
this.saveToDisk(target);
|
|
108
|
+
|
|
109
|
+
return this.successResponse(target, "Entry added.");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
replace(target: "memory" | "user", oldText: string, newContent: string): MemoryResult {
|
|
113
|
+
oldText = oldText.trim();
|
|
114
|
+
newContent = newContent.trim();
|
|
115
|
+
if (!oldText) return { success: false, error: "old_text cannot be empty." };
|
|
116
|
+
if (!newContent) return { success: false, error: "new_content cannot be empty. Use 'remove' to delete entries." };
|
|
117
|
+
|
|
118
|
+
const scanError = scanContent(newContent);
|
|
119
|
+
if (scanError) return { success: false, error: scanError };
|
|
120
|
+
|
|
121
|
+
const entries = this.entriesFor(target);
|
|
122
|
+
const matches = entries.filter((e) => e.includes(oldText));
|
|
123
|
+
|
|
124
|
+
if (matches.length === 0) return { success: false, error: `No entry matched '${oldText}'.` };
|
|
125
|
+
if (matches.length > 1 && new Set(matches).size > 1) {
|
|
126
|
+
return {
|
|
127
|
+
success: false,
|
|
128
|
+
error: `Multiple entries matched '${oldText}'. Be more specific.`,
|
|
129
|
+
matches: matches.map((e) => e.slice(0, 80) + (e.length > 80 ? "..." : "")),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const idx = entries.indexOf(matches[0]);
|
|
134
|
+
const testEntries = [...entries];
|
|
135
|
+
testEntries[idx] = newContent;
|
|
136
|
+
const newTotal = testEntries.join(ENTRY_DELIMITER).length;
|
|
137
|
+
|
|
138
|
+
if (newTotal > this.charLimit(target)) {
|
|
139
|
+
return {
|
|
140
|
+
success: false,
|
|
141
|
+
error: `Replacement would put memory at ${newTotal}/${this.charLimit(target)} chars. Shorten or remove other entries first.`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
entries[idx] = newContent;
|
|
146
|
+
this.setEntries(target, entries);
|
|
147
|
+
this.saveToDisk(target);
|
|
148
|
+
|
|
149
|
+
return this.successResponse(target, "Entry replaced.");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
remove(target: "memory" | "user", oldText: string): MemoryResult {
|
|
153
|
+
oldText = oldText.trim();
|
|
154
|
+
if (!oldText) return { success: false, error: "old_text cannot be empty." };
|
|
155
|
+
|
|
156
|
+
const entries = this.entriesFor(target);
|
|
157
|
+
const matches = entries.filter((e) => e.includes(oldText));
|
|
158
|
+
|
|
159
|
+
if (matches.length === 0) return { success: false, error: `No entry matched '${oldText}'.` };
|
|
160
|
+
if (matches.length > 1 && new Set(matches).size > 1) {
|
|
161
|
+
return {
|
|
162
|
+
success: false,
|
|
163
|
+
error: `Multiple entries matched '${oldText}'. Be more specific.`,
|
|
164
|
+
matches: matches.map((e) => e.slice(0, 80) + (e.length > 80 ? "..." : "")),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const idx = entries.indexOf(matches[0]);
|
|
169
|
+
entries.splice(idx, 1);
|
|
170
|
+
this.setEntries(target, entries);
|
|
171
|
+
this.saveToDisk(target);
|
|
172
|
+
|
|
173
|
+
return this.successResponse(target, "Entry removed.");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── System prompt injection (frozen snapshot) ───
|
|
177
|
+
|
|
178
|
+
formatForSystemPrompt(): string {
|
|
179
|
+
const parts: string[] = [];
|
|
180
|
+
if (this.snapshot.memory) parts.push(this.snapshot.memory);
|
|
181
|
+
if (this.snapshot.user) parts.push(this.snapshot.user);
|
|
182
|
+
return parts.join("\n\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
getMemoryEntries(): string[] {
|
|
186
|
+
return [...this.memoryEntries];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
getUserEntries(): string[] {
|
|
190
|
+
return [...this.userEntries];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── Internal helpers ───
|
|
194
|
+
|
|
195
|
+
private successResponse(target: "memory" | "user", message?: string): MemoryResult {
|
|
196
|
+
const entries = this.entriesFor(target);
|
|
197
|
+
const current = this.charCount(target);
|
|
198
|
+
const limit = this.charLimit(target);
|
|
199
|
+
const pct = limit > 0 ? Math.min(100, Math.floor((current / limit) * 100)) : 0;
|
|
200
|
+
|
|
201
|
+
const resp: MemoryResult = {
|
|
202
|
+
success: true,
|
|
203
|
+
target,
|
|
204
|
+
entries,
|
|
205
|
+
usage: `${pct}% — ${current}/${limit} chars`,
|
|
206
|
+
entry_count: entries.length,
|
|
207
|
+
};
|
|
208
|
+
if (message) resp.message = message;
|
|
209
|
+
return resp;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private renderBlock(target: "memory" | "user", entries: string[]): string {
|
|
213
|
+
if (!entries.length) return "";
|
|
214
|
+
const limit = this.charLimit(target);
|
|
215
|
+
const content = entries.join(ENTRY_DELIMITER);
|
|
216
|
+
const current = content.length;
|
|
217
|
+
const pct = limit > 0 ? Math.min(100, Math.floor((current / limit) * 100)) : 0;
|
|
218
|
+
|
|
219
|
+
const header = target === "user"
|
|
220
|
+
? `USER PROFILE (who the user is) [${pct}% — ${current}/${limit} chars]`
|
|
221
|
+
: `MEMORY (your personal notes) [${pct}% — ${current}/${limit} chars]`;
|
|
222
|
+
|
|
223
|
+
const separator = "═".repeat(46);
|
|
224
|
+
return `${separator}\n${header}\n${separator}\n${content}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private async readFile(filePath: string): Promise<string[]> {
|
|
228
|
+
try {
|
|
229
|
+
const raw = await fs.readFile(filePath, "utf-8");
|
|
230
|
+
if (!raw.trim()) return [];
|
|
231
|
+
return raw.split(ENTRY_DELIMITER).map((e) => e.trim()).filter(Boolean);
|
|
232
|
+
} catch {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Atomic write: temp file + fs.rename() — same crash-safety as Hermes. */
|
|
238
|
+
private saveToDisk(target: "memory" | "user"): void {
|
|
239
|
+
const filePath = this.pathFor(target);
|
|
240
|
+
const entries = this.entriesFor(target);
|
|
241
|
+
const content = entries.length ? entries.join(ENTRY_DELIMITER) : "";
|
|
242
|
+
|
|
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
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory tool — registers the LLM-callable `memory` tool.
|
|
3
|
+
* Ported from hermes-agent/tools/memory_tool.py (MEMORY_SCHEMA + memory_tool dispatch).
|
|
4
|
+
* See PLAN.md → "Hermes Source File Reference Map" for source lines.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
10
|
+
import { MemoryStore } from "../store/memory-store.js";
|
|
11
|
+
import { MEMORY_TOOL_DESCRIPTION } from "../constants.js";
|
|
12
|
+
|
|
13
|
+
export function registerMemoryTool(pi: ExtensionAPI, store: MemoryStore): void {
|
|
14
|
+
pi.registerTool({
|
|
15
|
+
name: "memory",
|
|
16
|
+
label: "Memory",
|
|
17
|
+
description: MEMORY_TOOL_DESCRIPTION,
|
|
18
|
+
promptSnippet:
|
|
19
|
+
"Save or manage persistent memory that survives across sessions",
|
|
20
|
+
promptGuidelines: [
|
|
21
|
+
"Use the memory tool proactively when the user corrects you, shares a preference, or reveals personal details worth remembering.",
|
|
22
|
+
"Use the memory tool when you discover environment facts, project conventions, or reusable patterns useful in future sessions.",
|
|
23
|
+
"Do NOT use memory for temporary task state, TODO items, or session progress — only for durable, cross-session facts.",
|
|
24
|
+
],
|
|
25
|
+
parameters: Type.Object({
|
|
26
|
+
action: StringEnum(["add", "replace", "remove"] as const),
|
|
27
|
+
target: StringEnum(["memory", "user"] as const),
|
|
28
|
+
content: Type.Optional(
|
|
29
|
+
Type.String({ description: "Entry content for add/replace" })
|
|
30
|
+
),
|
|
31
|
+
old_text: Type.Optional(
|
|
32
|
+
Type.String({
|
|
33
|
+
description:
|
|
34
|
+
"Substring identifying entry for replace/remove",
|
|
35
|
+
})
|
|
36
|
+
),
|
|
37
|
+
}),
|
|
38
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
39
|
+
const { action, target, content, old_text } = params;
|
|
40
|
+
|
|
41
|
+
let result;
|
|
42
|
+
switch (action) {
|
|
43
|
+
case "add":
|
|
44
|
+
if (!content) {
|
|
45
|
+
return {
|
|
46
|
+
content: [
|
|
47
|
+
{
|
|
48
|
+
type: "text",
|
|
49
|
+
text: JSON.stringify({
|
|
50
|
+
success: false,
|
|
51
|
+
error: "Content is required for 'add' action.",
|
|
52
|
+
}),
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
details: {},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
result = store.add(target, content);
|
|
59
|
+
break;
|
|
60
|
+
|
|
61
|
+
case "replace":
|
|
62
|
+
if (!old_text) {
|
|
63
|
+
return {
|
|
64
|
+
content: [
|
|
65
|
+
{
|
|
66
|
+
type: "text",
|
|
67
|
+
text: JSON.stringify({
|
|
68
|
+
success: false,
|
|
69
|
+
error: "old_text is required for 'replace' action.",
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
details: {},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (!content) {
|
|
77
|
+
return {
|
|
78
|
+
content: [
|
|
79
|
+
{
|
|
80
|
+
type: "text",
|
|
81
|
+
text: JSON.stringify({
|
|
82
|
+
success: false,
|
|
83
|
+
error: "content is required for 'replace' action.",
|
|
84
|
+
}),
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
details: {},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
result = store.replace(target, old_text, content);
|
|
91
|
+
break;
|
|
92
|
+
|
|
93
|
+
case "remove":
|
|
94
|
+
if (!old_text) {
|
|
95
|
+
return {
|
|
96
|
+
content: [
|
|
97
|
+
{
|
|
98
|
+
type: "text",
|
|
99
|
+
text: JSON.stringify({
|
|
100
|
+
success: false,
|
|
101
|
+
error: "old_text is required for 'remove' action.",
|
|
102
|
+
}),
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
details: {},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
result = store.remove(target, old_text);
|
|
109
|
+
break;
|
|
110
|
+
|
|
111
|
+
default:
|
|
112
|
+
result = {
|
|
113
|
+
success: false,
|
|
114
|
+
error: `Unknown action '${action}'. Use: add, replace, remove`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
120
|
+
details: result,
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared TypeScript types for the Hermes Memory extension.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { TextContent } from "@mariozechner/pi-ai";
|
|
6
|
+
|
|
7
|
+
export interface MemoryConfig {
|
|
8
|
+
/** Max chars for MEMORY.md (agent notes). Default: 2200 */
|
|
9
|
+
memoryCharLimit: number;
|
|
10
|
+
/** Max chars for USER.md (user profile). Default: 1375 */
|
|
11
|
+
userCharLimit: number;
|
|
12
|
+
/** Turns between background auto-reviews. Default: 10 */
|
|
13
|
+
nudgeInterval: number;
|
|
14
|
+
/** Enable background learning loop. Default: true */
|
|
15
|
+
reviewEnabled: boolean;
|
|
16
|
+
/** Flush memories before compaction. Default: true */
|
|
17
|
+
flushOnCompact: boolean;
|
|
18
|
+
/** Flush memories on session shutdown. Default: true */
|
|
19
|
+
flushOnShutdown: boolean;
|
|
20
|
+
/** Minimum user turns before flush triggers. Default: 6 */
|
|
21
|
+
flushMinTurns: number;
|
|
22
|
+
/** Override memory directory. Default: ~/.pi/agent/memory */
|
|
23
|
+
memoryDir?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MemoryResult {
|
|
27
|
+
success: boolean;
|
|
28
|
+
error?: string;
|
|
29
|
+
message?: string;
|
|
30
|
+
target?: "memory" | "user";
|
|
31
|
+
entries?: string[];
|
|
32
|
+
usage?: string;
|
|
33
|
+
entry_count?: number;
|
|
34
|
+
matches?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MemorySnapshot {
|
|
38
|
+
memory: string;
|
|
39
|
+
user: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extract displayable text from a Pi session entry message.
|
|
44
|
+
*
|
|
45
|
+
* Accepts any value — returns null for non-message entries (BashExecutionMessage,
|
|
46
|
+
* NotificationMessage, etc.) that lack a `content` property.
|
|
47
|
+
*
|
|
48
|
+
* Returns the concatenated text, truncated to `maxLength` chars.
|
|
49
|
+
*/
|
|
50
|
+
export function getMessageText(msg: unknown, maxLength = 500): string | null {
|
|
51
|
+
if (typeof msg !== "object" || msg === null) return null;
|
|
52
|
+
const { role, content } = msg as Record<string, unknown>;
|
|
53
|
+
if (typeof role !== "string") return null;
|
|
54
|
+
|
|
55
|
+
if (typeof content === "string") {
|
|
56
|
+
return content.slice(0, maxLength);
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(content)) {
|
|
59
|
+
const text = (content as TextContent[])
|
|
60
|
+
.filter((block): block is TextContent => block.type === "text" && typeof block.text === "string")
|
|
61
|
+
.map((block) => block.text)
|
|
62
|
+
.join("\n");
|
|
63
|
+
return text.length > 0 ? text.slice(0, maxLength) : null;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|