pi-hermes-memory 0.8.2 → 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.8.2",
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,8 +312,8 @@ 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
316
- - 'project': procedures tied to this repo's paths, scripts, architecture, deploy flow, or conventions
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
+ - '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:
319
319
  - Prefer 'patch' for one section when you can pass structured fields
@@ -4,7 +4,8 @@ import { randomUUID } from "node:crypto";
4
4
  import path from "node:path";
5
5
  import { AtomicLockCoordinator, type AtomicLockLease } from "./store/atomic-lock-coordinator.js";
6
6
  import { canonicalStoragePathSync } from "./store/canonical-storage-path.js";
7
- import { loadBetterSqlite3 } from "./store/sqlite-native.js";
7
+ import { createRequire } from "node:module";
8
+ import { isBunRuntime, loadBetterSqlite3 } from "./store/sqlite-native.js";
8
9
 
9
10
  type MigrationDatabase = {
10
11
  exec: (sql: string) => void;
@@ -22,7 +23,94 @@ type MigrationDatabaseCtor = new (
22
23
  options?: { readonly?: boolean; fileMustExist?: boolean; timeout?: number },
23
24
  ) => MigrationDatabase;
24
25
 
25
- const Database = loadBetterSqlite3() as MigrationDatabaseCtor;
26
+ type BunDatabaseInstance = {
27
+ exec: (sql: string) => void;
28
+ prepare: (sql: string) => { get: (...args: unknown[]) => unknown; run: (...args: unknown[]) => unknown };
29
+ close: () => void;
30
+ };
31
+
32
+ /**
33
+ * bun:sqlite shim covering the slice of the better-sqlite3 API this migration
34
+ * uses. Compiled Pi cannot resolve better-sqlite3 at all, so under Bun this is
35
+ * the only way the legacy-root migration can touch sessions.db.
36
+ */
37
+ function createBunMigrationDatabaseCtor(): MigrationDatabaseCtor {
38
+ const require = createRequire(import.meta.url);
39
+ const bunSqlite = require("bun:sqlite") as {
40
+ Database: new (
41
+ dbPath: string,
42
+ options?: { readonly?: boolean; readwrite?: boolean; create?: boolean },
43
+ ) => BunDatabaseInstance;
44
+ };
45
+
46
+ return class BunMigrationDatabase implements MigrationDatabase {
47
+ private readonly db: BunDatabaseInstance;
48
+
49
+ constructor(
50
+ dbPath: string,
51
+ options: { readonly?: boolean; fileMustExist?: boolean; timeout?: number } = {},
52
+ ) {
53
+ const readonly = options.readonly === true;
54
+ this.db = new bunSqlite.Database(dbPath, {
55
+ readonly,
56
+ readwrite: !readonly,
57
+ create: !readonly && options.fileMustExist !== true,
58
+ });
59
+ if (typeof options.timeout === "number") {
60
+ this.db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.trunc(options.timeout))}`);
61
+ }
62
+ }
63
+
64
+ exec(sql: string): void {
65
+ this.db.exec(sql);
66
+ }
67
+
68
+ prepare(sql: string): { get: (...args: unknown[]) => unknown; run: (...args: unknown[]) => unknown } {
69
+ return this.db.prepare(sql);
70
+ }
71
+
72
+ close(): void {
73
+ this.db.close();
74
+ }
75
+
76
+ pragma(query: string, options?: { simple?: boolean }): unknown {
77
+ if (query.includes("=")) {
78
+ this.db.exec(`PRAGMA ${query}`);
79
+ return undefined;
80
+ }
81
+ const row = this.db.prepare(`PRAGMA ${query}`).get();
82
+ if (typeof row !== "object" || row === null) return options?.simple ? row : [];
83
+ return options?.simple ? Object.values(row)[0] : [row];
84
+ }
85
+
86
+ /**
87
+ * bun:sqlite exposes no online backup API. `VACUUM INTO` writes an
88
+ * equivalent consistent snapshot under a read transaction, but has no
89
+ * incremental callback, so the heartbeat only fires either side of it.
90
+ */
91
+ async backup(destination: string, options?: { progress?: () => void }): Promise<void> {
92
+ options?.progress?.();
93
+ this.db.prepare("VACUUM INTO ?").run(destination);
94
+ options?.progress?.();
95
+ }
96
+ };
97
+ }
98
+
99
+ let cachedDatabaseCtor: MigrationDatabaseCtor | null = null;
100
+
101
+ /**
102
+ * Resolved on first use, never at import time: this module is pulled in by
103
+ * src/index.ts at extension load, and a module-scope native load turns any
104
+ * SQLite resolve/ABI failure into "Failed to load extension" (issue #117).
105
+ */
106
+ function getDatabaseCtor(): MigrationDatabaseCtor {
107
+ if (!cachedDatabaseCtor) {
108
+ cachedDatabaseCtor = isBunRuntime()
109
+ ? createBunMigrationDatabaseCtor()
110
+ : (loadBetterSqlite3() as MigrationDatabaseCtor);
111
+ }
112
+ return cachedDatabaseCtor;
113
+ }
26
114
  const DATABASE_FILES = ["sessions.db", "sessions.db-wal", "sessions.db-shm"] as const;
27
115
  const DATABASE_MIGRATION_PENDING_FILE = ".sessions-db-migration-pending";
28
116
 
@@ -102,6 +190,7 @@ async function stageDatabaseSnapshot(
102
190
  staged: string,
103
191
  onProgress?: () => void,
104
192
  ): Promise<void> {
193
+ const Database = getDatabaseCtor();
105
194
  const sourceDb = new Database(source, { readonly: true, fileMustExist: true });
106
195
  try {
107
196
  await sourceDb.backup(staged, {
@@ -137,7 +226,7 @@ function isDatabaseCorruption(error: unknown): boolean {
137
226
  }
138
227
 
139
228
  async function acquireMigrationLease(legacyRoot: string, targetRoot: string): Promise<AtomicLockLease> {
140
- const coordinator = new AtomicLockCoordinator(path.join(targetRoot, ".pi-hermes-locks.sqlite"));
229
+ const coordinator = AtomicLockCoordinator.shared(path.join(targetRoot, ".pi-hermes-locks.sqlite"));
141
230
  const sourceIdentity = canonicalStoragePathSync(path.join(legacyRoot, "sessions.db"));
142
231
  const targetIdentity = canonicalStoragePathSync(path.join(targetRoot, "sessions.db"));
143
232
  const key = `extension-root-migration:${sourceIdentity}:${targetIdentity}`;
@@ -390,7 +479,7 @@ async function migrateDatabaseGeneration(
390
479
  const staged = path.join(stagingDir, "sessions.db");
391
480
  const sourceState = await fs.lstat(source);
392
481
  try {
393
- writeLock = new Database(source, { fileMustExist: true, timeout: 0 });
482
+ writeLock = new (getDatabaseCtor())(source, { fileMustExist: true, timeout: 0 });
394
483
  writeLock.pragma("busy_timeout = 0");
395
484
  writeLock.exec("BEGIN IMMEDIATE");
396
485
  } catch (error) {
@@ -448,7 +537,15 @@ async function migrateDatabaseGeneration(
448
537
  published.set(target, await fileIdentity(target));
449
538
  }
450
539
 
451
- if (writeLock) writeLock.exec("COMMIT");
540
+ if (writeLock) {
541
+ // Every generation file has already been moved out of legacyRoot, so this
542
+ // transaction can no longer guard anything and its database no longer
543
+ // exists at this path. bun:sqlite reports SQLITE_IOERR here where
544
+ // better-sqlite3 succeeds; either way a failed cleanup COMMIT must not
545
+ // roll back an otherwise completed migration.
546
+ // The connection is closed in `finally` either way.
547
+ try { writeLock.exec("COMMIT"); } catch {}
548
+ }
452
549
  result.moved += generationNames.length;
453
550
  } catch (error) {
454
551
  for (const [target, identity] of [...published.entries()].reverse()) {
@@ -61,7 +61,7 @@ async function tryAcquireConsolidationLock(
61
61
  const storageIdentity = await store.getStorageIdentity(target);
62
62
  const root = consolidationLockRoot();
63
63
  await fs.mkdir(root, { recursive: true });
64
- const coordinator = new AtomicLockCoordinator(path.join(root, "locks.sqlite"));
64
+ const coordinator = AtomicLockCoordinator.shared(path.join(root, "locks.sqlite"));
65
65
  const lease = coordinator.tryAcquire(
66
66
  consolidationLockKey(target, toolTarget, storageIdentity),
67
67
  { staleMs: Math.max(timeoutMs, 0) + CONSOLIDATION_LOCK_STALE_GRACE_MS },
package/src/index.ts CHANGED
@@ -63,9 +63,12 @@ export function resolveProjectSkillDiscovery(
63
63
  const detected = detectProjectSkills(projectsMemoryDir, cwd);
64
64
  skillStore.setProjectContext(detected.name, detected.skillsDir);
65
65
 
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.
66
70
  const skillPaths = [skillStore.getGlobalSkillsDir()];
67
71
  if (detected.skillsDir) skillPaths.push(detected.skillsDir);
68
-
69
72
  return { skillPaths };
70
73
  }
71
74
 
@@ -103,10 +106,10 @@ export default function (pi: ExtensionAPI) {
103
106
  const projectName = project.name ?? "";
104
107
  const skillStore = new SkillStore({
105
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
- legacyPiGlobalSkillsDir: path.join(agentRoot, "skills"),
110
113
  migrationSentinelPath: path.join(globalDir, ".skills-migrated-to-extension-storage"),
111
114
  });
112
115
  const dbManager = new DatabaseManager(globalDir);
package/src/project.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * represents a project and resolves its name.
4
4
  */
5
5
 
6
+ import * as fs from "node:fs";
6
7
  import * as path from "node:path";
7
8
  import * as os from "node:os";
8
9
  import { resolveProjectsRoot } from "./paths.js";
@@ -19,11 +20,80 @@ export interface ProjectSkillInfo extends ProjectInfo {
19
20
  skillsDir: string | null;
20
21
  }
21
22
 
23
+ /**
24
+ * Resolve the repository root shared by every linked worktree of `dir`'s repo.
25
+ *
26
+ * Mirrors what `git rev-parse --git-common-dir` reports, without spawning git:
27
+ * a linked worktree's `.git` is a file pointing at
28
+ * `<main>/.git/worktrees/<name>`, and that directory carries a `commondir`
29
+ * file pointing back at the shared `<main>/.git`. Returns null outside a
30
+ * repository, or for a bare/detached layout with no obvious working root.
31
+ */
32
+ function findGitRepoRoot(dir: string): string | null {
33
+ let current = path.resolve(dir);
34
+
35
+ while (true) {
36
+ const dotGit = path.join(current, ".git");
37
+ let stat: fs.Stats | undefined;
38
+ try {
39
+ stat = fs.statSync(dotGit);
40
+ } catch {
41
+ stat = undefined;
42
+ }
43
+
44
+ if (stat?.isDirectory()) return current;
45
+
46
+ if (stat?.isFile()) {
47
+ const commonDir = resolveWorktreeCommonDir(current, dotGit);
48
+ if (!commonDir) return current;
49
+ return path.basename(commonDir) === ".git" ? path.dirname(commonDir) : commonDir;
50
+ }
51
+
52
+ const parent = path.dirname(current);
53
+ if (parent === current) return null;
54
+ current = parent;
55
+ }
56
+ }
57
+
58
+ function resolveWorktreeCommonDir(worktreeRoot: string, dotGitFile: string): string | null {
59
+ let pointer: string;
60
+ try {
61
+ pointer = fs.readFileSync(dotGitFile, "utf-8");
62
+ } catch {
63
+ return null;
64
+ }
65
+
66
+ const match = /^gitdir:\s*(.+)$/m.exec(pointer);
67
+ if (!match) return null;
68
+
69
+ const gitDir = path.resolve(worktreeRoot, match[1].trim());
70
+ try {
71
+ const commonDir = fs.readFileSync(path.join(gitDir, "commondir"), "utf-8").trim();
72
+ if (commonDir) return path.resolve(gitDir, commonDir);
73
+ } catch {
74
+ // Not a linked worktree, or an older layout without `commondir`.
75
+ }
76
+
77
+ // `<main>/.git/worktrees/<name>` — two levels up is the shared git dir.
78
+ const parent = path.dirname(gitDir);
79
+ return path.basename(parent) === "worktrees" ? path.dirname(parent) : null;
80
+ }
81
+
82
+ const repoRootCache = new Map<string, string | null>();
83
+
22
84
  /**
23
85
  * Detect project from the current working directory.
24
86
  *
25
- * A "project" is any directory that is not the user's home directory.
26
- * The project name is the directory's basename.
87
+ * A "project" is any directory that is not the user's home directory. Inside a
88
+ * Git repository the project name is the *repository* root's basename, so every
89
+ * linked worktree shares one identity instead of stranding its memory and
90
+ * skills under the worktree directory name (#120). Outside Git it stays the
91
+ * working directory's basename.
92
+ *
93
+ * An existing `projects-memory/<cwd-basename>/` directory still wins over a
94
+ * newly derived repository name, so upgrading never orphans memory that was
95
+ * written under the old cwd-basename identity.
96
+ *
27
97
  * Project-scoped memory is stored at ~/.pi/agent/<projectsMemoryDir>/<projectName>/.
28
98
  */
29
99
  export function detectProject(projectsMemoryDir = "projects-memory", cwd?: string): ProjectInfo {
@@ -38,17 +108,46 @@ export function detectProject(projectsMemoryDir = "projects-memory", cwd?: strin
38
108
  return { name: null, memoryDir: null };
39
109
  }
40
110
 
41
- const name = path.basename(resolved);
42
- if (!name || name === "." || name === "..") {
111
+ const cwdName = path.basename(resolved);
112
+ if (!cwdName || cwdName === "." || cwdName === "..") {
43
113
  return { name: null, memoryDir: null };
44
114
  }
45
115
 
116
+ const projectsRoot = resolveProjectsRoot(projectsMemoryDir);
117
+ const name = resolveProjectName(resolved, resolvedHome, cwdName, projectsRoot);
118
+
46
119
  return {
47
120
  name,
48
- memoryDir: path.join(resolveProjectsRoot(projectsMemoryDir), name),
121
+ memoryDir: path.join(projectsRoot, name),
49
122
  };
50
123
  }
51
124
 
125
+ function resolveProjectName(
126
+ resolved: string,
127
+ resolvedHome: string,
128
+ cwdName: string,
129
+ projectsRoot: string,
130
+ ): string {
131
+ let repoRoot = repoRootCache.get(resolved);
132
+ if (repoRoot === undefined) {
133
+ repoRoot = findGitRepoRoot(resolved);
134
+ repoRootCache.set(resolved, repoRoot);
135
+ }
136
+
137
+ if (!repoRoot || repoRoot === resolved || repoRoot === resolvedHome) return cwdName;
138
+
139
+ const repoName = path.basename(repoRoot);
140
+ if (!repoName || repoName === cwdName) return cwdName;
141
+
142
+ // Migration bridge: a store already written under the old cwd-basename
143
+ // identity keeps working. Only fresh directories adopt the repository name.
144
+ if (!fs.existsSync(path.join(projectsRoot, repoName)) && fs.existsSync(path.join(projectsRoot, cwdName))) {
145
+ return cwdName;
146
+ }
147
+
148
+ return repoName;
149
+ }
150
+
52
151
  export function detectProjectSkills(projectsMemoryDir = "projects-memory", cwd?: string): ProjectSkillInfo {
53
152
  const project = detectProject(projectsMemoryDir, cwd);
54
153
  return {
@@ -3,7 +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
+ import { isBunRuntime, loadBetterSqlite3 } from './sqlite-native.js';
7
7
 
8
8
  type StatementLike = {
9
9
  run: (...args: unknown[]) => unknown;
@@ -34,17 +34,25 @@ export interface AtomicLockCoordinatorOptions {
34
34
  probeIncarnation?: (pid: number) => string | null;
35
35
  }
36
36
 
37
- function loadDatabaseCtor(): DatabaseCtor {
38
- const require = createRequire(import.meta.url);
39
- if (typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined') {
40
- const bunSqlite = require('bun:sqlite') as { Database: DatabaseCtor };
41
- return bunSqlite.Database;
37
+ let cachedDatabaseCtor: DatabaseCtor | null = null;
38
+
39
+ /**
40
+ * Resolved on first use, never at import time — see the same note in db.ts.
41
+ * Compiled Pi cannot resolve better-sqlite3 at all, so Bun must take bun:sqlite.
42
+ */
43
+ function getDatabaseCtor(): DatabaseCtor {
44
+ if (!cachedDatabaseCtor) {
45
+ const require = createRequire(import.meta.url);
46
+ if (isBunRuntime()) {
47
+ const bunSqlite = require('bun:sqlite') as { Database: DatabaseCtor };
48
+ cachedDatabaseCtor = bunSqlite.Database;
49
+ } else {
50
+ cachedDatabaseCtor = loadBetterSqlite3({ requireImpl: require }) as DatabaseCtor;
51
+ }
42
52
  }
43
- return loadBetterSqlite3({ requireImpl: require }) as DatabaseCtor;
53
+ return cachedDatabaseCtor;
44
54
  }
45
55
 
46
- const Database = loadDatabaseCtor();
47
-
48
56
  function processIsAlive(pid: number): boolean {
49
57
  try {
50
58
  process.kill(pid, 0);
@@ -86,11 +94,13 @@ function probeProcessIncarnation(pid: number): string | null {
86
94
  const currentProcessIncarnation = probeProcessIncarnation(process.pid);
87
95
  const RELEASE_ATTEMPTS = 3;
88
96
  const pendingReleases = new Map<string, () => void>();
97
+ const sharedCoordinators = new Map<string, AtomicLockCoordinator>();
89
98
 
90
99
  export class AtomicLockCoordinator {
91
100
  private readonly pid: number;
92
101
  private readonly incarnation: string | null;
93
102
  private readonly probeIncarnation: (pid: number) => string | null;
103
+ private cachedDb: DatabaseLike | null = null;
94
104
 
95
105
  constructor(private readonly dbPath: string, options: AtomicLockCoordinatorOptions = {}) {
96
106
  this.pid = options.pid ?? process.pid;
@@ -108,10 +118,9 @@ export class AtomicLockCoordinator {
108
118
  const db = this.open();
109
119
  let acquired = false;
110
120
 
121
+ db.exec('BEGIN IMMEDIATE');
111
122
  try {
112
- db.exec('BEGIN IMMEDIATE');
113
- try {
114
- const owner = db.prepare(`
123
+ const owner = db.prepare(`
115
124
  SELECT token, pid, incarnation, acquired_at
116
125
  FROM locks
117
126
  WHERE lock_key = ?
@@ -149,12 +158,16 @@ export class AtomicLockCoordinator {
149
158
  }
150
159
 
151
160
  db.exec('COMMIT');
152
- } catch (error) {
153
- try { db.exec('ROLLBACK'); } catch {}
154
- throw error;
161
+ } catch (error) {
162
+ try {
163
+ db.exec('ROLLBACK');
164
+ } catch {
165
+ // The transaction is still open on a connection we are about to hand
166
+ // to the next caller, whose BEGIN IMMEDIATE would then fail forever.
167
+ // Drop the handle so open() rebuilds it.
168
+ this.discardCachedDb();
155
169
  }
156
- } finally {
157
- db.close();
170
+ throw error;
158
171
  }
159
172
 
160
173
  if (!acquired) return null;
@@ -176,12 +189,8 @@ export class AtomicLockCoordinator {
176
189
  */
177
190
  isCurrentOwner(key: string, token: string): boolean {
178
191
  const db = this.open();
179
- try {
180
- const row = db.prepare('SELECT token FROM locks WHERE lock_key = ?').get(key) as { token: string } | undefined;
181
- return row?.token === token;
182
- } finally {
183
- db.close();
184
- }
192
+ const row = db.prepare('SELECT token FROM locks WHERE lock_key = ?').get(key) as { token: string } | undefined;
193
+ return row?.token === token;
185
194
  }
186
195
 
187
196
  release(key: string, token: string): void {
@@ -199,11 +208,7 @@ export class AtomicLockCoordinator {
199
208
 
200
209
  private deleteOwnedLock(key: string, token: string): void {
201
210
  const db = this.open();
202
- try {
203
- db.prepare('DELETE FROM locks WHERE lock_key = ? AND token = ?').run(key, token);
204
- } finally {
205
- db.close();
206
- }
211
+ db.prepare('DELETE FROM locks WHERE lock_key = ? AND token = ?').run(key, token);
207
212
  }
208
213
 
209
214
  private retryPendingReleases(key: string): void {
@@ -217,10 +222,19 @@ export class AtomicLockCoordinator {
217
222
  return `${path.resolve(this.dbPath)}\0${key}\0${token}`;
218
223
  }
219
224
 
225
+ private discardCachedDb(): void {
226
+ const db = this.cachedDb;
227
+ this.cachedDb = null;
228
+ if (db) {
229
+ try { db.close(); } catch {}
230
+ }
231
+ }
232
+
220
233
  private open(): DatabaseLike {
234
+ if (this.cachedDb) return this.cachedDb;
221
235
  fs.mkdirSync(path.dirname(this.dbPath), { recursive: true });
222
236
  const existed = fs.existsSync(this.dbPath);
223
- const db = new Database(this.dbPath);
237
+ const db = new (getDatabaseCtor())(this.dbPath);
224
238
  try {
225
239
  db.exec(`
226
240
  PRAGMA busy_timeout = 5000;
@@ -243,10 +257,30 @@ export class AtomicLockCoordinator {
243
257
  }
244
258
  }
245
259
  if (!existed) fs.chmodSync(this.dbPath, 0o600);
260
+ this.cachedDb = db;
246
261
  return db;
247
262
  } catch (error) {
248
263
  db.close();
249
264
  throw error;
250
265
  }
251
266
  }
267
+
268
+ /**
269
+ * Process-wide coordinator for `dbPath`.
270
+ *
271
+ * Each instance now pins its SQLite connection for its own lifetime, so a
272
+ * caller that constructs a fresh coordinator per operation would leak one
273
+ * open WAL connection per call. Every default-options caller must share.
274
+ * The option-carrying constructor stays public for tests, which pass a
275
+ * synthetic pid/incarnation that a dbPath-keyed cache would silently ignore.
276
+ */
277
+ static shared(dbPath: string): AtomicLockCoordinator {
278
+ const key = path.resolve(dbPath);
279
+ let coordinator = sharedCoordinators.get(key);
280
+ if (!coordinator) {
281
+ coordinator = new AtomicLockCoordinator(dbPath);
282
+ sharedCoordinators.set(key, coordinator);
283
+ }
284
+ return coordinator;
285
+ }
252
286
  }
