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.
@@ -1,111 +1,171 @@
1
1
  /**
2
- * SkillStore — procedural memory stored as SKILL.md files.
2
+ * SkillStore — procedural memory stored as Pi-native skills.
3
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.
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 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 };
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
- 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() };
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
- 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");
42
+ export interface LegacySkillMigrationResult {
43
+ migrated: number;
44
+ skipped: number;
45
+ warnings: string[];
48
46
  }
49
47
 
50
- // ─── Slugify ───
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
- function slugify(name: string): string {
53
- return name
54
- .toLowerCase()
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
- // ─── SkillStore ───
69
+ getProjectSkillsDir(): string | null {
70
+ return this.projectSkillsDir;
71
+ }
61
72
 
62
- export class SkillStore {
63
- private skillsDir: string;
73
+ getProjectName(): string | null {
74
+ return this.projectName;
75
+ }
64
76
 
65
- constructor(skillsDir?: string) {
66
- this.skillsDir = skillsDir ?? path.join(os.homedir(), ".pi", "agent", "memory", "skills");
77
+ setProjectContext(projectName: string | null, projectSkillsDir: string | null): void {
78
+ this.projectName = projectName;
79
+ this.projectSkillsDir = projectSkillsDir;
67
80
  }
68
81
 
69
- // ─── Read ───
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 loadIndex(): Promise<SkillIndex[]> {
72
- await fs.mkdir(this.skillsDir, { recursive: true });
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: [] };
91
+
92
+ if (await exists(this.migrationSentinelPath)) return result;
93
+
94
+ await fs.mkdir(path.dirname(this.migrationSentinelPath), { recursive: true });
75
95
 
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 });
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 skills;
144
+ return result;
85
145
  }
86
146
 
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;
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
- // ─── Write ───
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
- // Scan content for security
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 fileName = `${slug}.md`;
125
- const filePath = path.join(this.skillsDir, fileName);
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
- // Check if file already exists
128
- try {
129
- await fs.access(filePath);
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 '${name}' already exists (file: ${fileName}). Use 'patch' or 'edit' to update it.`,
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
- await fs.mkdir(this.skillsDir, { recursive: true });
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 today = new Date().toISOString().split("T")[0];
141
- const doc: Omit<SkillDocument, "fileName"> = {
142
- name,
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: today,
146
- updated: today,
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(fileName: string, section: string, newContent: string): Promise<SkillResult> {
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(fileName);
163
- if (!doc) return { success: false, error: `Skill file '${fileName}' not found.` };
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
- i++;
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
- const today = new Date().toISOString().split("T")[0];
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(fileName: string, description: string, body: string): Promise<SkillResult> {
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(fileName);
220
- if (!doc) return { success: false, error: `Skill file '${fileName}' not found.` };
311
+ const doc = await this.loadSkill(skillId);
312
+ if (!doc) return { success: false, error: `Skill '${skillId}' not found.` };
221
313
 
222
- const newDesc = description || doc.description;
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
- const today = new Date().toISOString().split("T")[0];
230
- const updated: Omit<SkillDocument, "fileName"> = {
319
+ await this.atomicWrite(doc.path, formatFrontmatter({
231
320
  name: doc.name,
232
- description: newDesc,
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
- await this.atomicWrite(fileName, formatFrontmatter(updated));
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 { success: true, message: `Skill '${doc.name}' updated.`, fileName };
515
+ return strongSignals >= 2 || (strongSignals >= 1 && weakerSignals >= 1) ? "project" : "global";
242
516
  }
243
517
 
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.` };
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 fs.unlink(path.join(this.skillsDir, fileName));
526
+ const scored = await this.scoreGlobalSimilarity(candidateSlug, candidateDescription);
249
527
 
250
- return { success: true, message: `Skill '${doc.name}' deleted.`, fileName };
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
- // ─── System prompt injection (progressive disclosure) ───
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
- async formatIndexForSystemPrompt(): Promise<string> {
256
- const skills = await this.loadIndex();
257
- if (skills.length === 0) return "";
538
+ const scored = await this.scoreGlobalSimilarity(candidateSlug, candidateDescription);
258
539
 
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
- ];
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
- for (const skill of skills) {
268
- lines.push(`• ${skill.name}: ${skill.description}`);
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
- const block = lines.join("\n");
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");
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
- // ─── Internal helpers ───
651
+ private async findLocationById(skillId: string): Promise<SkillLocation | null> {
652
+ const parsed = parseSkillId(skillId);
653
+ if (!parsed) return null;
285
654
 
286
- /**
287
- * Atomic write: temp file + rename.
288
- * Creates temp files in the skills directory to avoid cross-device
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.writeFile(tmpPath, content, "utf-8");
298
- await fs.rename(tmpPath, filePath);
299
- } catch (err) {
300
- try { await fs.unlink(tmpPath); } catch { /* ignore */ }
301
- throw err;
302
- } finally {
303
- try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
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
  }