pi-hermes-memory 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -92,6 +92,18 @@ Every write — memory and skills — passes through a scanner before being acce
92
92
 
93
93
  ![Security: Content Scanning](docs/images/security-flow.svg)
94
94
 
95
+ ## Development
96
+
97
+ `npm run check` and `npm test` only work from a **full git checkout** after `npm install`. The published npm package intentionally omits `tests/`, TypeScript, and `tsconfig.json` (production install for Pi). Validate from source or rely on CI before publish.
98
+
99
+ ```bash
100
+ git clone https://github.com/chandra447/pi-hermes-memory.git
101
+ cd pi-hermes-memory
102
+ npm install
103
+ npm run check
104
+ npm test
105
+ ```
106
+
95
107
  ## Installation
96
108
 
97
109
  ```bash
@@ -110,6 +122,23 @@ Or test locally without installing:
110
122
  pi -e /path/to/pi-hermes-memory/src/index.ts
111
123
  ```
112
124
 
125
+ ### Homebrew / Node ABI mismatches
126
+
127
+ `better-sqlite3` is a native addon. If Pi is installed via Homebrew and the extension was compiled for a different Node ABI, session search may warn:
128
+
129
+ ```text
130
+ was compiled against a different Node.js version using NODE_MODULE_VERSION ...
131
+ ```
132
+
133
+ The extension attempts one automatic `npm rebuild better-sqlite3` against the Node that is running Pi. If that still fails:
134
+
135
+ ```bash
136
+ cd ~/.pi/agent/npm/node_modules/better-sqlite3
137
+ npm rebuild better-sqlite3
138
+ ```
139
+
140
+ Or install Pi with npm so the host runtime and extension install share one Node toolchain.
141
+
113
142
  ## Two-Tier Memory Architecture
114
143
 
115
144
  The extension stores memory at two levels:
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "pi-hermes-memory",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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",
7
7
  "files": [
8
8
  "src",
9
+ "scripts",
9
10
  "README.md",
10
11
  "LICENSE",
11
12
  "docs"
@@ -16,8 +17,8 @@
16
17
  ]
17
18
  },
18
19
  "scripts": {
19
- "check": "tsc --noEmit",
20
- "test": "tests/run-all.sh"
20
+ "check": "node scripts/ensure-dev.mjs check && tsc --noEmit",
21
+ "test": "node scripts/ensure-dev.mjs test && tests/run-all.sh"
21
22
  },