package/src/store/db.ts CHANGED
@@ -4,7 +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
+ import { isBunRuntime, loadBetterSqlite3 } from './sqlite-native.js';
8
8
 
9
9
  type StatementLike = {
10
10
  run: (...args: any[]) => any;
@@ -120,19 +120,23 @@ function createBunCompatDatabaseCtor(require: NodeRequire): DatabaseCtor {
120
120
  };
121
121
  }
122
122
 
123
- function loadDatabaseCtor(): DatabaseCtor {
124
- const require = createRequire(import.meta.url);
125
- const isBunRuntime = typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined';
126
-
127
- if (isBunRuntime) {
128
- return createBunCompatDatabaseCtor(require);
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);
129
136
  }
130
-
131
- return loadBetterSqlite3({ requireImpl: require }) as DatabaseCtor;
137
+ return cachedDatabaseCtor;
132
138
  }
133
139
 
134
- const Database = loadDatabaseCtor();
135
-
136
140
  export class DatabaseManager {
137
141
  private db: DatabaseLike | null = null;
138
142
  private readonly displayDbPath: string;
@@ -270,7 +274,7 @@ export class DatabaseManager {
270
274
 
271
275
  private openUnchecked(): DatabaseLike {
272
276
  const existed = this.hasExistingMainDatabaseFile();
273
- const db = new Database(this.dbPath);
277
+ const db = new (getDatabaseCtor())(this.dbPath);
274
278
  let ok = false;
275
279
 
276
280
  try {
@@ -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 });
@@ -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);
@@ -23,11 +23,13 @@ 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
- legacyPiGlobalSkillsDir?: string;
31
33
  migrationSentinelPath?: string;
32
34
  }
