pi-hermes-memory 0.9.0 → 0.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hermes-memory",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "🧠 Persistent memory + 🔍 session search + 🛡️ secret scanning for Pi. Token-aware policy-only memory by default, SQLite FTS5 search, auto-consolidation, procedural skills. 693 tests. Ported from Hermes agent.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/constants.ts CHANGED
@@ -312,7 +312,7 @@ WHEN TO CREATE A SKILL:
312
312
  - When the user teaches you a specific workflow or procedure
313
313
 
314
314
  SCOPE:
315
- - 'global': transferable procedures that can be reused across repositories. Written to Pi's own global skills root, ~/.pi/agent/skills/<slug>/SKILL.md the same directory Pi loads user skills from, so a name used there is already taken.
315
+ - 'global': transferable procedures that can be reused across repositories. Written to ~/.pi/agent/pi-hermes-memory/skills/<slug>/SKILL.md, this extension's own directory, kept separate from skills the user installed themselves. Pi also loads its own ~/.pi/agent/skills/ first, so a name already used there is rejected rather than silently shadowed.
316
316
  - 'project': procedures tied to this repo's paths, scripts, architecture, deploy flow, or conventions. Written to ~/.pi/agent/projects-memory/<project>/skills/<slug>/SKILL.md.
317
317
 
318
318
  WHEN TO UPDATE A SKILL:
package/src/index.ts CHANGED
@@ -63,10 +63,13 @@ export function resolveProjectSkillDiscovery(
63
63
  const detected = detectProjectSkills(projectsMemoryDir, cwd);
64
64
  skillStore.setProjectContext(detected.name, detected.skillsDir);
65
65
 
66
- // Global skills live in Pi's own `~/.pi/agent/skills/`, which Pi already
67
- // auto-discovers at higher precedence than anything contributed here. Only
68
- // the project skills directory needs registering (#125, #126).
69
- return { skillPaths: detected.skillsDir ? [detected.skillsDir] : [] };
66
+ // Pi auto-discovers its own `~/.pi/agent/skills/`, but this extension keeps
67
+ // its generated skills in a directory of its own so users can audit, wipe, or
68
+ // ignore them without touching skills they installed themselves (#126). Both
69
+ // of ours must therefore be contributed here.
70
+ const skillPaths = [skillStore.getGlobalSkillsDir()];
71
+ if (detected.skillsDir) skillPaths.push(detected.skillsDir);
72
+ return { skillPaths };
70
73
  }
71
74
 
