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/README.md +209 -78
- package/docs/0.2/PLAN.md +290 -0
- package/docs/0.2/TASKS.md +134 -0
- package/docs/0.2/TEST-PLAN.md +216 -0
- package/docs/ROADMAP.md +245 -135
- package/package.json +9 -5
- package/src/config.ts +11 -0
- package/src/constants.ts +78 -3
- package/src/handlers/auto-consolidate.ts +94 -0
- package/src/handlers/background-review.ts +42 -3
- package/src/handlers/correction-detector.ts +156 -0
- package/src/handlers/insights.ts +20 -1
- package/src/handlers/session-flush.ts +1 -0
- package/src/handlers/skill-auto-trigger.ts +108 -0
- package/src/handlers/skills-command.ts +38 -0
- package/src/index.ts +66 -13
- package/src/store/memory-store.ts +75 -21
- package/src/store/skill-store.ts +292 -0
- package/src/tools/memory-tool.ts +25 -6
- package/src/tools/skill-tool.ts +142 -0
- package/src/types.ts +42 -0
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.
|
|
10
|
+
* 4. Auto-Consolidation — merges 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
|
|
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
|
-
|
|
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" +
|
|
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
|
-
// ──
|
|
47
|
-
|
|
98
|
+
// ── 8. Setup correction detection ──
|
|
99
|
+
setupCorrectionDetector(pi, store, projectStore, config);
|
|
48
100
|
|
|
49
|
-
// ──
|
|
50
|
-
|
|
101
|
+
// ── 9. Setup skill auto-trigger ──
|
|
102
|
+
setupSkillAutoTrigger(pi, store, skillStore, config);
|
|
51
103
|
|
|
52
|
-
// ──
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
+
}
|
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 =
|
|
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,
|