pi-hermes-memory 0.7.6 → 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.
@@ -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: [] };
75
91
 
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 });
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 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,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(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,
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 this.atomicWrite(fileName, formatFrontmatter(updated));
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 { success: true, message: `Skill '${doc.name}' updated.`, fileName };
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
- 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.` };
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
- await fs.unlink(path.join(this.skillsDir, fileName));
362
+ const haystack = `${name}\n${description}\n${body}`.toLowerCase();
363
+ const projectLower = this.projectName.toLowerCase();
249
364
 
250
- return { success: true, message: `Skill '${doc.name}' deleted.`, fileName };
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
- // ─── System prompt injection (progressive disclosure) ───
381
+ private getScopeRoot(scope: SkillScope): string | null {
382
+ return scope === "global" ? this.globalSkillsDir : this.projectSkillsDir;
383
+ }
254
384
 
255
- async formatIndexForSystemPrompt(): Promise<string> {
256
- const skills = await this.loadIndex();
257
- if (skills.length === 0) return "";
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 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
- ];
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
- for (const skill of skills) {
268
- lines.push(`• ${skill.name}: ${skill.description}`);
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
- 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");
459
+ return locations;
282
460
  }
283
461
 
284
- // ─── Internal helpers ───
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
- * 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");
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.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 */ }
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
  }