pi-hermes-memory 0.8.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hermes-memory",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
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's own global skills root, ~/.pi/agent/skills/<slug>/SKILL.md — the same directory Pi loads user skills from, so a name used there is already taken.
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,10 +63,10 @@ export function resolveProjectSkillDiscovery(
63
63
  const detected = detectProjectSkills(projectsMemoryDir, cwd);
64
64
  skillStore.setProjectContext(detected.name, detected.skillsDir);
65
65
 
66
- const skillPaths = [skillStore.getGlobalSkillsDir()];
67
- if (detected.skillsDir) skillPaths.push(detected.skillsDir);
68
-
69
- return { skillPaths };
66
+ // Global skills live in Pi's own `~/.pi/agent/skills/`, which Pi already
67
+ // auto-discovers at higher precedence than anything contributed here. Only
68
+ // the project skills directory needs registering (#125, #126).
69
+ return { skillPaths: detected.skillsDir ? [detected.skillsDir] : [] };
70
70
  }
71
71
 
72
72
  export function registerProjectSkillDiscoveryHandler(
@@ -102,12 +102,13 @@ export default function (pi: ExtensionAPI) {
102
102
  const project = detectProject(config.projectsMemoryDir);
103
103
  const projectName = project.name ?? "";
104
104
  const skillStore = new SkillStore({
105
- globalSkillsDir: path.join(globalDir, "skills"),
105
+ globalSkillsDir: path.join(agentRoot, "skills"),
106
106
  projectSkillsDir: project.memoryDir ? path.join(project.memoryDir, "skills") : null,
107
107
  projectName: project.name,
108
108
  legacySkillsDir: path.join(legacyGlobalDir, "skills"),
109
- legacyPiGlobalSkillsDir: path.join(agentRoot, "skills"),
109
+ legacyExtensionSkillsDir: path.join(globalDir, "skills"),
110
110
  migrationSentinelPath: path.join(globalDir, ".skills-migrated-to-extension-storage"),
111
+ extensionSkillsMigrationSentinelPath: path.join(globalDir, ".skills-migrated-to-pi-global"),
111
112
  });
112
113
  const dbManager = new DatabaseManager(globalDir);
113
114
  let databaseMigrationPending = shouldMigrateExtensionRoot
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);
@@ -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 {
@@ -156,8 +157,9 @@ export class SkillStore {
156
157
  private projectSkillsDir: string | null;
157
158
  private projectName: string | null;
158
159
  private legacySkillsDir: string;
159
- private legacyPiGlobalSkillsDir: string;
160
+ private legacyExtensionSkillsDir: string;
160
161
  private migrationSentinelPath: string;
162
+ private extensionSkillsMigrationSentinelPath: string;
161
163
 
162
164
  constructor(options: SkillStoreOptions = {}) {
163
165
  const agentRoot = AGENT_ROOT;
@@ -165,9 +167,12 @@ export class SkillStore {
165
167
  this.projectSkillsDir = options.projectSkillsDir ?? null;
166
168
  this.projectName = options.projectName ?? null;
167
169
  this.legacySkillsDir = options.legacySkillsDir ?? path.join(agentRoot, "memory", "skills");
168
- this.legacyPiGlobalSkillsDir = options.legacyPiGlobalSkillsDir ?? path.join(agentRoot, "skills");
170
+ this.legacyExtensionSkillsDir = options.legacyExtensionSkillsDir
171
+ ?? path.join(agentRoot, "pi-hermes-memory", "skills");
169
172
  this.migrationSentinelPath = options.migrationSentinelPath
170
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");
171
176
  }
172
177
 
173
178
  getGlobalSkillsDir(): string {
@@ -200,16 +205,19 @@ export class SkillStore {
200
205
  // Always normalize flat markdown files under the global skills root,
201
206
  // even when a previous migration sentinel already exists.
202
207
  await this.migrateFlatMarkdownInGlobalSkillsDir(result);
208
+ await this.migrateExtensionSkillsToGlobalRoot(result);
203
209
 
204
210
  if (await exists(this.migrationSentinelPath)) return result;
205
211
 
206
212
  await fs.mkdir(path.dirname(this.migrationSentinelPath), { recursive: true });
207
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;
208
217
  try {
209
218
  await this.migrateLegacyMarkdownSkills(result);
210
- await this.migrateLegacyPiGlobalSkillDirs(result);
211
219
  } finally {
212
- if (result.warnings.length === 0) {
220
+ if (result.warnings.length === warningsBefore) {
213
221
  await fs.writeFile(this.migrationSentinelPath, `${new Date().toISOString()}\n`, "utf-8");
214
222
  }
215
223
  }
@@ -307,45 +315,61 @@ export class SkillStore {
307
315
  }
308
316
  }
309
317
 
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;
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;
313
338
 
314
- const entries = await fs.readdir(this.legacyPiGlobalSkillsDir, { withFileTypes: true });
315
339
  for (const entry of entries) {
316
340
  if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
317
341
 
318
- const sourceDir = path.join(this.legacyPiGlobalSkillsDir, entry.name);
319
- const sourceSkill = path.join(sourceDir, "SKILL.md");
320
- if (!await exists(sourceSkill)) continue;
342
+ const sourceDir = path.join(this.legacyExtensionSkillsDir, entry.name);
343
+ if (!await exists(path.join(sourceDir, "SKILL.md"))) continue;
321
344
 
322
345
  const targetDir = path.join(this.globalSkillsDir, entry.name);
323
- const targetSkill = path.join(targetDir, "SKILL.md");
324
- 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++;
325
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
+ );
326
355
  continue;
327
356
  }
328
357
 
329
358
  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
359
  await fs.mkdir(path.dirname(targetDir), { recursive: true });
343
360
  await fs.rename(sourceDir, targetDir);
344
361
  result.migrated++;
345
362
  } catch (error) {
363
+ shadowed++;
346
364
  result.warnings.push(`${entry.name}: ${error instanceof Error ? error.message : String(error)}`);
347
365
  }
348
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
+ }
349
373
  }
350
374
 
351
375
  async loadIndex(scope?: SkillScope): Promise<SkillIndex[]> {
@@ -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);