33
35
 
@@ -153,19 +155,19 @@ export function normalizeSkillPatchContent(
153
155
 
154
156
  export class SkillStore {
155
157
  private globalSkillsDir: string;
158
+ private piGlobalSkillsDir: string;
156
159
  private projectSkillsDir: string | null;
157
160
  private projectName: string | null;
158
161
  private legacySkillsDir: string;
159
- private legacyPiGlobalSkillsDir: string;
160
162
  private migrationSentinelPath: string;
161
163
 
162
164
  constructor(options: SkillStoreOptions = {}) {
163
165
  const agentRoot = AGENT_ROOT;
164
- 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");
165
168
  this.projectSkillsDir = options.projectSkillsDir ?? null;
166
169
  this.projectName = options.projectName ?? null;
167
170
  this.legacySkillsDir = options.legacySkillsDir ?? path.join(agentRoot, "memory", "skills");
168
- this.legacyPiGlobalSkillsDir = options.legacyPiGlobalSkillsDir ?? path.join(agentRoot, "skills");
169
171
  this.migrationSentinelPath = options.migrationSentinelPath
170
172
  ?? path.join(agentRoot, "pi-hermes-memory", ".skills-migrated-to-extension-storage");
171
173
  }
@@ -174,6 +176,15 @@ export class SkillStore {
174
176
  return this.globalSkillsDir;
175
177
  }
176
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
+
177
188
  getProjectSkillsDir(): string | null {
178
189
  return this.projectSkillsDir;
179
190
  }
@@ -205,11 +216,13 @@ export class SkillStore {
205
216
 
206
217
  await fs.mkdir(path.dirname(this.migrationSentinelPath), { recursive: true });
207
218
 
219
+ // Only this migration's own warnings may hold back its sentinel — a
220
+ // permanently shadowed skill reported above must not make it retry forever.
221
+ const warningsBefore = result.warnings.length;
208
222
  try {
209
223
  await this.migrateLegacyMarkdownSkills(result);
210
- await this.migrateLegacyPiGlobalSkillDirs(result);
211
224
  } finally {
212
- if (result.warnings.length === 0) {
225
+ if (result.warnings.length === warningsBefore) {
213
226
  await fs.writeFile(this.migrationSentinelPath, `${new Date().toISOString()}\n`, "utf-8");
214
227
  }
215
228
  }
@@ -307,45 +320,23 @@ export class SkillStore {
307
320
  }
308
321
  }
309
322
 
310
- private async migrateLegacyPiGlobalSkillDirs(result: LegacySkillMigrationResult): Promise<void> {
311
- if (path.resolve(this.legacyPiGlobalSkillsDir) === path.resolve(this.globalSkillsDir)) return;
312
- if (!await exists(this.legacyPiGlobalSkillsDir)) return;
313
-
314
- const entries = await fs.readdir(this.legacyPiGlobalSkillsDir, { withFileTypes: true });
315
- for (const entry of entries) {
316
- if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
317
-
318
- const sourceDir = path.join(this.legacyPiGlobalSkillsDir, entry.name);
319
- const sourceSkill = path.join(sourceDir, "SKILL.md");
320
- if (!await exists(sourceSkill)) continue;
321
-
322
- const targetDir = path.join(this.globalSkillsDir, entry.name);
323
- const targetSkill = path.join(targetDir, "SKILL.md");
324
- if (await exists(targetSkill)) {
325
- result.skipped++;
326
- continue;
327
- }
328
-
329
- try {
330
- const raw = await fs.readFile(sourceSkill, "utf-8");
331
- const parsed = parseFrontmatter(raw);
332
- const hasExtensionManagedMeta = Boolean(parsed.meta.display_name)
333
- && Boolean(parsed.meta.created)
334
- && Boolean(parsed.meta.updated)
335
- && /^\d+$/.test(parsed.meta.version ?? "");
336
-
337
- if (!hasExtensionManagedMeta) {
338
- result.skipped++;
339
- continue;
340
- }
341
-
342
- await fs.mkdir(path.dirname(targetDir), { recursive: true });
343
- await fs.rename(sourceDir, targetDir);
344
- result.migrated++;
345
- } catch (error) {
346
- result.warnings.push(`${entry.name}: ${error instanceof Error ? error.message : String(error)}`);
347
- }
348
- }
323
+ /**
324
+ * Is `slug` already claimed by a skill in Pi's own global root?
325
+ *
326
+ * Pi keys skills by name, first-loaded wins, and `~/.pi/agent/skills/` is
327
+ * auto-discovered at higher precedence than anything an extension contributes
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).
331
+ *
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.
335
+ */
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;
349
340
  }
350
341
 
351
342
  async loadIndex(scope?: SkillScope): Promise<SkillIndex[]> {
@@ -428,6 +419,19 @@ export class SkillStore {
428
419
  suggestedAction: "rename",
429
420
  };
430
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
+ }
431
435
  }
432
436
 
433
437
  const filePath = path.join(root, slug, "SKILL.md");
@@ -1,5 +1,11 @@
1
1
  import { DatabaseManager } from './db.js';
2
- import { buildFallbackFts5Query, isFts5QueryError, normalizeFts5Query } from './fts-query.js';
2
+ import {
3
+ buildFallbackFts5Query,
4
+ buildNaturalLanguageFallbackQuery,
5
+ isFts5QueryError,
6
+ normalizeFts5Query,
7
+ normalizeNaturalLanguageFts5Query,
8
+ } from './fts-query.js';
3
9
  import { normalizeMemoryLookupText } from './memory-lookup.js';
4
10
  import type { MemoryCategory } from '../types.js';
5
11
 
@@ -697,6 +703,8 @@ export function searchMemories(
697
703
  return [];
698
704
  }
699
705
 
706
+ let ftsParseError = false;
707
+
700
708
  const runSearch = (matchQuery: string): SqliteMemoryEntry[] => {
701
709
  const conditions: string[] = [];
702
710
  const params: unknown[] = [];
@@ -750,6 +758,7 @@ export function searchMemories(
750
758
  return rows.map(mapRow);
751
759
  } catch (err) {
752
760
  if (isFts5QueryError(err)) {
761
+ ftsParseError = true;
753
762
  return [];
754
763
  }
755
764
  throw err;
@@ -761,6 +770,26 @@ export function searchMemories(
761
770
  return exactResults;
762
771
  }
763
772
 
773
+ // A query with uppercase operator words (e.g. "DO NOT USE FIND /") passes
774
+ // through as raw FTS5 syntax; when that fails to parse, retry it as natural
775
+ // language instead of silently returning nothing. Valid operator queries
776
+ // that legitimately match nothing keep their exact semantics.
777
+ if (ftsParseError) {
778
+ const nlQuery = normalizeNaturalLanguageFts5Query(query);
779
+ if (nlQuery.length === 0 || nlQuery === normalizedQuery) {
780
+ return [];
781
+ }
782
+ const nlResults = runSearch(nlQuery);
783
+ if (nlResults.length > 0) {
784
+ return nlResults;
785
+ }
786
+ const nlFallback = buildNaturalLanguageFallbackQuery(query);
787
+ if (nlFallback && nlFallback !== nlQuery) {
788
+ return runSearch(nlFallback);
789
+ }
790
+ return nlResults;
791
+ }
792
+
764
793
  const fallbackQuery = buildFallbackFts5Query(query);
765
794
  if (!fallbackQuery || fallbackQuery === normalizedQuery) {
766
795
  return exactResults;
@@ -44,6 +44,18 @@ export class BetterSqlite3LoadError extends Error {
44
44
  const ABI_MISMATCH_RE =
45
45
  /NODE_MODULE_VERSION|was compiled against a different Node\.js version|ERR_DLOPEN_FAILED/i;
46
46
 
47
+ /**
48
+ * True when running under Bun (including the compiled Pi binary).
49
+ *
50
+ * Compiled Pi cannot resolve `better-sqlite3` — neither the bare specifier nor
51
+ * the package's own transitive `require("bindings")` resolve from an on-disk
52
+ * extension file, so every code path that needs SQLite must route through
53
+ * `bun:sqlite` instead of loading the native module.
54
+ */
55
+ export function isBunRuntime(): boolean {
56
+ return "Bun" in globalThis;
57
+ }
58
+
47
59
  export function isNativeModuleAbiMismatch(error: unknown): boolean {
48
60
  if (!error) return false;
49
61
  const message = error instanceof Error ? error.message : String(error);