22
23
  "keywords": [
23
24
  "pi-package",
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Guard for check/test npm scripts.
4
+ *
5
+ * The published tarball intentionally omits tests/ and TypeScript (devDependency).
6
+ * When those scripts are run from an installed package (e.g. after `pi install`),
7
+ * fail with a clear message instead of `tsc: not found` / `tests/run-all.sh: not found`.
8
+ *
9
+ * @see https://github.com/chandra447/pi-hermes-memory/issues/108
10
+ */
11
+ import { existsSync } from "node:fs";
12
+ import { createRequire } from "node:module";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
17
+ const mode = process.argv[2];
18
+ const REPO = "https://github.com/chandra447/pi-hermes-memory";
19
+
20
+ function fail(message) {
21
+ console.error(`\n[pi-hermes-memory] ${message}`);
22
+ console.error("");
23
+ console.error("These scripts only work from a full source checkout:");
24
+ console.error(` git clone ${REPO}.git`);
25
+ console.error(" cd pi-hermes-memory && npm install");
26
+ console.error(" npm run check # or: npm test");
27
+ console.error("");
28
+ console.error(
29
+ "The published package is for runtime use via Pi; CI validates check/test before publish.",
30
+ );
31
+ console.error("");
32
+ process.exit(1);
33
+ }
34
+
35
+ if (mode === "check") {
36
+ const require = createRequire(join(root, "package.json"));
37
+ try {
38
+ require.resolve("typescript/package.json");
39
+ } catch {
40
+ fail(
41
+ "`npm run check` requires TypeScript (a devDependency). The published npm package does not include it.",
42
+ );
43
+ }
44
+ if (!existsSync(join(root, "tsconfig.json"))) {
45
+ fail(
46
+ "`npm run check` requires tsconfig.json. The published npm package does not include it.",
47
+ );
48
+ }
49
+ } else if (mode === "test") {
50
+ if (!existsSync(join(root, "tests", "run-all.sh"))) {
51
+ fail(
52
+ "`npm test` requires the tests/ directory. The published npm package intentionally omits it.",
53
+ );
54
+ }
55
+ } else {
56
+ fail(`Unknown mode "${mode ?? ""}". Expected "check" or "test".`);
57
+ }
package/src/constants.ts CHANGED
@@ -70,7 +70,7 @@ If memory conflicts with current evidence, prefer current evidence and mention t
70
70
  Procedural skills:
71
71
  - Use the skill_manage tool during normal work when a task reveals a reusable how-to workflow, or when the user asks you to remember how to do something later.
72
72
  - Always pass scope explicitly on create: scope="global" for portable procedures, scope="project" for workflows tied to this repo's paths, scripts, architecture, deploy steps, or conventions.
73
- - Prefer structured fields for create/update: when_to_use, procedure_steps, pitfalls, verification_steps. Use patch to improve a specific section of an existing skill, update for a full rewrite, and view to inspect existing skills before changing them.
73
+ - Prefer structured fields for create/update/patch: when_to_use, procedure_steps, pitfalls, verification_steps. Use patch with the matching structured field for one section, update for a full rewrite, and view before changing an existing skill.
74
74
  - Do not create skills for one-off task state, generic summaries, or overly file-specific notes that will create noisy future matches.
75
75
 
76
76
  Do not use memory_search for generic questions, one-off examples, or explanations where durable memory would not help.
@@ -92,7 +92,7 @@ Memory write targets: user for preferences/profile; memory for global notes and
92
92
 
93
93
  memory_search filters: target searches user/global/failure memories; project filters project-scoped memories; category filters categorized failure/lesson memories only.
94
94
 
95
- Use the skill_manage tool during normal work for reusable procedures. On create, scope is required: global for transferable workflows, project for repo-specific ones. Prefer structured fields for create/update, patch for focused changes, and update for full rewrites. Skip one-off or overly narrow skills.
95
+ Use the skill_manage tool during normal work for reusable procedures. On create, scope is required: global for transferable workflows, project for repo-specific ones. Prefer structured fields for create/update/patch, patch for one section, and update for full rewrites. Skip one-off or overly narrow skills.
96
96
 
97
97
  Use category only for categorized failure/lesson searches. Do not use memory_search for generic questions, one-off examples, or explanations where durable memory would not help.
98
98
 
@@ -315,20 +315,21 @@ SCOPE:
315
315
  - 'global': transferable procedures that can be reused across repositories
316
316
  - 'project': procedures tied to this repo's paths, scripts, architecture, deploy flow, or conventions
317
317
 
318
- WHEN TO UPDATE A SKILL (use 'patch'):
319
- - You discover a better approach for an existing skill
320
- - A pitfall or edge case not covered by the skill
321
- - A step in the procedure changed
318
+ WHEN TO UPDATE A SKILL:
319
+ - Prefer 'patch' for one section when you can pass structured fields
320
+ - Prefer 'update' for multi-section rewrites or when patch formatting would be unstable
321
+ - Use patch when you discover a better approach, pitfall, or changed step in one section
322
322
 
323
323
  SKILL FORMAT:
324
324
  - name: short, descriptive (e.g., "debug-typescript-errors")
325
325
  - description: one-line summary of when to use it
326
326
  - body: structured with sections — ## When to Use, ## Procedure, ## Pitfalls, ## Verification
327
- - Prefer structured create/update fields over raw markdown when possible:
327
+ - Prefer structured fields over raw markdown when possible:
328
328
  - when_to_use: trigger conditions and boundaries
329
329
  - procedure_steps: ordered concrete steps
330
330
  - pitfalls: caveats or failure modes
331
331
  - verification_steps: checks that prove success
332
+ - For patch, pass section plus the matching structured field (section="Procedure" + procedure_steps, etc.). Do not pass JSON array/object strings as content.
332
333
 
333
334
  ONE-SHOT EXAMPLE:
334
335
  {
@@ -2,10 +2,27 @@ import fs from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import path from "node:path";
5
- import Database from "better-sqlite3";
6
5
  import { AtomicLockCoordinator, type AtomicLockLease } from "./store/atomic-lock-coordinator.js";
7
6
  import { canonicalStoragePathSync } from "./store/canonical-storage-path.js";
8
-
7
+ import { loadBetterSqlite3 } from "./store/sqlite-native.js";
8
+
9
+ type MigrationDatabase = {
10
+ exec: (sql: string) => void;
11
+ prepare: (sql: string) => { get: (...args: unknown[]) => unknown; run: (...args: unknown[]) => unknown };
12
+ close: () => void;
13
+ pragma: (query: string, options?: { simple?: boolean }) => unknown;
14
+ backup: (
15
+ destination: string,
16
+ options?: { progress?: () => void },
17
+ ) => Promise<void> | void;
18
+ };
19
+
20
+ type MigrationDatabaseCtor = new (
21
+ dbPath: string,
22
+ options?: { readonly?: boolean; fileMustExist?: boolean; timeout?: number },
23
+ ) => MigrationDatabase;
24
+
25
+ const Database = loadBetterSqlite3() as MigrationDatabaseCtor;
9
26
  const DATABASE_FILES = ["sessions.db", "sessions.db-wal", "sessions.db-shm"] as const;
10
27
  const DATABASE_MIGRATION_PENDING_FILE = ".sessions-db-migration-pending";
11
28
 
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { createRequire } from 'node:module';
5
5
  import { spawnSync } from 'node:child_process';
6
+ import { loadBetterSqlite3 } from './sqlite-native.js';
6
7
 
7
8
  type StatementLike = {
8
9
  run: (...args: unknown[]) => unknown;
@@ -39,8 +40,7 @@ function loadDatabaseCtor(): DatabaseCtor {
39
40
  const bunSqlite = require('bun:sqlite') as { Database: DatabaseCtor };
40
41
  return bunSqlite.Database;
41
42
  }
42
- const mod = require('better-sqlite3') as { default?: DatabaseCtor } | DatabaseCtor;
43
- return (mod as { default?: DatabaseCtor }).default ?? (mod as DatabaseCtor);
43
+ return loadBetterSqlite3({ requireImpl: require }) as DatabaseCtor;
44
44
  }
45
45
 
46
46
  const Database = loadDatabaseCtor();
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 { 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'];
@@ -126,12 +128,7 @@ function loadDatabaseCtor(): DatabaseCtor {
126
128
  return createBunCompatDatabaseCtor(require);
127
129
  }
128
130
 
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;
134
- }
131
+ return loadBetterSqlite3({ requireImpl: require }) as DatabaseCtor;
135
132
  }
136
133
 
137
134
  const Database = loadDatabaseCtor();
@@ -294,6 +291,9 @@ export class DatabaseManager {
294
291
  }
295
292
 
296
293
  private configureConnection(db: DatabaseLike): void {
294
+ // Wait briefly for concurrent writers across Pi processes instead of failing
295
+ // immediately with SQLITE_BUSY. Connection-local; applies before WAL/schema.
296
+ db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
297
297
  // Enable WAL mode + FK enforcement for each connection. Keep SQLite's
298
298
  // default WAL autocheckpoint size; a very aggressive checkpoint cadence
299
299
  // increases the chance that abrupt VM/host shutdown catches a checkpoint.
@@ -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;
@@ -46,6 +46,111 @@ export interface LegacySkillMigrationResult {
46
46
  warnings: string[];
47
47
  }
48
48
 
49
+ const LIST_SECTIONS = new Set(["procedure", "pitfalls", "verification"]);
50
+
51
+ function normalizeSectionName(section: string): string {
52
+ return section.replace(/^#+\s*/, "").trim();
53
+ }
54
+
55
+ function isExactSectionHeader(line: string, section: string): boolean {
56
+ const heading = line.trim().match(/^##\s+(.+?)\s*$/);
57
+ if (!heading) return false;
58
+ return heading[1].trim().toLowerCase() === section.trim().toLowerCase();
59
+ }
60
+
61
+ function looksLikeJsonArray(content: string): boolean {
62
+ const trimmed = content.trim();
63
+ return trimmed.startsWith("[") && trimmed.endsWith("]");
64
+ }
65
+
66
+ function looksLikeJsonObject(content: string): boolean {
67
+ const trimmed = content.trim();
68
+ return trimmed.startsWith("{") && trimmed.endsWith("}");
69
+ }
70
+
71
+ function formatPatchList(section: string, items: string[]): string {
72
+ const cleaned = items.map((item) => item.trim()).filter(Boolean);
73
+ if (cleaned.length === 0) return "";
74
+ const key = section.trim().toLowerCase();
75
+ if (key === "pitfalls") {
76
+ return cleaned.map((item) => `- ${item.replace(/^[-*]\s+/, "")}`).join("\n");
77
+ }
78
+ // Procedure / Verification default to ordered steps.
79
+ return cleaned
80
+ .map((item, index) => `${index + 1}. ${item.replace(/^\d+\.\s+/, "").replace(/^[-*]\s+/, "")}`)
81
+ .join("\n");
82
+ }
83
+
84
+ /**
85
+ * Normalize/validate free-form patch content so LLM format drift cannot wipe
86
+ * or splice skill sections. Coerces JSON string arrays into Markdown lists for
87
+ * list-shaped sections and rejects empty / object / header-injecting payloads.
88
+ */
89
+ export function normalizeSkillPatchContent(
90
+ section: string,
91
+ rawContent: string,
92
+ ): { content: string } | { error: string } {
93
+ const sectionName = normalizeSectionName(section);
94
+ if (!sectionName) {
95
+ return { error: "section is required for patch." };
96
+ }
97
+
98
+ let content = typeof rawContent === "string" ? rawContent.trim() : "";
99
+ if (!content) {
100
+ return {
101
+ error: "New content is required for patch. Prefer structured fields (procedure_steps, pitfalls, verification_steps, when_to_use) over free-form content.",
102
+ };
103
+ }
104
+
105
+ if (looksLikeJsonObject(content)) {
106
+ return {
107
+ error: "Patch content looks like a JSON object. Provide Markdown section body or a string array via structured fields.",
108
+ };
109
+ }
110
+
111
+ if (looksLikeJsonArray(content)) {
112
+ try {
113
+ const parsed: unknown = JSON.parse(content);
114
+ if (!Array.isArray(parsed)) {
115
+ return { error: "Patch content looks like JSON but is not a string array." };
116
+ }
117
+ const items = parsed
118
+ .filter((item): item is string => typeof item === "string")
119
+ .map((item) => item.trim())
120
+ .filter(Boolean);
121
+ if (items.length === 0) {
122
+ return { error: "Patch content JSON array must contain non-empty strings." };
123
+ }
124
+ const key = sectionName.toLowerCase();
125
+ if (key === "when to use") {
126
+ content = items.join("\n\n");
127
+ } else if (LIST_SECTIONS.has(key)) {
128
+ content = formatPatchList(sectionName, items);
129
+ } else {
130
+ content = items.map((item) => `- ${item}`).join("\n");
131
+ }
132
+ } catch {
133
+ return {
134
+ error: "Patch content looks like a JSON array but could not be parsed. Use Markdown or structured string[] fields.",
135
+ };
136
+ }
137
+ }
138
+
139
+ // Reject payloads that would inject extra ## sections mid-body.
140
+ if (/^#{1,6}\s+\S/m.test(content)) {
141
+ return {
142
+ error: "Patch content must not include Markdown section headers (## ...). Patch only the body of the target section.",
143
+ };
144
+ }
145
+
146
+ if (!content.trim()) {
147
+ return { error: "New content is required for patch." };
148
+ }
149
+
150
+ return { content: content.trim() };
151
+ }
152
+
153
+
49
154
  export class SkillStore {
50
155
  private globalSkillsDir: string;
51
156
  private projectSkillsDir: string | null;
@@ -352,34 +457,47 @@ export class SkillStore {
352
457
  }
353
458
 
354
459
  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." };
460
+ const sectionName = normalizeSectionName(section);
461
+ if (!sectionName) return { success: false, error: "section is required for patch." };
462
+
463
+ const normalized = normalizeSkillPatchContent(sectionName, newContent);
464
+ if ("error" in normalized) return { success: false, error: normalized.error };
465
+ const content = normalized.content;
357
466
 
358
- const scanError = scanContent(newContent);
467
+ const scanError = scanContent(content);
359
468
  if (scanError) return { success: false, error: scanError };
360
469
 
361
470
  const doc = await this.loadSkill(skillId);
362
471
  if (!doc) return { success: false, error: `Skill '${skillId}' not found.` };
363
472
 
364
- const sectionHeader = `## ${section}`;
473
+ const sectionHeader = `## ${sectionName}`;
365
474
  const lines = doc.body.split("\n");
366
475
  let found = false;
367
476
  const result: string[] = [];
368
477
 
369
478
  for (let i = 0; i < lines.length; i++) {
370
- if (lines[i].startsWith(sectionHeader)) {
479
+ if (isExactSectionHeader(lines[i], sectionName)) {
371
480
  result.push(sectionHeader);
372
- result.push(newContent);
481
+ // Preserve multi-line body as discrete lines so later headers stay exact.
482
+ for (const bodyLine of content.split("\n")) {
483
+ result.push(bodyLine);
484
+ }
373
485
  found = true;
374
486
  i++;
375
- while (i < lines.length && !lines[i].startsWith("## ")) i++;
487
+ while (i < lines.length && !lines[i].trim().startsWith("## ")) i++;
376
488
  if (i < lines.length) result.push(lines[i]);
377
489
  } else {
378
490
  result.push(lines[i]);
379
491
  }
380
492
  }
381
493
 
382
- if (!found) result.push("", sectionHeader, newContent);
494
+ if (!found) {
495
+ if (result.length > 0 && result[result.length - 1] !== "") result.push("");
496
+ result.push(sectionHeader);
497
+ for (const bodyLine of content.split("\n")) {
498
+ result.push(bodyLine);
499
+ }
500
+ }
383
501
 
384
502
  await this.atomicWrite(doc.path, formatFrontmatter({
385
503
  name: doc.name,
@@ -393,7 +511,7 @@ export class SkillStore {
393
511
 
394
512
  return {
395
513
  success: true,
396
- message: `Skill '${doc.displayName || doc.name}' section '${section}' updated.`,
514
+ message: `Skill '${doc.displayName || doc.name}' section '${sectionName}' updated.`,
397
515
  fileName: doc.fileName,
398
516
  skillId: doc.skillId,
399
517
  scope: doc.scope,
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Shared better-sqlite3 loader with ABI mismatch recovery.
3
+ *
4
+ * Pi installs extension deps under ~/.pi/agent/npm. When the host Node that
5
+ * runs Pi (e.g. Homebrew) differs from the Node that compiled better-sqlite3,
6
+ * require() throws NODE_MODULE_VERSION errors. Detect that, attempt one
7
+ * npm rebuild against the current runtime, and surface a clear recovery path.
8
+ */
9
+
10
+ import { createRequire } from "node:module";
11
+ import { spawnSync } from "node:child_process";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+
15
+ export type BetterSqlite3DatabaseCtor = new (
16
+ dbPath: string,
17
+ options?: { readonly?: boolean; fileMustExist?: boolean; timeout?: number },
18
+ ) => unknown;
19
+
20
+ export interface SqliteNativeLoadOptions {
21
+ /** Override createRequire base URL (tests). */
22
+ requireFrom?: string | URL;
23
+ /** Inject require() for tests. */
24
+ requireImpl?: NodeRequire;
25
+ /** Inject rebuild runner for tests. */
26
+ rebuild?: (packageRoot: string) => { ok: boolean; detail: string };
27
+ /** Force treating the first load failure as rebuild-eligible (tests). */
28
+ allowRebuild?: boolean;
29
+ }
30
+
31
+ export class BetterSqlite3LoadError extends Error {
32
+ readonly code = "BETTER_SQLITE3_LOAD_FAILED";
33
+ readonly packageRoot: string | null;
34
+ readonly causeError: unknown;
35
+
36
+ constructor(message: string, options: { packageRoot?: string | null; cause?: unknown } = {}) {
37
+ super(message);
38
+ this.name = "BetterSqlite3LoadError";
39
+ this.packageRoot = options.packageRoot ?? null;
40
+ this.causeError = options.cause;
41
+ }
42
+ }
43
+
44
+ const ABI_MISMATCH_RE =
45
+ /NODE_MODULE_VERSION|was compiled against a different Node\.js version|ERR_DLOPEN_FAILED/i;
46
+
47
+ export function isNativeModuleAbiMismatch(error: unknown): boolean {
48
+ if (!error) return false;
49
+ const message = error instanceof Error ? error.message : String(error);
50
+ if (ABI_MISMATCH_RE.test(message)) return true;
51
+ if (typeof error === "object" && error !== null) {
52
+ const code = "code" in error ? String((error as { code?: unknown }).code ?? "") : "";
53
+ if (code === "ERR_DLOPEN_FAILED") return true;
54
+ }
55
+ return false;
56
+ }
57
+
58
+ export function resolveBetterSqlite3PackageRoot(requireImpl: NodeRequire): string | null {
59
+ try {
60
+ const entry = requireImpl.resolve("better-sqlite3");
61
+ let dir = path.dirname(entry);
62
+ while (true) {
63
+ const pkgPath = path.join(dir, "package.json");
64
+ if (fs.existsSync(pkgPath)) {
65
+ try {
66
+ const raw = fs.readFileSync(pkgPath, "utf-8");
67
+ const pkg = JSON.parse(raw) as { name?: unknown };
68
+ if (pkg && typeof pkg === "object" && pkg.name === "better-sqlite3") return dir;
69
+ } catch {
70
+ // keep walking
71
+ }
72
+ }
73
+ const parent = path.dirname(dir);
74
+ if (parent === dir) break;
75
+ dir = parent;
76
+ }
77
+
78
+ // Classic layout fallback: .../node_modules/better-sqlite3/<...>
79
+ const parts = entry.split(path.sep);
80
+ const idx = parts.lastIndexOf("better-sqlite3");
81
+ if (idx >= 0) {
82
+ return parts.slice(0, idx + 1).join(path.sep) || null;
83
+ }
84
+ return path.dirname(entry);
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+ function defaultRebuild(packageRoot: string): { ok: boolean; detail: string } {
90
+ const npmExecPath = typeof process.env.npm_execpath === "string" && process.env.npm_execpath
91
+ ? process.env.npm_execpath
92
+ : null;
93
+
94
+ const attempts: Array<{ command: string; args: string[]; shell?: boolean }> = [];
95
+ if (npmExecPath) {
96
+ attempts.push({ command: process.execPath, args: [npmExecPath, "rebuild", "better-sqlite3"] });
97
+ }
98
+ attempts.push({ command: "npm", args: ["rebuild", "better-sqlite3"] });
99
+
100
+ const details: string[] = [];
101
+ for (const attempt of attempts) {
102
+ const result = spawnSync(attempt.command, attempt.args, {
103
+ cwd: packageRoot,
104
+ encoding: "utf-8",
105
+ env: process.env,
106
+ timeout: 120_000,
107
+ shell: attempt.shell ?? false,
108
+ });
109
+ if (result.error) {
110
+ details.push(`${attempt.command}: ${result.error.message}`);
111
+ continue;
112
+ }
113
+ if (result.status === 0) {
114
+ return { ok: true, detail: (result.stdout || result.stderr || "rebuild ok").trim() };
115
+ }
116
+ const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
117
+ details.push(`${attempt.command} exited ${result.status}${output ? `: ${output}` : ""}`);
118
+ }
119
+
120
+ return { ok: false, detail: details.join(" | ") || "rebuild failed" };
121
+ }
122
+
123
+ function unwrapModule(mod: { default?: BetterSqlite3DatabaseCtor } | BetterSqlite3DatabaseCtor): BetterSqlite3DatabaseCtor {
124
+ return (mod as { default?: BetterSqlite3DatabaseCtor }).default ?? (mod as BetterSqlite3DatabaseCtor);
125
+ }
126
+
127
+ function clearBetterSqlite3RequireCache(requireImpl: NodeRequire, packageRoot: string | null): void {
128
+ for (const key of Object.keys(requireImpl.cache)) {
129
+ if (!key.includes(`${path.sep}better-sqlite3${path.sep}`) && !key.endsWith(`${path.sep}better-sqlite3`)) {
130
+ continue;
131
+ }
132
+ if (packageRoot && !key.startsWith(packageRoot)) continue;
133
+ delete requireImpl.cache[key];
134
+ }
135
+ }
136
+
137
+ export function formatBetterSqlite3AbiError(options: {
138
+ originalError: unknown;
139
+ packageRoot: string | null;
140
+ rebuildAttempted: boolean;
141
+ rebuildDetail?: string;
142
+ }): string {
143
+ const original = options.originalError instanceof Error
144
+ ? options.originalError.message
145
+ : String(options.originalError);
146
+ const runtime = `Node ${process.version} (NODE_MODULE_VERSION ${process.versions.modules}) via ${process.execPath}`;
147
+ const location = options.packageRoot ?? "(better-sqlite3 package root unknown)";
148
+ const rebuildLine = options.rebuildAttempted
149
+ ? (options.rebuildDetail
150
+ ? `Automatic rebuild was attempted and failed: ${options.rebuildDetail}`
151
+ : "Automatic rebuild was attempted and failed.")
152
+ : "Automatic rebuild was not attempted.";
153
+
154
+ return [
155
+ "pi-hermes-memory could not load the native better-sqlite3 module for this Node runtime.",
156
+ `Runtime: ${runtime}`,
157
+ `Module: ${location}`,
158
+ `Original error: ${original}`,
159
+ rebuildLine,
160
+ "Fix: rebuild the extension install against the same Node that runs Pi, then restart Pi:",
161
+ options.packageRoot
162
+ ? ` cd "${options.packageRoot}" && npm rebuild better-sqlite3`
163
+ : " cd ~/.pi/agent/npm && npm rebuild better-sqlite3",
164
+ "If you installed Pi with Homebrew, either rebuild as above after brew Node upgrades, or install Pi with npm so the extension and host share one Node toolchain.",
165
+ ].join("\n");
166
+ }
167
+
168
+ /**
169
+ * Load better-sqlite3, attempting one rebuild on ABI/dlopen mismatch.
170
+ */
171
+ export function loadBetterSqlite3(options: SqliteNativeLoadOptions = {}): BetterSqlite3DatabaseCtor {
172
+ const requireImpl = options.requireImpl
173
+ ?? createRequire(options.requireFrom ?? import.meta.url);
174
+
175
+ const loadOnce = (): BetterSqlite3DatabaseCtor => {
176
+ const mod = requireImpl("better-sqlite3") as { default?: BetterSqlite3DatabaseCtor } | BetterSqlite3DatabaseCtor;
177
+ return unwrapModule(mod);
178
+ };
179
+
180
+ try {
181
+ return loadOnce();
182
+ } catch (firstError) {
183
+ const packageRoot = resolveBetterSqlite3PackageRoot(requireImpl);
184
+ const canRebuild = options.allowRebuild ?? isNativeModuleAbiMismatch(firstError);
185
+ if (!canRebuild || !packageRoot) {
186
+ if (isNativeModuleAbiMismatch(firstError)) {
187
+ throw new BetterSqlite3LoadError(
188
+ formatBetterSqlite3AbiError({
189
+ originalError: firstError,
190
+ packageRoot,
191
+ rebuildAttempted: false,
192
+ }),
193
+ { packageRoot, cause: firstError },
194
+ );
195
+ }
196
+ throw firstError;
197
+ }
198
+
199
+ const rebuild = options.rebuild ?? defaultRebuild;
200
+ const rebuildResult = rebuild(packageRoot);
201
+ clearBetterSqlite3RequireCache(requireImpl, packageRoot);
202
+
203
+ if (rebuildResult.ok) {
204
+ try {
205
+ return loadOnce();
206
+ } catch (secondError) {
207
+ throw new BetterSqlite3LoadError(
208
+ formatBetterSqlite3AbiError({
209
+ originalError: secondError,
210
+ packageRoot,
211
+ rebuildAttempted: true,
212
+ rebuildDetail: rebuildResult.detail,
213
+ }),
214
+ { packageRoot, cause: secondError },
215
+ );
216
+ }
217
+ }
218
+
219
+ throw new BetterSqlite3LoadError(
220
+ formatBetterSqlite3AbiError({
221
+ originalError: firstError,
222
+ packageRoot,
223
+ rebuildAttempted: true,
224
+ rebuildDetail: rebuildResult.detail,
225
+ }),
226
+ { packageRoot, cause: firstError },
227
+ );
228
+ }
229
+ }
@@ -66,22 +66,22 @@ const SKILL_TOOL_PARAMETERS = Type.Object({
66
66
  description: "Required for create. Use 'global' for portable procedures and 'project' for repo-specific workflows.",
67
67
  })),
68
68
  section: Type.Optional(Type.String({
69
- description: "Required for patch. Section header to patch. e.g., 'Procedure', 'Pitfalls'.",
69
+ description: "Required for patch. Section header to patch. e.g., 'Procedure', 'Pitfalls', 'Verification', 'When to Use'.",
70
70
  })),
71
71
  content: Type.Optional(Type.String({
72
- description: "Raw markdown body for create/update/edit, or new section content for patch. For create/update/edit you can provide this or the structured fields below.",
72
+ description: "Raw markdown body for create/update/edit, or Markdown section body for patch. Prefer structured fields over free-form content when possible. For patch, JSON arrays are auto-coerced for list sections; JSON objects are rejected.",
73
73
  })),
74
74
  when_to_use: Type.Optional(Type.String({
75
- description: "Structured create/update/edit field. Explain when this skill should be used and where its boundaries are.",
75
+ description: "Structured create/update/edit field, or structured patch body when section is 'When to Use'.",
76
76
  })),
77
77
  procedure_steps: Type.Optional(Type.Array(Type.String(), {
78
- description: "Structured create/update/edit field. Ordered concrete steps for the workflow.",
78
+ description: "Structured create/update/edit field, or structured patch body when section is 'Procedure'. Ordered concrete steps.",
79
79
  })),
80
80
  pitfalls: Type.Optional(Type.Array(Type.String(), {
81
- description: "Structured create/update/edit field. Optional common mistakes, caveats, or failure modes to avoid.",
81
+ description: "Structured create/update/edit field, or structured patch body when section is 'Pitfalls'.",
82
82
  })),
83
83
  verification_steps: Type.Optional(Type.Array(Type.String(), {
84
- description: "Structured create/update/edit field. Concrete checks that confirm the workflow succeeded.",
84
+ description: "Structured create/update/edit field, or structured patch body when section is 'Verification'.",
85
85
  })),
86
86
  }, { additionalProperties: false });
87
87
 
@@ -97,7 +97,9 @@ export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
97
97
  "Use the skill_manage tool after completing complex tasks that required trial and error or multiple tool calls.",
98
98
  "Use 'create' to save a new reusable procedure, 'patch' to update a section of an existing skill by skill_id, and 'update' for a full rewrite.",
99
99
  "Scope is required on create: choose scope='global' for transferable procedures and scope='project' when the workflow depends on this repo's paths, scripts, conventions, or deploy steps.",
100
- "Prefer structured fields for create/update: when_to_use, procedure_steps, pitfalls, and verification_steps. The tool will render valid SKILL.md sections for you.",
100
+ "Prefer structured fields for create/update/patch: when_to_use, procedure_steps, pitfalls, and verification_steps. The tool renders valid SKILL.md sections for you.",
101
+ "For patch, pass section plus the matching structured field (e.g. section='Procedure' with procedure_steps). Avoid free-form content that is a JSON array/object string.",
102
+ "Prefer 'update' for multi-section rewrites when patch content would be large or format-unstable.",
101
103
  "Use 'view' before patching or updating when you need to inspect an existing skill.",
102
104
  "Do NOT use skills for temporary task state — only for durable, reusable procedures.",
103
105
  ],
@@ -206,7 +208,7 @@ export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
206
208
  result = { success: true, ...doc };
207
209
  break;
208
210
 
209
- case "patch":
211
+ case "patch": {
210
212
  if (!skill_id) {
211
213
  return {
212
214
  content: [{ type: "text", text: JSON.stringify({ success: false, error: "skill_id is required for 'patch' action." }) }],
@@ -219,14 +221,60 @@ export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
219
221
  details: {},
220
222
  };
221
223
  }
222
- if (!content) {
224
+
225
+ const sectionKey = section.replace(/^#+\s*/, "").trim().toLowerCase();
226
+ let patchContent = content?.trim() ?? "";
227
+
228
+ // Prefer structured fields matching the target section so the LLM
229
+ // does not have to invent Markdown list formatting.
230
+ if (sectionKey === "procedure" && procedureSteps.length > 0) {
231
+ patchContent = formatOrderedList(procedureSteps);
232
+ } else if (sectionKey === "pitfalls" && pitfallItems.length > 0) {
233
+ patchContent = formatBulletList(pitfallItems, "No notable pitfalls recorded yet.");
234
+ } else if (sectionKey === "verification" && verificationSteps.length > 0) {
235
+ patchContent = formatOrderedList(verificationSteps);
236
+ } else if ((sectionKey === "when to use" || sectionKey === "when_to_use") && whenToUse) {
237
+ patchContent = whenToUse;
238
+ } else if (!patchContent && hasStructuredBody) {
239
+ // Allow a single structured field even if section name is non-standard.
240
+ if (procedureSteps.length > 0 && pitfallItems.length === 0 && verificationSteps.length === 0 && !whenToUse) {
241
+ patchContent = formatOrderedList(procedureSteps);
242
+ } else if (pitfallItems.length > 0 && procedureSteps.length === 0 && verificationSteps.length === 0 && !whenToUse) {
243
+ patchContent = formatBulletList(pitfallItems, "No notable pitfalls recorded yet.");
244
+ } else if (verificationSteps.length > 0 && procedureSteps.length === 0 && pitfallItems.length === 0 && !whenToUse) {
245
+ patchContent = formatOrderedList(verificationSteps);
246
+ } else if (whenToUse && procedureSteps.length === 0 && pitfallItems.length === 0 && verificationSteps.length === 0) {
247
+ patchContent = whenToUse;
248
+ } else {
249
+ return {
250
+ content: [{
251
+ type: "text",
252
+ text: JSON.stringify({
253
+ success: false,
254
+ error: "For patch, provide content or exactly one structured field matching the target section (procedure_steps, pitfalls, verification_steps, or when_to_use). Use update for multi-section rewrites.",
255
+ }),
256
+ }],
257
+ details: {},
258
+ };
259
+ }
260
+ }
261
+
262
+ if (!patchContent) {
223
263
  return {
224
- content: [{ type: "text", text: JSON.stringify({ success: false, error: "content is required for 'patch' action." }) }],
264
+ content: [{
265
+ type: "text",
266
+ text: JSON.stringify({
267
+ success: false,
268
+ error: "content or a matching structured field is required for 'patch' action. Prefer procedure_steps/pitfalls/verification_steps/when_to_use.",
269
+ }),
270
+ }],
225
271
  details: {},
226
272
  };
227
273
  }
228
- result = await store.patch(skill_id, section, content);
274
+
275
+ result = await store.patch(skill_id, section, patchContent);
229
276
  break;
277
+ }
230
278
 
231
279
  case "update":
232
280
  case "edit": {