pi-hermes-memory 0.1.0 → 0.2.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.
@@ -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
+ }
@@ -55,7 +55,7 @@ export function registerMemoryTool(pi: ExtensionAPI, store: MemoryStore): void {
55
55
  details: {},
56
56
  };
57
57
  }
58
- result = store.add(target, content);
58
+ result = await store.add(target, content);
59
59
  break;
60
60
 
61
61
  case "replace":
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Skill tool — registers the LLM-callable `skill` tool for procedural memory.
3
+ * Complements the `memory` tool (declarative knowledge) with procedural knowledge.
4
+ */
5
+
6
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
7
+ import { Type } from "typebox";
8
+ import { StringEnum } from "@mariozechner/pi-ai";
9
+ import { SkillStore } from "../store/skill-store.js";
10
+ import { SKILL_TOOL_DESCRIPTION } from "../constants.js";
11
+
12
+ export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
13
+ pi.registerTool({
14
+ name: "skill",
15
+ label: "Skill",
16
+ description: SKILL_TOOL_DESCRIPTION,
17
+ promptSnippet: "Save or manage reusable procedures and patterns",
18
+ promptGuidelines: [
19
+ "Use the skill tool after completing complex tasks that required trial and error or multiple tool calls.",
20
+ "Use 'create' to save a new reusable procedure, 'patch' to update a section of an existing skill.",
21
+ "Do NOT use skills for temporary task state — only for durable, reusable procedures.",
22
+ ],
23
+ parameters: Type.Object({
24
+ action: StringEnum(["create", "view", "patch", "edit", "delete"] as const),
25
+ name: Type.Optional(
26
+ Type.String({ description: "Skill name (for create). e.g., 'debug-typescript-errors'" })
27
+ ),
28
+ file_name: Type.Optional(
29
+ Type.String({ description: "Skill file name (for view/patch/edit/delete). e.g., 'debug-typescript-errors.md'" })
30
+ ),
31
+ description: Type.Optional(
32
+ Type.String({ description: "One-line description of when to use this skill (for create/edit)" })
33
+ ),
34
+ section: Type.Optional(
35
+ Type.String({ description: "Section header to patch (for patch action). e.g., 'Procedure', 'Pitfalls'" })
36
+ ),
37
+ content: Type.Optional(
38
+ Type.String({ description: "Body content for create, new section content for patch, or new body for edit" })
39
+ ),
40
+ }),
41
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
42
+ const { action, name, file_name, description, section, content } = params;
43
+
44
+ let result;
45
+ switch (action) {
46
+ case "create":
47
+ if (!name) {
48
+ return {
49
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: "name is required for 'create' action." }) }],
50
+ details: {},
51
+ };
52
+ }
53
+ if (!description) {
54
+ return {
55
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: "description is required for 'create' action." }) }],
56
+ details: {},
57
+ };
58
+ }
59
+ if (!content) {
60
+ return {
61
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: "content (skill body) is required for 'create' action." }) }],
62
+ details: {},
63
+ };
64
+ }
65
+ result = await store.create(name, description, content);
66
+ break;
67
+
68
+ case "view":
69
+ if (!file_name) {
70
+ // List all skills
71
+ const index = await store.loadIndex();
72
+ return {
73
+ content: [{ type: "text", text: JSON.stringify({ success: true, skills: index }) }],
74
+ details: { skills: index },
75
+ };
76
+ }
77
+ const doc = await store.loadSkill(file_name);
78
+ if (!doc) {
79
+ return {
80
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: `Skill '${file_name}' not found.` }) }],
81
+ details: {},
82
+ };
83
+ }
84
+ result = { success: true, ...doc };
85
+ break;
86
+
87
+ case "patch":
88
+ if (!file_name) {
89
+ return {
90
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: "file_name is required for 'patch' action." }) }],
91
+ details: {},
92
+ };
93
+ }
94
+ if (!section) {
95
+ return {
96
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: "section is required for 'patch' action." }) }],
97
+ details: {},
98
+ };
99
+ }
100
+ if (!content) {
101
+ return {
102
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: "content is required for 'patch' action." }) }],
103
+ details: {},
104
+ };
105
+ }
106
+ result = await store.patch(file_name, section, content);
107
+ break;
108
+
109
+ case "edit":
110
+ if (!file_name) {
111
+ return {
112
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: "file_name is required for 'edit' action." }) }],
113
+ details: {},
114
+ };
115
+ }
116
+ result = await store.edit(file_name, description || "", content || "");
117
+ break;
118
+
119
+ case "delete":
120
+ if (!file_name) {
121
+ return {
122
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: "file_name is required for 'delete' action." }) }],
123
+ details: {},
124
+ };
125
+ }
126
+ result = await store.delete(file_name);
127
+ break;
128
+
129
+ default:
130
+ result = {
131
+ success: false,
132
+ error: `Unknown action '${action}'. Use: create, view, patch, edit, delete`,
133
+ };
134
+ }
135
+
136
+ return {
137
+ content: [{ type: "text", text: JSON.stringify(result) }],
138
+ details: result,
139
+ };
140
+ },
141
+ });
142
+ }
package/src/types.ts CHANGED
@@ -21,6 +21,12 @@ export interface MemoryConfig {
21
21
  flushMinTurns: number;
22
22
  /** Override memory directory. Default: ~/.pi/agent/memory */
23
23
  memoryDir?: string;
24
+ /** Auto-consolidate when memory is full instead of returning error. Default: true */
25
+ autoConsolidate: boolean;
26
+ /** Detect user corrections and trigger immediate memory save. Default: true */
27
+ correctionDetection: boolean;
28
+ /** Tool calls before triggering background review (in addition to turn count). Default: 15 */
29
+ nudgeToolCalls: number;
24
30
  }
25
31
 
26
32
  export interface MemoryResult {
@@ -39,6 +45,40 @@ export interface MemorySnapshot {
39
45
  user: string;
40
46
  }
41
47
 
48
+ export interface ConsolidationResult {
49
+ /** Whether consolidation succeeded */
50
+ consolidated: boolean;
51
+ /** Error message if consolidation failed */
52
+ error?: string;
53
+ }
54
+
55
+ export interface SkillIndex {
56
+ /** File name (slug.md) */
57
+ fileName: string;
58
+ /** Human-readable name */
59
+ name: string;
60
+ /** Short description for system prompt index */
61
+ description: string;
62
+ }
63
+
64
+ export interface SkillDocument extends SkillIndex {
65
+ /** Full markdown body (after frontmatter) */
66
+ body: string;
67
+ /** Version number */
68
+ version: number;
69
+ /** ISO date created */
70
+ created: string;
71
+ /** ISO date last updated */
72
+ updated: string;
73
+ }
74
+
75
+ export interface SkillResult {
76
+ success: boolean;
77
+ error?: string;
78
+ message?: string;
79
+ fileName?: string;
80
+ }
81
+
42
82
  /**
43
83
  * Extract displayable text from a Pi session entry message.
44
84
  *