pi-hermes-memory 0.7.6 → 0.7.8
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 +80 -33
- package/docs/PUBLISHING.md +1 -1
- package/docs/ROADMAP.md +1 -1
- package/package.json +5 -4
- package/src/constants.ts +7 -3
- package/src/handlers/auto-consolidate.ts +1 -1
- 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 +749 -23
- package/src/handlers/switch-project.ts +1 -1
- package/src/handlers/sync-markdown-memories.ts +1 -1
- package/src/index.ts +58 -27
- 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 +600 -181
- package/src/store/skill-utils.ts +116 -0
- package/src/tools/memory-search-tool.ts +2 -2
- package/src/tools/memory-tool.ts +2 -2
- package/src/tools/session-search-tool.ts +2 -2
- package/src/tools/skill-tool.ts +23 -20
- package/src/types.ts +22 -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
|
-
|
|
74
|
-
|
|
89
|
+
async migrateLegacySkills(): Promise<LegacySkillMigrationResult> {
|
|
90
|
+
const result: LegacySkillMigrationResult = { migrated: 0, skipped: 0, warnings: [] };
|
|
91
|
+
|
|
92
|
+
if (await exists(this.migrationSentinelPath)) return result;
|
|
93
|
+
|
|
94
|
+
await fs.mkdir(path.dirname(this.migrationSentinelPath), { recursive: true });
|
|
75
95
|
|
|
76
|
-
|
|
77
|
-
if (!
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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,418 @@ 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,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async move(skillId: string, targetScope: SkillScope): Promise<SkillResult> {
|
|
340
|
+
const doc = await this.loadSkill(skillId);
|
|
341
|
+
if (!doc) return { success: false, error: `Skill '${skillId}' not found.` };
|
|
342
|
+
|
|
343
|
+
const parsed = parseSkillId(skillId);
|
|
344
|
+
if (!parsed) return { success: false, error: `Skill '${skillId}' is invalid.` };
|
|
345
|
+
|
|
346
|
+
if (doc.scope === targetScope) {
|
|
347
|
+
return {
|
|
348
|
+
success: true,
|
|
349
|
+
message: `Skill '${doc.displayName || doc.name}' is already ${targetScope}.`,
|
|
350
|
+
fileName: doc.fileName,
|
|
351
|
+
skillId: doc.skillId,
|
|
352
|
+
scope: doc.scope,
|
|
353
|
+
path: doc.path,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const targetRoot = this.getScopeRoot(targetScope);
|
|
358
|
+
if (!targetRoot) {
|
|
359
|
+
return { success: false, error: "Project skills require an active project." };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const targetSkillId = buildSkillId(targetScope, parsed.slug, this.projectName);
|
|
363
|
+
const targetPath = path.join(targetRoot, parsed.slug, "SKILL.md");
|
|
364
|
+
if (await exists(targetPath)) {
|
|
365
|
+
return {
|
|
366
|
+
success: false,
|
|
367
|
+
error: `Cannot move '${doc.displayName || doc.name}' to ${targetScope}: ${targetSkillId} already exists.`,
|
|
368
|
+
conflictType: "scope-conflict",
|
|
369
|
+
similarSkillIds: [targetSkillId],
|
|
370
|
+
suggestedAction: "rename",
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (targetScope === "global") {
|
|
375
|
+
const similarSkillIds = await this.findSimilarGlobalSkillIds(parsed.slug, doc.description);
|
|
376
|
+
if (similarSkillIds.length > 0) {
|
|
377
|
+
const targetId = similarSkillIds[0];
|
|
378
|
+
return {
|
|
379
|
+
success: false,
|
|
380
|
+
error: `Cannot move '${doc.displayName || doc.name}' to global: a similar global skill already exists (${targetId}).`,
|
|
381
|
+
conflictType: "similar",
|
|
382
|
+
similarSkillIds,
|
|
383
|
+
suggestedAction: "patch",
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const collidingNameSkillIds = await this.findNameCollisionGlobalSkillIds(parsed.slug, doc.description);
|
|
388
|
+
if (collidingNameSkillIds.length > 0) {
|
|
389
|
+
const targetId = collidingNameSkillIds[0];
|
|
390
|
+
return {
|
|
391
|
+
success: false,
|
|
392
|
+
error: `Cannot move '${doc.displayName || doc.name}' to global: a near-name global skill already exists (${targetId}) with different intent.`,
|
|
393
|
+
conflictType: "name-collision",
|
|
394
|
+
similarSkillIds: collidingNameSkillIds,
|
|
395
|
+
suggestedAction: "rename",
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
401
|
+
|
|
402
|
+
// Same-filesystem move: use rename first for atomicity and to avoid duplicate windows.
|
|
403
|
+
try {
|
|
404
|
+
await fs.rename(doc.path, targetPath);
|
|
405
|
+
|
|
406
|
+
if (path.basename(doc.path) === "SKILL.md") {
|
|
407
|
+
await this.removeEmptyParents(path.dirname(doc.path), this.getScopeRoot(doc.scope));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
success: true,
|
|
412
|
+
message: `Skill '${doc.displayName || doc.name}' moved to ${targetScope}.`,
|
|
413
|
+
fileName: path.basename(targetPath),
|
|
414
|
+
skillId: targetSkillId,
|
|
415
|
+
scope: targetScope,
|
|
416
|
+
path: targetPath,
|
|
417
|
+
};
|
|
418
|
+
} catch (renameError) {
|
|
419
|
+
const code = (renameError as NodeJS.ErrnoException)?.code;
|
|
420
|
+
if (code !== "EXDEV") {
|
|
421
|
+
return {
|
|
422
|
+
success: false,
|
|
423
|
+
error: `Move to ${targetScope} failed before copy for skill '${skillId}'. Source path: ${doc.path}. Destination path: ${targetPath}. Error: ${renameError instanceof Error ? renameError.message : String(renameError)}`,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
// Cross-device fallback below.
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Cross-device fallback: copy then remove source.
|
|
430
|
+
await this.atomicWrite(targetPath, formatFrontmatter({
|
|
431
|
+
name: parsed.slug,
|
|
432
|
+
displayName: doc.displayName,
|
|
433
|
+
description: doc.description,
|
|
434
|
+
version: doc.version,
|
|
435
|
+
created: doc.created,
|
|
436
|
+
updated: doc.updated,
|
|
437
|
+
body: doc.body,
|
|
438
|
+
}));
|
|
439
|
+
|
|
440
|
+
try {
|
|
441
|
+
await fs.unlink(doc.path);
|
|
442
|
+
if (path.basename(doc.path) === "SKILL.md") {
|
|
443
|
+
await this.removeEmptyParents(path.dirname(doc.path), this.getScopeRoot(doc.scope));
|
|
444
|
+
}
|
|
445
|
+
} catch (error) {
|
|
446
|
+
// Best-effort rollback: remove the destination copy if source removal fails,
|
|
447
|
+
// so we do not silently leave duplicate skills across scopes.
|
|
448
|
+
let rollbackFailed = false;
|
|
449
|
+
try {
|
|
450
|
+
await fs.unlink(targetPath);
|
|
451
|
+
if (path.basename(targetPath) === "SKILL.md") {
|
|
452
|
+
await this.removeEmptyParents(path.dirname(targetPath), this.getScopeRoot(targetScope));
|
|
453
|
+
}
|
|
454
|
+
} catch {
|
|
455
|
+
rollbackFailed = true;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
success: false,
|
|
460
|
+
error: rollbackFailed
|
|
461
|
+
? `Move to ${targetScope} failed while removing source skill '${skillId}', and rollback also failed. Source path: ${doc.path}. Destination path: ${targetPath}. Error: ${error instanceof Error ? error.message : String(error)}`
|
|
462
|
+
: `Move to ${targetScope} failed while removing source skill '${skillId}'. Rolled back destination copy. Source path: ${doc.path}. Destination path: ${targetPath}. Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
success: true,
|
|
468
|
+
message: `Skill '${doc.displayName || doc.name}' moved to ${targetScope}.`,
|
|
469
|
+
fileName: path.basename(targetPath),
|
|
470
|
+
skillId: targetSkillId,
|
|
471
|
+
scope: targetScope,
|
|
472
|
+
path: targetPath,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async delete(skillId: string): Promise<SkillResult> {
|
|
477
|
+
const doc = await this.loadSkill(skillId);
|
|
478
|
+
if (!doc) return { success: false, error: `Skill '${skillId}' not found.` };
|
|
479
|
+
|
|
480
|
+
await fs.unlink(doc.path);
|
|
481
|
+
if (path.basename(doc.path) === "SKILL.md") {
|
|
482
|
+
await this.removeEmptyParents(path.dirname(doc.path), this.getScopeRoot(doc.scope));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
success: true,
|
|
487
|
+
message: `Skill '${doc.displayName || doc.name}' deleted.`,
|
|
488
|
+
fileName: doc.fileName,
|
|
489
|
+
skillId: doc.skillId,
|
|
490
|
+
scope: doc.scope,
|
|
491
|
+
path: doc.path,
|
|
237
492
|
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private resolveScope(scope: SkillScope | undefined, name: string, description: string, body: string): SkillScope {
|
|
496
|
+
if (scope) return scope;
|
|
497
|
+
if (!this.projectSkillsDir || !this.projectName) return "global";
|
|
498
|
+
|
|
499
|
+
const haystack = `${name}\n${description}\n${body}`.toLowerCase();
|
|
500
|
+
const projectLower = this.projectName.toLowerCase();
|
|
501
|
+
|
|
502
|
+
const strongSignals = [
|
|
503
|
+
haystack.includes(projectLower),
|
|
504
|
+
/\bthis repo\b|\bthis repository\b|\bthis project\b|\bour codebase\b|\bour app\b/.test(haystack),
|
|
505
|
+
/\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),
|
|
506
|
+
/(^|\s)(src|app|apps|packages|services|scripts|tests|docs|infra|migrations|db|api|web|frontend|backend)\/[a-z0-9._/-]+/m.test(haystack),
|
|
507
|
+
/\b(npm|pnpm|yarn|bun)\s+(run|test|build|dev|lint|deploy)\b/.test(haystack),
|
|
508
|
+
].filter(Boolean).length;
|
|
238
509
|
|
|
239
|
-
|
|
510
|
+
const weakerSignals = [
|
|
511
|
+
/\bdeploy\b|\brelease\b|\bmigrate\b|\bmonorepo\b|\bworkspace\b|\bstaging\b|\bproduction\b/.test(haystack),
|
|
512
|
+
/\bteam convention\b|\bcodebase convention\b|\brepo convention\b/.test(haystack),
|
|
513
|
+
].filter(Boolean).length;
|
|
240
514
|
|
|
241
|
-
return
|
|
515
|
+
return strongSignals >= 2 || (strongSignals >= 1 && weakerSignals >= 1) ? "project" : "global";
|
|
242
516
|
}
|
|
243
517
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
518
|
+
private getScopeRoot(scope: SkillScope): string | null {
|
|
519
|
+
return scope === "global" ? this.globalSkillsDir : this.projectSkillsDir;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private async findSimilarGlobalSkillIds(candidateSlug: string, candidateDescription: string): Promise<string[]> {
|
|
523
|
+
const NAME_SIMILARITY_THRESHOLD = 0.7;
|
|
524
|
+
const DESCRIPTION_SIMILARITY_THRESHOLD = 0.75;
|
|
247
525
|
|
|
248
|
-
await
|
|
526
|
+
const scored = await this.scoreGlobalSimilarity(candidateSlug, candidateDescription);
|
|
249
527
|
|
|
250
|
-
return
|
|
528
|
+
return scored
|
|
529
|
+
.filter((entry) => entry.nameSimilarity > NAME_SIMILARITY_THRESHOLD
|
|
530
|
+
&& entry.descriptionSimilarity > DESCRIPTION_SIMILARITY_THRESHOLD)
|
|
531
|
+
.map((entry) => entry.skillId);
|
|
251
532
|
}
|
|
252
533
|
|
|
253
|
-
|
|
534
|
+
private async findNameCollisionGlobalSkillIds(candidateSlug: string, candidateDescription: string): Promise<string[]> {
|
|
535
|
+
const NAME_SIMILARITY_THRESHOLD = 0.7;
|
|
536
|
+
const DESCRIPTION_SIMILARITY_THRESHOLD = 0.75;
|
|
254
537
|
|
|
255
|
-
|
|
256
|
-
const skills = await this.loadIndex();
|
|
257
|
-
if (skills.length === 0) return "";
|
|
538
|
+
const scored = await this.scoreGlobalSimilarity(candidateSlug, candidateDescription);
|
|
258
539
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
"",
|
|
265
|
-
];
|
|
540
|
+
return scored
|
|
541
|
+
.filter((entry) => entry.nameSimilarity > NAME_SIMILARITY_THRESHOLD
|
|
542
|
+
&& entry.descriptionSimilarity <= DESCRIPTION_SIMILARITY_THRESHOLD)
|
|
543
|
+
.map((entry) => entry.skillId);
|
|
544
|
+
}
|
|
266
545
|
|
|
267
|
-
|
|
268
|
-
|
|
546
|
+
private async scoreGlobalSimilarity(
|
|
547
|
+
candidateSlug: string,
|
|
548
|
+
candidateDescription: string,
|
|
549
|
+
): Promise<Array<{ skillId: string; nameSimilarity: number; descriptionSimilarity: number }>> {
|
|
550
|
+
const globals = await this.loadIndex("global");
|
|
551
|
+
const candidateNameTokens = tokenizeForSimilarity(candidateSlug.replace(/-/g, " "));
|
|
552
|
+
const candidateDescriptionTokens = tokenizeForSimilarity(candidateDescription);
|
|
553
|
+
|
|
554
|
+
return globals
|
|
555
|
+
.map((skill) => {
|
|
556
|
+
const nameTokens = tokenizeForSimilarity((skill.displayName || skill.name).replace(/-/g, " "));
|
|
557
|
+
const descriptionTokens = tokenizeForSimilarity(skill.description || "");
|
|
558
|
+
const nameSimilarity = jaccardSimilarity(candidateNameTokens, nameTokens);
|
|
559
|
+
const descriptionSimilarity = jaccardSimilarity(candidateDescriptionTokens, descriptionTokens);
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
skillId: skill.skillId,
|
|
563
|
+
nameSimilarity,
|
|
564
|
+
descriptionSimilarity,
|
|
565
|
+
};
|
|
566
|
+
})
|
|
567
|
+
.sort((a, b) => {
|
|
568
|
+
const byName = b.nameSimilarity - a.nameSimilarity;
|
|
569
|
+
if (Math.abs(byName) > 0.0001) return byName;
|
|
570
|
+
return b.descriptionSimilarity - a.descriptionSimilarity;
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private async collectLocations(scope?: SkillScope): Promise<SkillLocation[]> {
|
|
575
|
+
const locations: SkillLocation[] = [];
|
|
576
|
+
const seen = new Set<string>();
|
|
577
|
+
|
|
578
|
+
if (!scope || scope === "global") {
|
|
579
|
+
const globalLocations = await this.scanScope(this.globalSkillsDir, "global", true, this.projectName ?? undefined);
|
|
580
|
+
for (const location of globalLocations) {
|
|
581
|
+
if (seen.has(location.skillId)) continue;
|
|
582
|
+
seen.add(location.skillId);
|
|
583
|
+
locations.push(location);
|
|
584
|
+
}
|
|
269
585
|
}
|
|
270
586
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
587
|
+
if ((!scope || scope === "project") && this.projectSkillsDir && this.projectName) {
|
|
588
|
+
const projectLocations = await this.scanScope(this.projectSkillsDir, "project", false, this.projectName);
|
|
589
|
+
for (const location of projectLocations) {
|
|
590
|
+
if (seen.has(location.skillId)) continue;
|
|
591
|
+
seen.add(location.skillId);
|
|
592
|
+
locations.push(location);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return locations;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private async scanScope(
|
|
600
|
+
root: string,
|
|
601
|
+
scope: SkillScope,
|
|
602
|
+
allowRootMarkdown: boolean,
|
|
603
|
+
projectName?: string,
|
|
604
|
+
): Promise<SkillLocation[]> {
|
|
605
|
+
if (!await exists(root)) return [];
|
|
606
|
+
const results: SkillLocation[] = [];
|
|
607
|
+
|
|
608
|
+
const walk = async (dir: string, isRoot: boolean): Promise<void> => {
|
|
609
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
610
|
+
const dirs = entries.filter((entry) => entry.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
|
|
611
|
+
const files = entries.filter((entry) => entry.isFile()).sort((a, b) => a.name.localeCompare(b.name));
|
|
612
|
+
|
|
613
|
+
for (const entry of dirs) {
|
|
614
|
+
if (entry.name.startsWith(".")) continue;
|
|
615
|
+
const childDir = path.join(dir, entry.name);
|
|
616
|
+
const skillFile = path.join(childDir, "SKILL.md");
|
|
617
|
+
if (await exists(skillFile)) {
|
|
618
|
+
results.push({
|
|
619
|
+
skillId: buildSkillId(scope, entry.name, projectName),
|
|
620
|
+
scope,
|
|
621
|
+
slug: entry.name,
|
|
622
|
+
fileName: "SKILL.md",
|
|
623
|
+
path: skillFile,
|
|
624
|
+
projectName,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
await walk(childDir, false);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (!isRoot || !allowRootMarkdown) return;
|
|
631
|
+
|
|
632
|
+
for (const entry of files) {
|
|
633
|
+
if (!entry.name.endsWith(".md") || entry.name === "SKILL.md") continue;
|
|
634
|
+
const slug = slugify(path.basename(entry.name, ".md"));
|
|
635
|
+
if (!slug) continue;
|
|
636
|
+
results.push({
|
|
637
|
+
skillId: buildSkillId(scope, slug, projectName),
|
|
638
|
+
scope,
|
|
639
|
+
slug,
|
|
640
|
+
fileName: entry.name,
|
|
641
|
+
path: path.join(dir, entry.name),
|
|
642
|
+
projectName,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
await walk(root, true);
|
|
648
|
+
return results;
|
|
282
649
|
}
|
|
283
650
|
|
|
284
|
-
|
|
651
|
+
private async findLocationById(skillId: string): Promise<SkillLocation | null> {
|
|
652
|
+
const parsed = parseSkillId(skillId);
|
|
653
|
+
if (!parsed) return null;
|
|
285
654
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
* rename errors (EXDEV) on Windows when os.tmpdir() is on a different drive.
|
|
290
|
-
*/
|
|
291
|
-
private async atomicWrite(fileName: string, content: string): Promise<void> {
|
|
292
|
-
const filePath = path.join(this.skillsDir, fileName);
|
|
293
|
-
const tmpDir = await fs.mkdtemp(path.join(this.skillsDir, ".tmp-"));
|
|
294
|
-
const tmpPath = path.join(tmpDir, "write.tmp");
|
|
655
|
+
const locations = await this.collectLocations(parsed.scope);
|
|
656
|
+
return locations.find((location) => location.skillId === skillId) ?? null;
|
|
657
|
+
}
|
|
295
658
|
|
|
659
|
+
private async readLocation(location: SkillLocation): Promise<SkillDocument | null> {
|
|
296
660
|
try {
|
|
297
|
-
await fs.
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
661
|
+
const raw = await fs.readFile(location.path, "utf-8");
|
|
662
|
+
const { meta, body } = parseFrontmatter(raw);
|
|
663
|
+
const skillName = meta.name?.trim() || location.slug;
|
|
664
|
+
const displayName = meta.display_name?.trim() || undefined;
|
|
665
|
+
return {
|
|
666
|
+
skillId: location.skillId,
|
|
667
|
+
scope: location.scope,
|
|
668
|
+
fileName: location.fileName,
|
|
669
|
+
path: location.path,
|
|
670
|
+
projectName: location.projectName,
|
|
671
|
+
name: skillName,
|
|
672
|
+
displayName,
|
|
673
|
+
description: meta.description?.trim() || "",
|
|
674
|
+
version: Number.parseInt(meta.version || "1", 10) || 1,
|
|
675
|
+
created: meta.created || today(),
|
|
676
|
+
updated: meta.updated || today(),
|
|
677
|
+
body,
|
|
678
|
+
};
|
|
679
|
+
} catch {
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private toIndex(doc: SkillDocument): SkillIndex {
|
|
685
|
+
return {
|
|
686
|
+
skillId: doc.skillId,
|
|
687
|
+
scope: doc.scope,
|
|
688
|
+
fileName: doc.fileName,
|
|
689
|
+
path: doc.path,
|
|
690
|
+
projectName: doc.projectName,
|
|
691
|
+
name: doc.name,
|
|
692
|
+
displayName: doc.displayName,
|
|
693
|
+
description: doc.description,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private async atomicWrite(filePath: string, content: string): Promise<void> {
|
|
698
|
+
const dir = path.dirname(filePath);
|
|
699
|
+
await fs.mkdir(dir, { recursive: true });
|
|
700
|
+
|
|
701
|
+
const tempFile = path.join(
|
|
702
|
+
dir,
|
|
703
|
+
`.${path.basename(filePath)}.tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
704
|
+
);
|
|
705
|
+
|
|
706
|
+
await fs.writeFile(tempFile, content, "utf-8");
|
|
707
|
+
await fs.rename(tempFile, filePath);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
private async removeEmptyParents(startDir: string, stopDir: string | null): Promise<void> {
|
|
711
|
+
if (!stopDir) return;
|
|
712
|
+
|
|
713
|
+
let current = startDir;
|
|
714
|
+
while (current.startsWith(stopDir) && current !== stopDir) {
|
|
715
|
+
try {
|
|
716
|
+
const entries = await fs.readdir(current);
|
|
717
|
+
if (entries.length > 0) return;
|
|
718
|
+
await fs.rmdir(current);
|
|
719
|
+
current = path.dirname(current);
|
|
720
|
+
} catch {
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
304
723
|
}
|
|
305
724
|
}
|
|
306
725
|
}
|