72
75
  export function registerProjectSkillDiscoveryHandler(
@@ -102,13 +105,12 @@ export default function (pi: ExtensionAPI) {
102
105
  const project = detectProject(config.projectsMemoryDir);
103
106
  const projectName = project.name ?? "";
104
107
  const skillStore = new SkillStore({
105
- globalSkillsDir: path.join(agentRoot, "skills"),
108
+ globalSkillsDir: path.join(globalDir, "skills"),
109
+ piGlobalSkillsDir: path.join(agentRoot, "skills"),
106
110
  projectSkillsDir: project.memoryDir ? path.join(project.memoryDir, "skills") : null,
107
111
  projectName: project.name,
108
112
  legacySkillsDir: path.join(legacyGlobalDir, "skills"),
109
- legacyExtensionSkillsDir: path.join(globalDir, "skills"),
110
113
  migrationSentinelPath: path.join(globalDir, ".skills-migrated-to-extension-storage"),
111
- extensionSkillsMigrationSentinelPath: path.join(globalDir, ".skills-migrated-to-pi-global"),
112
114
  });
113
115
  const dbManager = new DatabaseManager(globalDir);
114
116
  let databaseMigrationPending = shouldMigrateExtensionRoot
@@ -23,13 +23,14 @@ import type { SkillDocument, SkillIndex, SkillResult, SkillScope } from "../type
23
23
  import { AGENT_ROOT } from "../paths.js";
24
24
 
25
25
  interface SkillStoreOptions {
26
+ /** Where this extension writes global skills. Never Pi's own root. */
26
27
  globalSkillsDir?: string;
28
+ /** Pi's global skills root, consulted read-only to refuse shadowed writes. */
29
+ piGlobalSkillsDir?: string;
27
30
  projectSkillsDir?: string | null;
28
31
  projectName?: string | null;
29
32
  legacySkillsDir?: string;
30
- legacyExtensionSkillsDir?: string;
31
33
  migrationSentinelPath?: string;
32
- extensionSkillsMigrationSentinelPath?: string;
33
34
  }
34
35
 
35
36
  interface SkillLocation {
@@ -154,31 +155,36 @@ export function normalizeSkillPatchContent(
154
155
 
155
156
  export class SkillStore {
156
157
  private globalSkillsDir: string;
158
+ private piGlobalSkillsDir: string;
157
159
  private projectSkillsDir: string | null;
158
160
  private projectName: string | null;
159
161
  private legacySkillsDir: string;
160
- private legacyExtensionSkillsDir: string;
161
162
  private migrationSentinelPath: string;
162
- private extensionSkillsMigrationSentinelPath: string;
163
163
 
164
164
  constructor(options: SkillStoreOptions = {}) {
165
165
  const agentRoot = AGENT_ROOT;
166
- this.globalSkillsDir = options.globalSkillsDir ?? path.join(agentRoot, "skills");
166
+ this.globalSkillsDir = options.globalSkillsDir ?? path.join(agentRoot, "pi-hermes-memory", "skills");
167
+ this.piGlobalSkillsDir = options.piGlobalSkillsDir ?? path.join(agentRoot, "skills");
167
168
  this.projectSkillsDir = options.projectSkillsDir ?? null;
168
169
  this.projectName = options.projectName ?? null;
169
170
  this.legacySkillsDir = options.legacySkillsDir ?? path.join(agentRoot, "memory", "skills");
170
- this.legacyExtensionSkillsDir = options.legacyExtensionSkillsDir
171
- ?? path.join(agentRoot, "pi-hermes-memory", "skills");
172
171
  this.migrationSentinelPath = options.migrationSentinelPath
173
172
  ?? path.join(agentRoot, "pi-hermes-memory", ".skills-migrated-to-extension-storage");
174
- this.extensionSkillsMigrationSentinelPath = options.extensionSkillsMigrationSentinelPath
175
- ?? path.join(agentRoot, "pi-hermes-memory", ".skills-migrated-to-pi-global");
176
173
  }
177
174
 
178
175
  getGlobalSkillsDir(): string {
179
176
  return this.globalSkillsDir;
180
177
  }
181
178
 
179
+ /**
180
+ * Pi's own global skills root. Read-only from here: we never create, patch,
181
+ * or delete inside it. It is consulted solely to refuse writes that Pi would
182
+ * silently shadow (see `findShadowingPiGlobalSkill`).
183
+ */
184
+ getPiGlobalSkillsDir(): string {
185
+ return this.piGlobalSkillsDir;
186
+ }
187
+
182
188
  getProjectSkillsDir(): string | null {
183
189
  return this.projectSkillsDir;
184
190
  }
@@ -205,7 +211,6 @@ export class SkillStore {
205
211
  // Always normalize flat markdown files under the global skills root,
206
212
  // even when a previous migration sentinel already exists.
207
213
  await this.migrateFlatMarkdownInGlobalSkillsDir(result);
208
- await this.migrateExtensionSkillsToGlobalRoot(result);
209
214
 
210
215
  if (await exists(this.migrationSentinelPath)) return result;
211
216
 
@@ -316,60 +321,22 @@ export class SkillStore {
316
321
  }
317
322
 
318
323
  /**
319
- * Move extension-managed global skills back into Pi's own global skills root.
324
+ * Is `slug` already claimed by a skill in Pi's own global root?
320
325
  *
321
326
  * Pi keys skills by name, first-loaded wins, and `~/.pi/agent/skills/` is
322
327
  * auto-discovered at higher precedence than anything an extension contributes
323
- * via `resources_discover`. While global skills lived in a private directory,
324
- * any name that also existed in Pi's root was permanently shadowed: the
325
- * collision warning fired every session and `skill_manage` edits silently had
326
- * no effect on the skill the agent actually loaded (#125, #126).
328
+ * via `resources_discover`. A global skill we write under a name that also
329
+ * exists there is never the copy Pi loads, so the write succeeds on disk and
330
+ * changes nothing about the agent's behaviour silent write-loss (#125).
327
331
  *
328
- * Runs on every startup until it completes with no shadowed names, since a
329
- * shadowed skill can only be resolved by the user renaming one of the two.
332
+ * Callers refuse the write and name both paths instead, which makes the
333
+ * shadowed state impossible to create rather than merely reported after the
334
+ * fact. Returns the shadowing path, or null when the name is free.
330
335
  */
331
- private async migrateExtensionSkillsToGlobalRoot(result: LegacySkillMigrationResult): Promise<void> {
332
- if (path.resolve(this.legacyExtensionSkillsDir) === path.resolve(this.globalSkillsDir)) return;
333
- if (await exists(this.extensionSkillsMigrationSentinelPath)) return;
334
- if (!await exists(this.legacyExtensionSkillsDir)) return;
335
-
336
- const entries = await fs.readdir(this.legacyExtensionSkillsDir, { withFileTypes: true });
337
- let shadowed = 0;
338
-
339
- for (const entry of entries) {
340
- if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
341
-
342
- const sourceDir = path.join(this.legacyExtensionSkillsDir, entry.name);
343
- if (!await exists(path.join(sourceDir, "SKILL.md"))) continue;
344
-
345
- const targetDir = path.join(this.globalSkillsDir, entry.name);
346
- if (await exists(path.join(targetDir, "SKILL.md"))) {
347
- // Pi already loads the copy in its own root; ours was never reachable.
348
- // Leave both in place rather than clobbering a skill we did not write.
349
- shadowed++;
350
- result.skipped++;
351
- result.warnings.push(
352
- `${entry.name}: already exists at ${targetDir}; the copy at ${sourceDir} was never loaded by Pi. `
353
- + `Rename or delete one of them, then restart Pi.`,
354
- );
355
- continue;
356
- }
357
-
358
- try {
359
- await fs.mkdir(path.dirname(targetDir), { recursive: true });
360
- await fs.rename(sourceDir, targetDir);
361
- result.migrated++;
362
- } catch (error) {
363
- shadowed++;
364
- result.warnings.push(`${entry.name}: ${error instanceof Error ? error.message : String(error)}`);
365
- }
366
- }
367
-
368
- if (shadowed === 0) {
369
- await fs.mkdir(path.dirname(this.extensionSkillsMigrationSentinelPath), { recursive: true });
370
- await fs.writeFile(this.extensionSkillsMigrationSentinelPath, `${new Date().toISOString()}\n`, "utf-8");
371
- await fs.rm(this.legacyExtensionSkillsDir, { recursive: true, force: true });
372
- }
336
+ private async findShadowingPiGlobalSkill(slug: string): Promise<string | null> {
337
+ if (path.resolve(this.piGlobalSkillsDir) === path.resolve(this.globalSkillsDir)) return null;
338
+ const candidate = path.join(this.piGlobalSkillsDir, slug, "SKILL.md");
339
+ return await exists(candidate) ? candidate : null;
373
340
  }
374
341
 
375
342
  async loadIndex(scope?: SkillScope): Promise<SkillIndex[]> {
@@ -452,6 +419,19 @@ export class SkillStore {
452
419
  suggestedAction: "rename",
453
420
  };
454
421
  }
422
+
423
+ const shadowedBy = await this.findShadowingPiGlobalSkill(slug);
424
+ if (shadowedBy) {
425
+ return {
426
+ success: false,
427
+ error: `Pi already loads a global skill named '${slug}' from ${shadowedBy}. `
428
+ + `Pi keys skills by name and loads its own root first, so a skill written to `
429
+ + `${path.join(this.globalSkillsDir, slug, "SKILL.md")} would never be the copy in effect. `
430
+ + `Choose a different name, or edit ${shadowedBy} directly.`,
431
+ conflictType: "name-collision",
432
+ suggestedAction: "rename",
433
+ };
434
+ }
455
435
  }
456
436
 
457
437
  const filePath = path.join(root, slug, "SKILL.md");