pi-hermes-memory 0.7.5 → 0.7.7
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 +59 -20
- package/docs/PUBLISHING.md +1 -1
- package/docs/ROADMAP.md +1 -1
- package/package.json +4 -4
- package/src/config.ts +3 -0
- package/src/constants.ts +8 -3
- package/src/handlers/auto-consolidate.ts +5 -3
- package/src/handlers/background-review.ts +1 -1
- package/src/handlers/correction-detector.ts +1 -1
- package/src/handlers/index-sessions.ts +1 -1
- package/src/handlers/insights.ts +1 -1
- package/src/handlers/interview.ts +1 -1
- package/src/handlers/learn-memory.ts +4 -4
- package/src/handlers/preview-context.ts +5 -15
- package/src/handlers/session-flush.ts +1 -1
- package/src/handlers/skill-auto-trigger.ts +24 -4
- package/src/handlers/skills-command.ts +26 -6
- package/src/handlers/switch-project.ts +1 -1
- package/src/handlers/sync-markdown-memories.ts +1 -1
- package/src/index.ts +54 -11
- package/src/project.ts +13 -0
- package/src/prompt-context.ts +0 -4
- package/src/skills/procedural-skill-creator/SKILL.md +146 -0
- package/src/store/skill-store.ts +463 -181
- package/src/store/skill-utils.ts +116 -0
- package/src/store/sqlite-memory-store.ts +42 -4
- package/src/tools/memory-search-tool.ts +2 -2
- package/src/tools/memory-tool.ts +29 -2
- package/src/tools/session-search-tool.ts +2 -2
- package/src/tools/skill-tool.ts +23 -20
- package/src/types.ts +24 -4
package/src/store/skill-store.ts
CHANGED
|
@@ -1,111 +1,171 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SkillStore — procedural memory stored as
|
|
2
|
+
* SkillStore — procedural memory stored as Pi-native skills.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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.
|
|
4
|
+
* Global skills live in ~/.pi/agent/skills/<slug>/SKILL.md.
|
|
5
|
+
* Project skills live in ~/.pi/agent/<projectsMemoryDir>/<project>/skills/<slug>/SKILL.md.
|
|
11
6
|
*/
|
|
12
7
|
|
|
13
8
|
import * as fs from "node:fs/promises";
|
|
14
9
|
import * as path from "node:path";
|
|
15
10
|
import * as os from "node:os";
|
|
16
11
|
import { scanContent } from "./content-scanner.js";
|
|
17
|
-
import
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
12
|
+
import {
|
|
13
|
+
buildSkillId,
|
|
14
|
+
exists,
|
|
15
|
+
formatFrontmatter,
|
|
16
|
+
jaccardSimilarity,
|
|
17
|
+
parseFrontmatter,
|
|
18
|
+
parseSkillId,
|
|
19
|
+
slugify,
|
|
20
|
+
today,
|
|
21
|
+
tokenizeForSimilarity,
|
|
22
|
+
} from "./skill-utils.js";
|
|
23
|
+
import type { SkillDocument, SkillIndex, SkillResult, SkillScope } from "../types.js";
|
|
24
|
+
|
|
25
|
+
interface SkillStoreOptions {
|
|
26
|
+
globalSkillsDir?: string;
|
|
27
|
+
projectSkillsDir?: string | null;
|
|
28
|
+
projectName?: string | null;
|
|
29
|
+
legacySkillsDir?: string;
|
|
30
|
+
migrationSentinelPath?: string;
|
|
31
|
+
}
|
|
24
32
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return { meta, body: match[2].trim() };
|
|
33
|
+
interface SkillLocation {
|
|
34
|
+
skillId: string;
|
|
35
|
+
scope: SkillScope;
|
|
36
|
+
slug: string;
|
|
37
|
+
fileName: string;
|
|
38
|
+
path: string;
|
|
39
|
+
projectName?: string;
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
`description: ${doc.description}`,
|
|
42
|
-
`version: ${doc.version}`,
|
|
43
|
-
`created: ${doc.created}`,
|
|
44
|
-
`updated: ${doc.updated}`,
|
|
45
|
-
"---",
|
|
46
|
-
doc.body,
|
|
47
|
-
].join("\n");
|
|
42
|
+
export interface LegacySkillMigrationResult {
|
|
43
|
+
migrated: number;
|
|
44
|
+
skipped: number;
|
|
45
|
+
warnings: string[];
|
|
48
46
|
}
|
|
49
47
|
|
|
50
|
-
|
|
48
|
+
export class SkillStore {
|
|
49
|
+
private globalSkillsDir: string;
|
|
50
|
+
private projectSkillsDir: string | null;
|
|
51
|
+
private projectName: string | null;
|
|
52
|
+
private legacySkillsDir: string;
|
|
53
|
+
private migrationSentinelPath: string;
|
|
54
|
+
|
|
55
|
+
constructor(options: SkillStoreOptions = {}) {
|
|
56
|
+
const agentRoot = path.join(os.homedir(), ".pi", "agent");
|
|
57
|
+
this.globalSkillsDir = options.globalSkillsDir ?? path.join(agentRoot, "skills");
|
|
58
|
+
this.projectSkillsDir = options.projectSkillsDir ?? null;
|
|
59
|
+
this.projectName = options.projectName ?? null;
|
|
60
|
+
this.legacySkillsDir = options.legacySkillsDir ?? path.join(agentRoot, "memory", "skills");
|
|
61
|
+
this.migrationSentinelPath = options.migrationSentinelPath
|
|
62
|
+
?? path.join(agentRoot, "memory", ".skills-migrated-to-pi-native");
|
|
63
|
+
}
|
|
51
64
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
56
|
-
.replace(/^-|-$/g, "")
|
|
57
|
-
.slice(0, 64);
|
|
58
|
-
}
|
|
65
|
+
getGlobalSkillsDir(): string {
|
|
66
|
+
return this.globalSkillsDir;
|
|
67
|
+
}
|
|
59
68
|
|
|
60
|
-
|
|
69
|
+
getProjectSkillsDir(): string | null {
|
|
70
|
+
return this.projectSkillsDir;
|
|
71
|
+
}
|
|
61
72
|
|
|
62
|
-
|
|
63
|
-
|
|
73
|
+
getProjectName(): string | null {
|
|
74
|
+
return this.projectName;
|
|
75
|
+
}
|
|
64
76
|
|
|
65
|
-
|
|
66
|
-
this.
|
|
77
|
+
setProjectContext(projectName: string | null, projectSkillsDir: string | null): void {
|
|
78
|
+
this.projectName = projectName;
|
|
79
|
+
this.projectSkillsDir = projectSkillsDir;
|
|
67
80
|
}
|
|
68
81
|
|
|
69
|
-
|
|
82
|
+
async ensureDiscoveredRoots(): Promise<void> {
|
|
83
|
+
await fs.mkdir(this.globalSkillsDir, { recursive: true });
|
|
84
|
+
if (this.projectSkillsDir) {
|
|
85
|
+
await fs.mkdir(this.projectSkillsDir, { recursive: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
70
88
|
|
|
71
|
-
async
|
|
72
|
-
|
|
73
|
-
const files = await fs.readdir(this.skillsDir);
|
|
74
|
-
const skills: SkillIndex[] = [];
|
|
89
|
+
async migrateLegacySkills(): Promise<LegacySkillMigrationResult> {
|
|
90
|
+
const result: LegacySkillMigrationResult = { migrated: 0, skipped: 0, warnings: [] };
|
|
75
91
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
if (await exists(this.migrationSentinelPath)) return result;
|
|
93
|
+
|
|
94
|
+
await fs.mkdir(path.dirname(this.migrationSentinelPath), { recursive: true });
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
if (!await exists(this.legacySkillsDir)) return result;
|
|
98
|
+
|
|
99
|
+
const files = (await fs.readdir(this.legacySkillsDir))
|
|
100
|
+
.filter((file) => file.endsWith(".md"))
|
|
101
|
+
.sort();
|
|
102
|
+
|
|
103
|
+
for (const file of files) {
|
|
104
|
+
const legacyPath = path.join(this.legacySkillsDir, file);
|
|
105
|
+
try {
|
|
106
|
+
const raw = await fs.readFile(legacyPath, "utf-8");
|
|
107
|
+
const parsed = parseFrontmatter(raw);
|
|
108
|
+
const fallbackSlug = slugify(path.basename(file, ".md"));
|
|
109
|
+
const slug = slugify(parsed.meta.name || fallbackSlug);
|
|
110
|
+
if (!slug) {
|
|
111
|
+
result.skipped++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const targetPath = path.join(this.globalSkillsDir, slug, "SKILL.md");
|
|
116
|
+
if (await exists(targetPath)) {
|
|
117
|
+
result.skipped++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const skillDoc = {
|
|
122
|
+
name: slug,
|
|
123
|
+
displayName: parsed.meta.display_name?.trim() || parsed.meta.name?.trim() || undefined,
|
|
124
|
+
description: parsed.meta.description?.trim() || `Migrated legacy skill: ${slug}`,
|
|
125
|
+
version: Number.parseInt(parsed.meta.version || "1", 10) || 1,
|
|
126
|
+
created: parsed.meta.created || today(),
|
|
127
|
+
updated: parsed.meta.updated || today(),
|
|
128
|
+
body: parsed.body || `# ${slug}\n`,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
132
|
+
await this.atomicWrite(targetPath, formatFrontmatter(skillDoc));
|
|
133
|
+
result.migrated++;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
result.warnings.push(`${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} finally {
|
|
139
|
+
if (result.warnings.length === 0) {
|
|
140
|
+
await fs.writeFile(this.migrationSentinelPath, `${new Date().toISOString()}\n`, "utf-8");
|
|
81
141
|
}
|
|
82
142
|
}
|
|
83
143
|
|
|
84
|
-
return
|
|
144
|
+
return result;
|
|
85
145
|
}
|
|
86
146
|
|
|
87
|
-
async
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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;
|
|
147
|
+
async loadIndex(scope?: SkillScope): Promise<SkillIndex[]> {
|
|
148
|
+
const locations = await this.collectLocations(scope);
|
|
149
|
+
const skills: SkillIndex[] = [];
|
|
150
|
+
|
|
151
|
+
for (const location of locations) {
|
|
152
|
+
const doc = await this.readLocation(location);
|
|
153
|
+
if (doc) skills.push(this.toIndex(doc));
|
|
103
154
|
}
|
|
155
|
+
|
|
156
|
+
return skills.sort((a, b) => {
|
|
157
|
+
if (a.scope !== b.scope) return a.scope.localeCompare(b.scope);
|
|
158
|
+
return (a.displayName || a.name).localeCompare(b.displayName || b.name);
|
|
159
|
+
});
|
|
104
160
|
}
|
|
105
161
|
|
|
106
|
-
|
|
162
|
+
async loadSkill(skillId: string): Promise<SkillDocument | null> {
|
|
163
|
+
const location = await this.findLocationById(skillId);
|
|
164
|
+
if (!location) return null;
|
|
165
|
+
return this.readLocation(location);
|
|
166
|
+
}
|
|
107
167
|
|
|
108
|
-
async create(name: string, description: string, body: string): Promise<SkillResult> {
|
|
168
|
+
async create(name: string, description: string, body: string, scope?: SkillScope): Promise<SkillResult> {
|
|
109
169
|
name = name.trim();
|
|
110
170
|
description = description.trim();
|
|
111
171
|
body = body.trim();
|
|
@@ -114,55 +174,92 @@ export class SkillStore {
|
|
|
114
174
|
if (!description) return { success: false, error: "Skill description is required." };
|
|
115
175
|
if (!body) return { success: false, error: "Skill body is required." };
|
|
116
176
|
|
|
117
|
-
|
|
118
|
-
const scanError = scanContent(name + " " + description + " " + body);
|
|
177
|
+
const scanError = scanContent(`${name} ${description} ${body}`);
|
|
119
178
|
if (scanError) return { success: false, error: scanError };
|
|
120
179
|
|
|
121
180
|
const slug = slugify(name);
|
|
122
181
|
if (!slug) return { success: false, error: "Skill name produces empty slug." };
|
|
123
182
|
|
|
124
|
-
const
|
|
125
|
-
const
|
|
183
|
+
const resolvedScope = this.resolveScope(scope, name, description, body);
|
|
184
|
+
const root = this.getScopeRoot(resolvedScope);
|
|
185
|
+
if (!root) {
|
|
186
|
+
return { success: false, error: "Project skills require an active project." };
|
|
187
|
+
}
|
|
126
188
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
189
|
+
const skillId = buildSkillId(resolvedScope, slug, this.projectName);
|
|
190
|
+
const existing = await this.findLocationById(skillId);
|
|
191
|
+
if (existing) {
|
|
130
192
|
return {
|
|
131
193
|
success: false,
|
|
132
|
-
error: `Skill '${
|
|
194
|
+
error: `Skill '${slug}' already exists (${skillId}). Use 'patch' or 'edit' to update it.`,
|
|
195
|
+
conflictType: "duplicate",
|
|
196
|
+
similarSkillIds: [skillId],
|
|
197
|
+
suggestedAction: "patch",
|
|
133
198
|
};
|
|
134
|
-
} catch {
|
|
135
|
-
// File doesn't exist — good
|
|
136
199
|
}
|
|
137
200
|
|
|
138
|
-
|
|
201
|
+
if (resolvedScope === "global") {
|
|
202
|
+
const similarSkillIds = await this.findSimilarGlobalSkillIds(slug, description);
|
|
203
|
+
if (similarSkillIds.length > 0) {
|
|
204
|
+
const targetId = similarSkillIds[0];
|
|
205
|
+
return {
|
|
206
|
+
success: false,
|
|
207
|
+
error: `A similar global skill already exists (${targetId}). Enhance the existing skill with new learnings/failures using 'patch' or 'edit' instead of creating a duplicate.`,
|
|
208
|
+
conflictType: "similar",
|
|
209
|
+
similarSkillIds,
|
|
210
|
+
suggestedAction: "patch",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const collidingNameSkillIds = await this.findNameCollisionGlobalSkillIds(slug, description);
|
|
215
|
+
if (collidingNameSkillIds.length > 0) {
|
|
216
|
+
const targetId = collidingNameSkillIds[0];
|
|
217
|
+
return {
|
|
218
|
+
success: false,
|
|
219
|
+
error: `A near-name global skill already exists (${targetId}) but with different intent. Use a clearer differentiated name for the new skill, or patch/edit the existing skill if the intent is actually the same.`,
|
|
220
|
+
conflictType: "name-collision",
|
|
221
|
+
similarSkillIds: collidingNameSkillIds,
|
|
222
|
+
suggestedAction: "rename",
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const filePath = path.join(root, slug, "SKILL.md");
|
|
228
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
139
229
|
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
|
|
230
|
+
const displayName = name;
|
|
231
|
+
const storedName = slug;
|
|
232
|
+
const stamp = today();
|
|
233
|
+
await this.atomicWrite(filePath, formatFrontmatter({
|
|
234
|
+
name: storedName,
|
|
235
|
+
displayName,
|
|
143
236
|
description,
|
|
144
237
|
version: 1,
|
|
145
|
-
created:
|
|
146
|
-
updated:
|
|
238
|
+
created: stamp,
|
|
239
|
+
updated: stamp,
|
|
147
240
|
body,
|
|
241
|
+
}));
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
success: true,
|
|
245
|
+
message: `Skill '${displayName}' created as a ${resolvedScope} skill.`,
|
|
246
|
+
fileName: path.basename(filePath),
|
|
247
|
+
skillId,
|
|
248
|
+
scope: resolvedScope,
|
|
249
|
+
path: filePath,
|
|
148
250
|
};
|
|
149
|
-
|
|
150
|
-
await this.atomicWrite(fileName, formatFrontmatter(doc));
|
|
151
|
-
|
|
152
|
-
return { success: true, message: `Skill '${name}' created.`, fileName };
|
|
153
251
|
}
|
|
154
252
|
|
|
155
|
-
async patch(
|
|
253
|
+
async patch(skillId: string, section: string, newContent: string): Promise<SkillResult> {
|
|
156
254
|
newContent = newContent.trim();
|
|
157
255
|
if (!newContent) return { success: false, error: "New content is required for patch." };
|
|
158
256
|
|
|
159
257
|
const scanError = scanContent(newContent);
|
|
160
258
|
if (scanError) return { success: false, error: scanError };
|
|
161
259
|
|
|
162
|
-
const doc = await this.loadSkill(
|
|
163
|
-
if (!doc) return { success: false, error: `Skill
|
|
260
|
+
const doc = await this.loadSkill(skillId);
|
|
261
|
+
if (!doc) return { success: false, error: `Skill '${skillId}' not found.` };
|
|
164
262
|
|
|
165
|
-
// Replace or append the section in the body
|
|
166
263
|
const sectionHeader = `## ${section}`;
|
|
167
264
|
const lines = doc.body.split("\n");
|
|
168
265
|
let found = false;
|
|
@@ -170,45 +267,40 @@ export class SkillStore {
|
|
|
170
267
|
|
|
171
268
|
for (let i = 0; i < lines.length; i++) {
|
|
172
269
|
if (lines[i].startsWith(sectionHeader)) {
|
|
173
|
-
// Replace this section — skip old content until next section or end
|
|
174
270
|
result.push(sectionHeader);
|
|
175
271
|
result.push(newContent);
|
|
176
272
|
found = true;
|
|
177
|
-
// Skip lines until next ## header or end
|
|
178
273
|
i++;
|
|
179
|
-
while (i < lines.length && !lines[i].startsWith("## "))
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
// Don't skip the next ## header
|
|
183
|
-
if (i < lines.length) {
|
|
184
|
-
result.push(lines[i]);
|
|
185
|
-
}
|
|
274
|
+
while (i < lines.length && !lines[i].startsWith("## ")) i++;
|
|
275
|
+
if (i < lines.length) result.push(lines[i]);
|
|
186
276
|
} else {
|
|
187
277
|
result.push(lines[i]);
|
|
188
278
|
}
|
|
189
279
|
}
|
|
190
280
|
|
|
191
|
-
if (!found)
|
|
192
|
-
// Append the section
|
|
193
|
-
result.push("", sectionHeader, newContent);
|
|
194
|
-
}
|
|
281
|
+
if (!found) result.push("", sectionHeader, newContent);
|
|
195
282
|
|
|
196
|
-
|
|
197
|
-
const updated: Omit<SkillDocument, "fileName"> = {
|
|
283
|
+
await this.atomicWrite(doc.path, formatFrontmatter({
|
|
198
284
|
name: doc.name,
|
|
285
|
+
displayName: doc.displayName,
|
|
199
286
|
description: doc.description,
|
|
200
287
|
version: doc.version + 1,
|
|
201
288
|
created: doc.created,
|
|
202
|
-
updated: today,
|
|
289
|
+
updated: today(),
|
|
203
290
|
body: result.join("\n").trim(),
|
|
291
|
+
}));
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
success: true,
|
|
295
|
+
message: `Skill '${doc.displayName || doc.name}' section '${section}' updated.`,
|
|
296
|
+
fileName: doc.fileName,
|
|
297
|
+
skillId: doc.skillId,
|
|
298
|
+
scope: doc.scope,
|
|
299
|
+
path: doc.path,
|
|
204
300
|
};
|
|
205
|
-
|
|
206
|
-
await this.atomicWrite(fileName, formatFrontmatter(updated));
|
|
207
|
-
|
|
208
|
-
return { success: true, message: `Skill '${doc.name}' section '${section}' updated.`, fileName };
|
|
209
301
|
}
|
|
210
302
|
|
|
211
|
-
async edit(
|
|
303
|
+
async edit(skillId: string, description: string, body: string): Promise<SkillResult> {
|
|
212
304
|
description = description.trim();
|
|
213
305
|
body = body.trim();
|
|
214
306
|
|
|
@@ -216,91 +308,281 @@ export class SkillStore {
|
|
|
216
308
|
return { success: false, error: "At least one of description or body is required." };
|
|
217
309
|
}
|
|
218
310
|
|
|
219
|
-
const doc = await this.loadSkill(
|
|
220
|
-
if (!doc) return { success: false, error: `Skill
|
|
311
|
+
const doc = await this.loadSkill(skillId);
|
|
312
|
+
if (!doc) return { success: false, error: `Skill '${skillId}' not found.` };
|
|
221
313
|
|
|
222
|
-
const
|
|
314
|
+
const newDescription = description || doc.description;
|
|
223
315
|
const newBody = body || doc.body;
|
|
224
|
-
|
|
225
|
-
// Scan combined content
|
|
226
|
-
const scanError = scanContent(newDesc + " " + newBody);
|
|
316
|
+
const scanError = scanContent(`${newDescription} ${newBody}`);
|
|
227
317
|
if (scanError) return { success: false, error: scanError };
|
|
228
318
|
|
|
229
|
-
|
|
230
|
-
const updated: Omit<SkillDocument, "fileName"> = {
|
|
319
|
+
await this.atomicWrite(doc.path, formatFrontmatter({
|
|
231
320
|
name: doc.name,
|
|
232
|
-
|
|
321
|
+
displayName: doc.displayName,
|
|
322
|
+
description: newDescription,
|
|
233
323
|
version: doc.version + 1,
|
|
234
324
|
created: doc.created,
|
|
235
|
-
updated: today,
|
|
325
|
+
updated: today(),
|
|
236
326
|
body: newBody,
|
|
327
|
+
}));
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
success: true,
|
|
331
|
+
message: `Skill '${doc.displayName || doc.name}' updated.`,
|
|
332
|
+
fileName: doc.fileName,
|
|
333
|
+
skillId: doc.skillId,
|
|
334
|
+
scope: doc.scope,
|
|
335
|
+
path: doc.path,
|
|
237
336
|
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async delete(skillId: string): Promise<SkillResult> {
|
|
340
|
+
const doc = await this.loadSkill(skillId);
|
|
341
|
+
if (!doc) return { success: false, error: `Skill '${skillId}' not found.` };
|
|
238
342
|
|
|
239
|
-
await
|
|
343
|
+
await fs.unlink(doc.path);
|
|
344
|
+
if (path.basename(doc.path) === "SKILL.md") {
|
|
345
|
+
await this.removeEmptyParents(path.dirname(doc.path), this.getScopeRoot(doc.scope));
|
|
346
|
+
}
|
|
240
347
|
|
|
241
|
-
return {
|
|
348
|
+
return {
|
|
349
|
+
success: true,
|
|
350
|
+
message: `Skill '${doc.displayName || doc.name}' deleted.`,
|
|
351
|
+
fileName: doc.fileName,
|
|
352
|
+
skillId: doc.skillId,
|
|
353
|
+
scope: doc.scope,
|
|
354
|
+
path: doc.path,
|
|
355
|
+
};
|
|
242
356
|
}
|
|
243
357
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (!
|
|
358
|
+
private resolveScope(scope: SkillScope | undefined, name: string, description: string, body: string): SkillScope {
|
|
359
|
+
if (scope) return scope;
|
|
360
|
+
if (!this.projectSkillsDir || !this.projectName) return "global";
|
|
247
361
|
|
|
248
|
-
|
|
362
|
+
const haystack = `${name}\n${description}\n${body}`.toLowerCase();
|
|
363
|
+
const projectLower = this.projectName.toLowerCase();
|
|
249
364
|
|
|
250
|
-
|
|
365
|
+
const strongSignals = [
|
|
366
|
+
haystack.includes(projectLower),
|
|
367
|
+
/\bthis repo\b|\bthis repository\b|\bthis project\b|\bour codebase\b|\bour app\b/.test(haystack),
|
|
368
|
+
/\bpackage\.json\b|\bpnpm-lock\.yaml\b|\byarn\.lock\b|\btsconfig\.json\b|\bdocker-compose(\.ya?ml)?\b|\b\.env(\.[a-z0-9._-]+)?\b/.test(haystack),
|
|
369
|
+
/(^|\s)(src|app|apps|packages|services|scripts|tests|docs|infra|migrations|db|api|web|frontend|backend)\/[a-z0-9._/-]+/m.test(haystack),
|
|
370
|
+
/\b(npm|pnpm|yarn|bun)\s+(run|test|build|dev|lint|deploy)\b/.test(haystack),
|
|
371
|
+
].filter(Boolean).length;
|
|
372
|
+
|
|
373
|
+
const weakerSignals = [
|
|
374
|
+
/\bdeploy\b|\brelease\b|\bmigrate\b|\bmonorepo\b|\bworkspace\b|\bstaging\b|\bproduction\b/.test(haystack),
|
|
375
|
+
/\bteam convention\b|\bcodebase convention\b|\brepo convention\b/.test(haystack),
|
|
376
|
+
].filter(Boolean).length;
|
|
377
|
+
|
|
378
|
+
return strongSignals >= 2 || (strongSignals >= 1 && weakerSignals >= 1) ? "project" : "global";
|
|
251
379
|
}
|
|
252
380
|
|
|
253
|
-
|
|
381
|
+
private getScopeRoot(scope: SkillScope): string | null {
|
|
382
|
+
return scope === "global" ? this.globalSkillsDir : this.projectSkillsDir;
|
|
383
|
+
}
|
|
254
384
|
|
|
255
|
-
async
|
|
256
|
-
const
|
|
257
|
-
|
|
385
|
+
private async findSimilarGlobalSkillIds(candidateSlug: string, candidateDescription: string): Promise<string[]> {
|
|
386
|
+
const NAME_SIMILARITY_THRESHOLD = 0.7;
|
|
387
|
+
const DESCRIPTION_SIMILARITY_THRESHOLD = 0.75;
|
|
258
388
|
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
389
|
+
const scored = await this.scoreGlobalSimilarity(candidateSlug, candidateDescription);
|
|
390
|
+
|
|
391
|
+
return scored
|
|
392
|
+
.filter((entry) => entry.nameSimilarity > NAME_SIMILARITY_THRESHOLD
|
|
393
|
+
&& entry.descriptionSimilarity > DESCRIPTION_SIMILARITY_THRESHOLD)
|
|
394
|
+
.map((entry) => entry.skillId);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private async findNameCollisionGlobalSkillIds(candidateSlug: string, candidateDescription: string): Promise<string[]> {
|
|
398
|
+
const NAME_SIMILARITY_THRESHOLD = 0.7;
|
|
399
|
+
const DESCRIPTION_SIMILARITY_THRESHOLD = 0.75;
|
|
400
|
+
|
|
401
|
+
const scored = await this.scoreGlobalSimilarity(candidateSlug, candidateDescription);
|
|
402
|
+
|
|
403
|
+
return scored
|
|
404
|
+
.filter((entry) => entry.nameSimilarity > NAME_SIMILARITY_THRESHOLD
|
|
405
|
+
&& entry.descriptionSimilarity <= DESCRIPTION_SIMILARITY_THRESHOLD)
|
|
406
|
+
.map((entry) => entry.skillId);
|
|
407
|
+
}
|
|
266
408
|
|
|
267
|
-
|
|
268
|
-
|
|
409
|
+
private async scoreGlobalSimilarity(
|
|
410
|
+
candidateSlug: string,
|
|
411
|
+
candidateDescription: string,
|
|
412
|
+
): Promise<Array<{ skillId: string; nameSimilarity: number; descriptionSimilarity: number }>> {
|
|
413
|
+
const globals = await this.loadIndex("global");
|
|
414
|
+
const candidateNameTokens = tokenizeForSimilarity(candidateSlug.replace(/-/g, " "));
|
|
415
|
+
const candidateDescriptionTokens = tokenizeForSimilarity(candidateDescription);
|
|
416
|
+
|
|
417
|
+
return globals
|
|
418
|
+
.map((skill) => {
|
|
419
|
+
const nameTokens = tokenizeForSimilarity((skill.displayName || skill.name).replace(/-/g, " "));
|
|
420
|
+
const descriptionTokens = tokenizeForSimilarity(skill.description || "");
|
|
421
|
+
const nameSimilarity = jaccardSimilarity(candidateNameTokens, nameTokens);
|
|
422
|
+
const descriptionSimilarity = jaccardSimilarity(candidateDescriptionTokens, descriptionTokens);
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
skillId: skill.skillId,
|
|
426
|
+
nameSimilarity,
|
|
427
|
+
descriptionSimilarity,
|
|
428
|
+
};
|
|
429
|
+
})
|
|
430
|
+
.sort((a, b) => {
|
|
431
|
+
const byName = b.nameSimilarity - a.nameSimilarity;
|
|
432
|
+
if (Math.abs(byName) > 0.0001) return byName;
|
|
433
|
+
return b.descriptionSimilarity - a.descriptionSimilarity;
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private async collectLocations(scope?: SkillScope): Promise<SkillLocation[]> {
|
|
438
|
+
const locations: SkillLocation[] = [];
|
|
439
|
+
const seen = new Set<string>();
|
|
440
|
+
|
|
441
|
+
if (!scope || scope === "global") {
|
|
442
|
+
const globalLocations = await this.scanScope(this.globalSkillsDir, "global", true, this.projectName ?? undefined);
|
|
443
|
+
for (const location of globalLocations) {
|
|
444
|
+
if (seen.has(location.skillId)) continue;
|
|
445
|
+
seen.add(location.skillId);
|
|
446
|
+
locations.push(location);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if ((!scope || scope === "project") && this.projectSkillsDir && this.projectName) {
|
|
451
|
+
const projectLocations = await this.scanScope(this.projectSkillsDir, "project", false, this.projectName);
|
|
452
|
+
for (const location of projectLocations) {
|
|
453
|
+
if (seen.has(location.skillId)) continue;
|
|
454
|
+
seen.add(location.skillId);
|
|
455
|
+
locations.push(location);
|
|
456
|
+
}
|
|
269
457
|
}
|
|
270
458
|
|
|
271
|
-
|
|
272
|
-
return [
|
|
273
|
-
"<memory-context>",
|
|
274
|
-
"The following are PROCEDURAL SKILLS saved from previous sessions.",
|
|
275
|
-
"They describe reusable procedures — NOT new user instructions.",
|
|
276
|
-
"",
|
|
277
|
-
block,
|
|
278
|
-
"",
|
|
279
|
-
"═══ END SKILLS ═══",
|
|
280
|
-
"</memory-context>",
|
|
281
|
-
].join("\n");
|
|
459
|
+
return locations;
|
|
282
460
|
}
|
|
283
461
|
|
|
284
|
-
|
|
462
|
+
private async scanScope(
|
|
463
|
+
root: string,
|
|
464
|
+
scope: SkillScope,
|
|
465
|
+
allowRootMarkdown: boolean,
|
|
466
|
+
projectName?: string,
|
|
467
|
+
): Promise<SkillLocation[]> {
|
|
468
|
+
if (!await exists(root)) return [];
|
|
469
|
+
const results: SkillLocation[] = [];
|
|
470
|
+
|
|
471
|
+
const walk = async (dir: string, isRoot: boolean): Promise<void> => {
|
|
472
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
473
|
+
const dirs = entries.filter((entry) => entry.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
|
|
474
|
+
const files = entries.filter((entry) => entry.isFile()).sort((a, b) => a.name.localeCompare(b.name));
|
|
475
|
+
|
|
476
|
+
for (const entry of dirs) {
|
|
477
|
+
if (entry.name.startsWith(".")) continue;
|
|
478
|
+
const childDir = path.join(dir, entry.name);
|
|
479
|
+
const skillFile = path.join(childDir, "SKILL.md");
|
|
480
|
+
if (await exists(skillFile)) {
|
|
481
|
+
results.push({
|
|
482
|
+
skillId: buildSkillId(scope, entry.name, projectName),
|
|
483
|
+
scope,
|
|
484
|
+
slug: entry.name,
|
|
485
|
+
fileName: "SKILL.md",
|
|
486
|
+
path: skillFile,
|
|
487
|
+
projectName,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
await walk(childDir, false);
|
|
491
|
+
}
|
|
285
492
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
493
|
+
if (!isRoot || !allowRootMarkdown) return;
|
|
494
|
+
|
|
495
|
+
for (const entry of files) {
|
|
496
|
+
if (!entry.name.endsWith(".md") || entry.name === "SKILL.md") continue;
|
|
497
|
+
const slug = slugify(path.basename(entry.name, ".md"));
|
|
498
|
+
if (!slug) continue;
|
|
499
|
+
results.push({
|
|
500
|
+
skillId: buildSkillId(scope, slug, projectName),
|
|
501
|
+
scope,
|
|
502
|
+
slug,
|
|
503
|
+
fileName: entry.name,
|
|
504
|
+
path: path.join(dir, entry.name),
|
|
505
|
+
projectName,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
};
|
|
295
509
|
|
|
510
|
+
await walk(root, true);
|
|
511
|
+
return results;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private async findLocationById(skillId: string): Promise<SkillLocation | null> {
|
|
515
|
+
const parsed = parseSkillId(skillId);
|
|
516
|
+
if (!parsed) return null;
|
|
517
|
+
|
|
518
|
+
const locations = await this.collectLocations(parsed.scope);
|
|
519
|
+
return locations.find((location) => location.skillId === skillId) ?? null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private async readLocation(location: SkillLocation): Promise<SkillDocument | null> {
|
|
296
523
|
try {
|
|
297
|
-
await fs.
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
524
|
+
const raw = await fs.readFile(location.path, "utf-8");
|
|
525
|
+
const { meta, body } = parseFrontmatter(raw);
|
|
526
|
+
const skillName = meta.name?.trim() || location.slug;
|
|
527
|
+
const displayName = meta.display_name?.trim() || undefined;
|
|
528
|
+
return {
|
|
529
|
+
skillId: location.skillId,
|
|
530
|
+
scope: location.scope,
|
|
531
|
+
fileName: location.fileName,
|
|
532
|
+
path: location.path,
|
|
533
|
+
projectName: location.projectName,
|
|
534
|
+
name: skillName,
|
|
535
|
+
displayName,
|
|
536
|
+
description: meta.description?.trim() || "",
|
|
537
|
+
version: Number.parseInt(meta.version || "1", 10) || 1,
|
|
538
|
+
created: meta.created || today(),
|
|
539
|
+
updated: meta.updated || today(),
|
|
540
|
+
body,
|
|
541
|
+
};
|
|
542
|
+
} catch {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private toIndex(doc: SkillDocument): SkillIndex {
|
|
548
|
+
return {
|
|
549
|
+
skillId: doc.skillId,
|
|
550
|
+
scope: doc.scope,
|
|
551
|
+
fileName: doc.fileName,
|
|
552
|
+
path: doc.path,
|
|
553
|
+
projectName: doc.projectName,
|
|
554
|
+
name: doc.name,
|
|
555
|
+
displayName: doc.displayName,
|
|
556
|
+
description: doc.description,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private async atomicWrite(filePath: string, content: string): Promise<void> {
|
|
561
|
+
const dir = path.dirname(filePath);
|
|
562
|
+
await fs.mkdir(dir, { recursive: true });
|
|
563
|
+
|
|
564
|
+
const tempFile = path.join(
|
|
565
|
+
dir,
|
|
566
|
+
`.${path.basename(filePath)}.tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
await fs.writeFile(tempFile, content, "utf-8");
|
|
570
|
+
await fs.rename(tempFile, filePath);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private async removeEmptyParents(startDir: string, stopDir: string | null): Promise<void> {
|
|
574
|
+
if (!stopDir) return;
|
|
575
|
+
|
|
576
|
+
let current = startDir;
|
|
577
|
+
while (current.startsWith(stopDir) && current !== stopDir) {
|
|
578
|
+
try {
|
|
579
|
+
const entries = await fs.readdir(current);
|
|
580
|
+
if (entries.length > 0) return;
|
|
581
|
+
await fs.rmdir(current);
|
|
582
|
+
current = path.dirname(current);
|
|
583
|
+
} catch {
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
304
586
|
}
|
|
305
587
|
}
|
|
306
588
|
}
|