pi-hermes-memory 0.8.1 → 0.9.0

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/src/store/db.ts CHANGED
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module';
4
4
  import { SCHEMA_SQL } from './schema.js';
5
5
  import { AtomicLockCoordinator } from './atomic-lock-coordinator.js';
6
6
  import { canonicalStoragePathSync } from './canonical-storage-path.js';
7
+ import { isBunRuntime, loadBetterSqlite3 } from './sqlite-native.js';
7
8
 
8
9
  type StatementLike = {
9
10
  run: (...args: any[]) => any;
@@ -69,6 +70,7 @@ class DatabaseCorruptionError extends Error {
69
70
  }
70
71
  }
71
72
 
73
+ export const SQLITE_BUSY_TIMEOUT_MS = 5000;
72
74
  export const SQLITE_WAL_AUTOCHECKPOINT_PAGES = 1000;
73
75
 
74
76
  const DATABASE_FILE_SUFFIXES: readonly DatabaseFileSuffix[] = ['', '-wal', '-shm'];
@@ -118,24 +120,23 @@ function createBunCompatDatabaseCtor(require: NodeRequire): DatabaseCtor {
118
120
  };
119
121
  }
120
122
 
121
- function loadDatabaseCtor(): DatabaseCtor {
122
- const require = createRequire(import.meta.url);
123
- const isBunRuntime = typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined';
124
-
125
- if (isBunRuntime) {
126
- return createBunCompatDatabaseCtor(require);
127
- }
128
-
129
- try {
130
- const mod = require('better-sqlite3') as { default?: DatabaseCtor } | DatabaseCtor;
131
- return (mod as { default?: DatabaseCtor }).default ?? (mod as DatabaseCtor);
132
- } catch (err) {
133
- throw err;
123
+ let cachedDatabaseCtor: DatabaseCtor | null = null;
124
+
125
+ /**
126
+ * Resolved on first use, never at import time. A module-scope native load turns
127
+ * any SQLite resolve/ABI failure into "Failed to load extension", which hides
128
+ * the actionable rebuild message and bricks the whole extension (issue #117).
129
+ */
130
+ function getDatabaseCtor(): DatabaseCtor {
131
+ if (!cachedDatabaseCtor) {
132
+ const require = createRequire(import.meta.url);
133
+ cachedDatabaseCtor = isBunRuntime()
134
+ ? createBunCompatDatabaseCtor(require)
135
+ : (loadBetterSqlite3({ requireImpl: require }) as DatabaseCtor);
134
136
  }
137
+ return cachedDatabaseCtor;
135
138
  }
136
139
 
137
- const Database = loadDatabaseCtor();
138
-
139
140
  export class DatabaseManager {
140
141
  private db: DatabaseLike | null = null;
141
142
  private readonly displayDbPath: string;
@@ -273,7 +274,7 @@ export class DatabaseManager {
273
274
 
274
275
  private openUnchecked(): DatabaseLike {
275
276
  const existed = this.hasExistingMainDatabaseFile();
276
- const db = new Database(this.dbPath);
277
+ const db = new (getDatabaseCtor())(this.dbPath);
277
278
  let ok = false;
278
279
 
279
280
  try {
@@ -294,6 +295,9 @@ export class DatabaseManager {
294
295
  }
295
296
 
296
297
  private configureConnection(db: DatabaseLike): void {
298
+ // Wait briefly for concurrent writers across Pi processes instead of failing
299
+ // immediately with SQLITE_BUSY. Connection-local; applies before WAL/schema.
300
+ db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
297
301
  // Enable WAL mode + FK enforcement for each connection. Keep SQLite's
298
302
  // default WAL autocheckpoint size; a very aggressive checkpoint cadence
299
303
  // increases the chance that abrupt VM/host shutdown catches a checkpoint.
@@ -361,7 +365,7 @@ export class DatabaseManager {
361
365
  }
362
366
 
363
367
  private recoverDatabaseFile(cause: unknown, verify: () => void): DatabaseRecoveryResult {
364
- const coordinator = new AtomicLockCoordinator(path.join(path.dirname(this.dbPath), '.pi-hermes-locks.sqlite'));
368
+ const coordinator = AtomicLockCoordinator.shared(path.join(path.dirname(this.dbPath), '.pi-hermes-locks.sqlite'));
365
369
  const lockKey = `recovery:${this.dbPath}`;
366
370
  const deadline = Date.now() + Math.max(0, this.recoveryOptions.recoveryLockWaitMs);
367
371
 
@@ -434,7 +438,7 @@ export class DatabaseManager {
434
438
  if (!this.hasExistingMainDatabaseFile()) return false;
435
439
  let db: DatabaseLike | null = null;
436
440
  try {
437
- db = new Database(this.dbPath);
441
+ db = new (getDatabaseCtor())(this.dbPath);
438
442
  this.assertIntegrityOk(db, 'quick_check', 'while joining corruption recovery');
439
443
  return true;
440
444
  } catch {
@@ -546,6 +550,7 @@ export class DatabaseManager {
546
550
  let rebuildOk = false;
547
551
 
548
552
  try {
553
+ const Database = getDatabaseCtor();
549
554
  source = new Database(this.dbPath);
550
555
  target = new Database(tempPath);
551
556
  target.exec('PRAGMA journal_mode = DELETE');
@@ -63,6 +63,37 @@ export function buildFallbackFts5Query(query: string): string | null {
63
63
  .join(' OR ');
64
64
  }
65
65
 
66
+ function quoteTerms(terms: string[], separator: string): string {
67
+ return terms.map((term) => `"${term.replace(/"/g, '""')}"`).join(separator);
68
+ }
69
+
70
+ /**
71
+ * Normalize a query as natural language even when it contains uppercase
72
+ * operator words. Queries like "DO NOT USE FIND /" are passed through as raw
73
+ * FTS5 syntax by normalizeFts5Query; when that raw form fails to parse, this
74
+ * produces the quoted-term form to retry with.
75
+ */
76
+ export function normalizeNaturalLanguageFts5Query(query: string): string {
77
+ const trimmed = query.trim();
78
+ if (trimmed.length === 0) return '';
79
+
80
+ return quoteTerms(collectNaturalLanguageTerms(trimmed), ' ');
81
+ }
82
+
83
+ /**
84
+ * Build the broader OR fallback for the same recovery path, ignoring
85
+ * operator detection.
86
+ */
87
+ export function buildNaturalLanguageFallbackQuery(query: string): string | null {
88
+ const trimmed = query.trim();
89
+ if (trimmed.length === 0) return null;
90
+
91
+ const terms = collectNaturalLanguageTerms(trimmed);
92
+ if (terms.length <= 1) return null;
93
+
94
+ return quoteTerms(terms, ' OR ');
95
+ }
96
+
66
97
  export function isFts5QueryError(err: unknown): boolean {
67
98
  if (!(err instanceof Error)) return false;
68
99
  const msg = err.message.toLowerCase();
@@ -12,7 +12,7 @@ export async function canonicalMarkdownIdentity(filePath: string): Promise<strin
12
12
  export async function acquireMarkdownMutationLock(filePath: string): Promise<AtomicLockLease> {
13
13
  const identity = await canonicalMarkdownIdentity(filePath);
14
14
  const coordinatorDir = path.dirname(path.dirname(identity));
15
- const coordinator = new AtomicLockCoordinator(path.join(coordinatorDir, ".pi-hermes-locks.sqlite"));
15
+ const coordinator = AtomicLockCoordinator.shared(path.join(coordinatorDir, ".pi-hermes-locks.sqlite"));
16
16
  const lockKey = `mutation:${identity}`;
17
17
  const deadline = Date.now() + MUTATION_WAIT_MS;
18
18
  let lease = coordinator.tryAcquire(lockKey, { staleMs: MUTATION_STALE_MS });
@@ -587,6 +587,46 @@ export class MemoryStore {
587
587
  this.fileFingerprints[filePath] = state.fingerprint;
588
588
  }
589
589
 
590
+ /**
591
+ * Reload target state from disk (source of truth), refresh success metadata,
592
+ * and always notify the mutation observer so SQLite stays aligned even when
593
+ * the mutation itself failed or an external editor raced the write.
594
+ */
595
+ private async finalizeTargetMutation(
596
+ target: "memory" | "user" | "failure",
597
+ storagePath: string,
598
+ result: MemoryResult,
599
+ ): Promise<MemoryResult> {
600
+ const state = await this.readFileState(storagePath);
601
+ this.setEntries(target, [...new Set(state.entries)]);
602
+ this.fileFingerprints[storagePath] = state.fingerprint;
603
+
604
+ let finalized = result;
605
+ if (result.success) {
606
+ finalized = {
607
+ ...result,
608
+ ...this.successResponse(target, result.message),
609
+ };
610
+ if (result.evicted_entries) finalized.evicted_entries = result.evicted_entries;
611
+ if (result.evicted_count !== undefined) finalized.evicted_count = result.evicted_count;
612
+ if (result.matches) finalized.matches = result.matches;
613
+ if (result.entries) finalized.entries = result.entries;
614
+ }
615
+
616
+ if (!this.mutationObserver) return finalized;
617
+
618
+ const warning = await this.mutationObserver(target, [...state.entries]);
619
+ if (!warning || !finalized.success) return finalized;
620
+
621
+ const warnings = [...(finalized.warnings ?? []), warning];
622
+ return {
623
+ ...finalized,
624
+ message: finalized.message ? `${finalized.message} Warning: ${warning}` : warning,
625
+ warning,
626
+ warnings,
627
+ };
628
+ }
629
+
590
630
  private async runTargetMutation(
591
631
  target: "memory" | "user" | "failure",
592
632
  mutation: () => Promise<MemoryResult>,
@@ -596,35 +636,32 @@ export class MemoryStore {
596
636
  for (let attempt = 0; ; attempt++) {
597
637
  try {
598
638
  const result = await mutation();
599
- if (result.success && this.mutationObserver) {
600
- const filePath = storagePath;
601
- const state = await this.readFileState(filePath);
602
- this.setEntries(target, [...new Set(state.entries)]);
603
- this.fileFingerprints[filePath] = state.fingerprint;
604
- const warning = await this.mutationObserver(target, [...state.entries]);
605
- if (warning) {
606
- const warnings = [...(result.warnings ?? []), warning];
607
- return {
608
- ...result,
609
- message: result.message ? `${result.message} Warning: ${warning}` : warning,
610
- warning,
611
- warnings,
612
- };
639
+ if (result.success) {
640
+ // saveToDisk stamps fileFingerprints on success. If an editor
641
+ // truncates/replaces the file after publish returns, refuse the
642
+ // phantom success and retry against disk truth.
643
+ const expectedFingerprint = this.fileFingerprints[storagePath];
644
+ if (expectedFingerprint !== undefined) {
645
+ const state = await this.readFileState(storagePath);
646
+ if (state.fingerprint !== expectedFingerprint) {
647
+ this.setEntries(target, [...new Set(state.entries)]);
648
+ this.fileFingerprints[storagePath] = state.fingerprint;
649
+ throw new ExternalMemoryWriteConflict();
650
+ }
613
651
  }
614
652
  }
615
- return result;
653
+ return await this.finalizeTargetMutation(target, storagePath, result);
616
654
  } catch (error) {
617
- const filePath = storagePath;
618
- delete this.fileFingerprints[filePath];
619
- const state = await this.readFileState(filePath);
655
+ delete this.fileFingerprints[storagePath];
656
+ const state = await this.readFileState(storagePath);
620
657
  this.setEntries(target, [...new Set(state.entries)]);
621
- this.fileFingerprints[filePath] = state.fingerprint;
658
+ this.fileFingerprints[storagePath] = state.fingerprint;
622
659
  if (!(error instanceof ExternalMemoryWriteConflict)) throw error;
623
660
  if (attempt >= MAX_EXTERNAL_WRITE_RETRIES) {
624
- return {
661
+ return await this.finalizeTargetMutation(target, storagePath, {
625
662
  success: false,
626
- error: "Memory file changed repeatedly during this update. No external changes were overwritten.",
627
- };
663
+ error: "Memory file changed repeatedly during this update. No external changes were overwritten. If you edited the file manually, re-run the memory tool or /memory-sync-markdown after the file is stable.",
664
+ });
628
665
  }
629
666
  }
630
667
  }
@@ -718,7 +755,18 @@ export class MemoryStore {
718
755
  }
719
756
 
720
757
  try { await this.unlinkPublishedTempLink(tmpPath); } catch { /* ignore */ }
721
- this.fileFingerprints[filePath] = this.fingerprint(content);
758
+
759
+ // Re-read after publish. An external truncate/cp can land between the
760
+ // link/rename and returning success; treat that as a write conflict so
761
+ // the caller retries against disk truth instead of reporting phantom state.
762
+ const publishedFingerprint = this.fingerprint(content);
763
+ this.fileFingerprints[filePath] = publishedFingerprint;
764
+ const publishedState = await this.readFileState(filePath);
765
+ if (publishedState.fingerprint !== publishedFingerprint) {
766
+ this.setEntries(target, [...new Set(publishedState.entries)]);
767
+ this.fileFingerprints[filePath] = publishedState.fingerprint;
768
+ throw new ExternalMemoryWriteConflict();
769
+ }
722
770
  } catch (err) {
723
771
  try { await fs.unlink(tmpPath); } catch { /* ignore */ }
724
772
  throw err;
@@ -1,5 +1,12 @@
1
1
  import { DatabaseManager } from './db.js';
2
- import { buildFallbackFts5Query, hasExplicitFts5Operator, isFts5QueryError, normalizeFts5Query } from './fts-query.js';
2
+ import {
3
+ buildFallbackFts5Query,
4
+ buildNaturalLanguageFallbackQuery,
5
+ hasExplicitFts5Operator,
6
+ isFts5QueryError,
7
+ normalizeFts5Query,
8
+ normalizeNaturalLanguageFts5Query,
9
+ } from './fts-query.js';
3
10
 
4
11
  /**
5
12
  * Search result from session history.
@@ -93,6 +100,8 @@ export function searchSessions(
93
100
  const db = dbManager.getDb();
94
101
  const { limit = 10, project, role, since } = options;
95
102
 
103
+ let ftsParseError = false;
104
+
96
105
  const executeSearch = (match: SearchMatch): SessionSearchResult[] => {
97
106
  const conditions: string[] = [];
98
107
  const params: unknown[] = [];
@@ -160,6 +169,7 @@ export function searchSessions(
160
169
  return mapRows(rows);
161
170
  } catch (err) {
162
171
  if (match.type === 'fts' && isFts5QueryError(err)) {
172
+ ftsParseError = true;
163
173
  return [];
164
174
  }
165
175
  throw err;
@@ -178,7 +188,28 @@ export function searchSessions(
178
188
 
179
189
  const explicitOperatorQuery = hasExplicitFts5Operator(query);
180
190
  if (explicitOperatorQuery) {
181
- return exactResults;
191
+ // Same recovery as searchMemories: only when the raw operator query fails
192
+ // to parse do we retry it as natural language; valid operator queries that
193
+ // match nothing keep their exact semantics.
194
+ if (!ftsParseError) {
195
+ return exactResults;
196
+ }
197
+ const nlQuery = normalizeNaturalLanguageFts5Query(query);
198
+ if (nlQuery.length > 0 && nlQuery !== normalizedQuery) {
199
+ const nlResults = executeSearch({ type: 'fts', query: nlQuery });
200
+ if (nlResults.length > 0) {
201
+ return nlResults;
202
+ }
203
+ const nlFallback = buildNaturalLanguageFallbackQuery(query);
204
+ if (nlFallback && nlFallback !== nlQuery) {
205
+ const nlFallbackResults = executeSearch({ type: 'fts', query: nlFallback });
206
+ if (nlFallbackResults.length > 0) {
207
+ return nlFallbackResults;
208
+ }
209
+ }
210
+ }
211
+ const likeTerms = collectLikeTerms(query);
212
+ return executeSearch({ type: 'like', terms: likeTerms });
182
213
  }
183
214
 
184
215
  const fallbackQuery = buildFallbackFts5Query(query);
@@ -27,8 +27,9 @@ interface SkillStoreOptions {
27
27
  projectSkillsDir?: string | null;
28
28
  projectName?: string | null;
29
29
  legacySkillsDir?: string;
30
- legacyPiGlobalSkillsDir?: string;
30
+ legacyExtensionSkillsDir?: string;
31
31
  migrationSentinelPath?: string;
32
+ extensionSkillsMigrationSentinelPath?: string;
32
33
  }
33
34
 
34
35
  interface SkillLocation {
@@ -46,13 +47,119 @@ export interface LegacySkillMigrationResult {
46
47
  warnings: string[];
47
48
  }
48
49
 
50
+ const LIST_SECTIONS = new Set(["procedure", "pitfalls", "verification"]);
51
+
52
+ function normalizeSectionName(section: string): string {
53
+ return section.replace(/^#+\s*/, "").trim();
54
+ }
55
+
56
+ function isExactSectionHeader(line: string, section: string): boolean {
57
+ const heading = line.trim().match(/^##\s+(.+?)\s*$/);
58
+ if (!heading) return false;
59
+ return heading[1].trim().toLowerCase() === section.trim().toLowerCase();
60
+ }
61
+
62
+ function looksLikeJsonArray(content: string): boolean {
63
+ const trimmed = content.trim();
64
+ return trimmed.startsWith("[") && trimmed.endsWith("]");
65
+ }
66
+
67
+ function looksLikeJsonObject(content: string): boolean {
68
+ const trimmed = content.trim();
69
+ return trimmed.startsWith("{") && trimmed.endsWith("}");
70
+ }
71
+
72
+ function formatPatchList(section: string, items: string[]): string {
73
+ const cleaned = items.map((item) => item.trim()).filter(Boolean);
74
+ if (cleaned.length === 0) return "";
75
+ const key = section.trim().toLowerCase();
76
+ if (key === "pitfalls") {
77
+ return cleaned.map((item) => `- ${item.replace(/^[-*]\s+/, "")}`).join("\n");
78
+ }
79
+ // Procedure / Verification default to ordered steps.
80
+ return cleaned
81
+ .map((item, index) => `${index + 1}. ${item.replace(/^\d+\.\s+/, "").replace(/^[-*]\s+/, "")}`)
82
+ .join("\n");
83
+ }
84
+
85
+ /**
86
+ * Normalize/validate free-form patch content so LLM format drift cannot wipe
87
+ * or splice skill sections. Coerces JSON string arrays into Markdown lists for
88
+ * list-shaped sections and rejects empty / object / header-injecting payloads.
89
+ */
90
+ export function normalizeSkillPatchContent(
91
+ section: string,
92
+ rawContent: string,
93
+ ): { content: string } | { error: string } {
94
+ const sectionName = normalizeSectionName(section);
95
+ if (!sectionName) {
96
+ return { error: "section is required for patch." };
97
+ }
98
+
99
+ let content = typeof rawContent === "string" ? rawContent.trim() : "";
100
+ if (!content) {
101
+ return {
102
+ error: "New content is required for patch. Prefer structured fields (procedure_steps, pitfalls, verification_steps, when_to_use) over free-form content.",
103
+ };
104
+ }
105
+
106
+ if (looksLikeJsonObject(content)) {
107
+ return {
108
+ error: "Patch content looks like a JSON object. Provide Markdown section body or a string array via structured fields.",
109
+ };
110
+ }
111
+
112
+ if (looksLikeJsonArray(content)) {
113
+ try {
114
+ const parsed: unknown = JSON.parse(content);
115
+ if (!Array.isArray(parsed)) {
116
+ return { error: "Patch content looks like JSON but is not a string array." };
117
+ }
118
+ const items = parsed
119
+ .filter((item): item is string => typeof item === "string")
120
+ .map((item) => item.trim())
121
+ .filter(Boolean);
122
+ if (items.length === 0) {
123
+ return { error: "Patch content JSON array must contain non-empty strings." };
124
+ }
125
+ const key = sectionName.toLowerCase();
126
+ if (key === "when to use") {
127
+ content = items.join("\n\n");
128
+ } else if (LIST_SECTIONS.has(key)) {
129
+ content = formatPatchList(sectionName, items);
130
+ } else {
131
+ content = items.map((item) => `- ${item}`).join("\n");
132
+ }
133
+ } catch {
134
+ return {
135
+ error: "Patch content looks like a JSON array but could not be parsed. Use Markdown or structured string[] fields.",
136
+ };
137
+ }
138
+ }
139
+
140
+ // Reject payloads that would inject extra ## sections mid-body.
141
+ if (/^#{1,6}\s+\S/m.test(content)) {
142
+ return {
143
+ error: "Patch content must not include Markdown section headers (## ...). Patch only the body of the target section.",
144
+ };
145
+ }
146
+
147
+ if (!content.trim()) {
148
+ return { error: "New content is required for patch." };
149
+ }
150
+
151
+ return { content: content.trim() };
152
+ }
153
+
154
+
49
155
  export class SkillStore {
50
156
  private globalSkillsDir: string;
51
157
  private projectSkillsDir: string | null;
52
158
  private projectName: string | null;
53
159
  private legacySkillsDir: string;
54
- private legacyPiGlobalSkillsDir: string;
160
+ private legacyExtensionSkillsDir: string;
55
161
  private migrationSentinelPath: string;
162
+ private extensionSkillsMigrationSentinelPath: string;
56
163
 
57
164
  constructor(options: SkillStoreOptions = {}) {
58
165
  const agentRoot = AGENT_ROOT;
@@ -60,9 +167,12 @@ export class SkillStore {
60
167
  this.projectSkillsDir = options.projectSkillsDir ?? null;
61
168
  this.projectName = options.projectName ?? null;
62
169
  this.legacySkillsDir = options.legacySkillsDir ?? path.join(agentRoot, "memory", "skills");
63
- this.legacyPiGlobalSkillsDir = options.legacyPiGlobalSkillsDir ?? path.join(agentRoot, "skills");
170
+ this.legacyExtensionSkillsDir = options.legacyExtensionSkillsDir
171
+ ?? path.join(agentRoot, "pi-hermes-memory", "skills");
64
172
  this.migrationSentinelPath = options.migrationSentinelPath
65
173
  ?? 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");
66
176
  }
67
177
 
68
178
  getGlobalSkillsDir(): string {
@@ -95,16 +205,19 @@ export class SkillStore {
95
205
  // Always normalize flat markdown files under the global skills root,
96
206
  // even when a previous migration sentinel already exists.
97
207
  await this.migrateFlatMarkdownInGlobalSkillsDir(result);
208
+ await this.migrateExtensionSkillsToGlobalRoot(result);
98
209
 
99
210
  if (await exists(this.migrationSentinelPath)) return result;
100
211
 
101
212
  await fs.mkdir(path.dirname(this.migrationSentinelPath), { recursive: true });
102
213
 
214
+ // Only this migration's own warnings may hold back its sentinel — a
215
+ // permanently shadowed skill reported above must not make it retry forever.
216
+ const warningsBefore = result.warnings.length;
103
217
  try {
104
218
  await this.migrateLegacyMarkdownSkills(result);
105
- await this.migrateLegacyPiGlobalSkillDirs(result);
106
219
  } finally {
107
- if (result.warnings.length === 0) {
220
+ if (result.warnings.length === warningsBefore) {
108
221
  await fs.writeFile(this.migrationSentinelPath, `${new Date().toISOString()}\n`, "utf-8");
109
222
  }
110
223
  }
@@ -202,45 +315,61 @@ export class SkillStore {
202
315
  }
203
316
  }
204
317
 
205
- private async migrateLegacyPiGlobalSkillDirs(result: LegacySkillMigrationResult): Promise<void> {
206
- if (path.resolve(this.legacyPiGlobalSkillsDir) === path.resolve(this.globalSkillsDir)) return;
207
- if (!await exists(this.legacyPiGlobalSkillsDir)) return;
318
+ /**
319
+ * Move extension-managed global skills back into Pi's own global skills root.
320
+ *
321
+ * Pi keys skills by name, first-loaded wins, and `~/.pi/agent/skills/` is
322
+ * 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).
327
+ *
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.
330
+ */
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;
208
338
 
209
- const entries = await fs.readdir(this.legacyPiGlobalSkillsDir, { withFileTypes: true });
210
339
  for (const entry of entries) {
211
340
  if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
212
341
 
213
- const sourceDir = path.join(this.legacyPiGlobalSkillsDir, entry.name);
214
- const sourceSkill = path.join(sourceDir, "SKILL.md");
215
- if (!await exists(sourceSkill)) continue;
342
+ const sourceDir = path.join(this.legacyExtensionSkillsDir, entry.name);
343
+ if (!await exists(path.join(sourceDir, "SKILL.md"))) continue;
216
344
 
217
345
  const targetDir = path.join(this.globalSkillsDir, entry.name);
218
- const targetSkill = path.join(targetDir, "SKILL.md");
219
- if (await exists(targetSkill)) {
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++;
220
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
+ );
221
355
  continue;
222
356
  }
223
357
 
224
358
  try {
225
- const raw = await fs.readFile(sourceSkill, "utf-8");
226
- const parsed = parseFrontmatter(raw);
227
- const hasExtensionManagedMeta = Boolean(parsed.meta.display_name)
228
- && Boolean(parsed.meta.created)
229
- && Boolean(parsed.meta.updated)
230
- && /^\d+$/.test(parsed.meta.version ?? "");
231
-
232
- if (!hasExtensionManagedMeta) {
233
- result.skipped++;
234
- continue;
235
- }
236
-
237
359
  await fs.mkdir(path.dirname(targetDir), { recursive: true });
238
360
  await fs.rename(sourceDir, targetDir);
239
361
  result.migrated++;
240
362
  } catch (error) {
363
+ shadowed++;
241
364
  result.warnings.push(`${entry.name}: ${error instanceof Error ? error.message : String(error)}`);
242
365
  }
243
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
+ }
244
373
  }
245
374
 
246
375
  async loadIndex(scope?: SkillScope): Promise<SkillIndex[]> {
@@ -352,34 +481,47 @@ export class SkillStore {
352
481
  }
353
482
 
354
483
  async patch(skillId: string, section: string, newContent: string): Promise<SkillResult> {
355
- newContent = newContent.trim();
356
- if (!newContent) return { success: false, error: "New content is required for patch." };
484
+ const sectionName = normalizeSectionName(section);
485
+ if (!sectionName) return { success: false, error: "section is required for patch." };
486
+
487
+ const normalized = normalizeSkillPatchContent(sectionName, newContent);
488
+ if ("error" in normalized) return { success: false, error: normalized.error };
489
+ const content = normalized.content;
357
490
 
358
- const scanError = scanContent(newContent);
491
+ const scanError = scanContent(content);
359
492
  if (scanError) return { success: false, error: scanError };
360
493
 
361
494
  const doc = await this.loadSkill(skillId);
362
495
  if (!doc) return { success: false, error: `Skill '${skillId}' not found.` };
363
496
 
364
- const sectionHeader = `## ${section}`;
497
+ const sectionHeader = `## ${sectionName}`;
365
498
  const lines = doc.body.split("\n");
366
499
  let found = false;
367
500
  const result: string[] = [];
368
501
 
369
502
  for (let i = 0; i < lines.length; i++) {
370
- if (lines[i].startsWith(sectionHeader)) {
503
+ if (isExactSectionHeader(lines[i], sectionName)) {
371
504
  result.push(sectionHeader);
372
- result.push(newContent);
505
+ // Preserve multi-line body as discrete lines so later headers stay exact.
506
+ for (const bodyLine of content.split("\n")) {
507
+ result.push(bodyLine);
508
+ }
373
509
  found = true;
374
510
  i++;
375
- while (i < lines.length && !lines[i].startsWith("## ")) i++;
511
+ while (i < lines.length && !lines[i].trim().startsWith("## ")) i++;
376
512
  if (i < lines.length) result.push(lines[i]);
377
513
  } else {
378
514
  result.push(lines[i]);
379
515
  }
380
516
  }
381
517
 
382
- if (!found) result.push("", sectionHeader, newContent);
518
+ if (!found) {
519
+ if (result.length > 0 && result[result.length - 1] !== "") result.push("");
520
+ result.push(sectionHeader);
521
+ for (const bodyLine of content.split("\n")) {
522
+ result.push(bodyLine);
523
+ }
524
+ }
383
525
 
384
526
  await this.atomicWrite(doc.path, formatFrontmatter({
385
527
  name: doc.name,
@@ -393,7 +535,7 @@ export class SkillStore {
393
535
 
394
536
  return {
395
537
  success: true,
396
- message: `Skill '${doc.displayName || doc.name}' section '${section}' updated.`,
538
+ message: `Skill '${doc.displayName || doc.name}' section '${sectionName}' updated.`,
397
539
  fileName: doc.fileName,
398
540
  skillId: doc.skillId,
399
541
  scope: doc.scope,