pi-hermes-memory 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -122,6 +122,23 @@ Or test locally without installing:
122
122
  pi -e /path/to/pi-hermes-memory/src/index.ts
123
123
  ```
124
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
+
125
142
  ## Two-Tier Memory Architecture
126
143
 
127
144
  The extension stores memory at two levels:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hermes-memory",
3
- "version": "0.8.1",
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
@@ -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
 
@@ -312,23 +312,24 @@ 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
- 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,115 @@ 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";
7
+ import { createRequire } from "node:module";
8
+ import { isBunRuntime, loadBetterSqlite3 } from "./store/sqlite-native.js";
9
+
10
+ type MigrationDatabase = {
11
+ exec: (sql: string) => void;
12
+ prepare: (sql: string) => { get: (...args: unknown[]) => unknown; run: (...args: unknown[]) => unknown };
13
+ close: () => void;
14
+ pragma: (query: string, options?: { simple?: boolean }) => unknown;
15
+ backup: (
16
+ destination: string,
17
+ options?: { progress?: () => void },
18
+ ) => Promise<void> | void;
19
+ };
20
+
21
+ type MigrationDatabaseCtor = new (
22
+ dbPath: string,
23
+ options?: { readonly?: boolean; fileMustExist?: boolean; timeout?: number },
24
+ ) => MigrationDatabase;
25
+
26
+ type BunDatabaseInstance = {
27
+ exec: (sql: string) => void;
28
+ prepare: (sql: string) => { get: (...args: unknown[]) => unknown; run: (...args: unknown[]) => unknown };
29
+ close: () => void;
30
+ };
8
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
+ }
9
114
  const DATABASE_FILES = ["sessions.db", "sessions.db-wal", "sessions.db-shm"] as const;
10
115
  const DATABASE_MIGRATION_PENDING_FILE = ".sessions-db-migration-pending";
11
116
 
@@ -85,6 +190,7 @@ async function stageDatabaseSnapshot(
85
190
  staged: string,
86
191
  onProgress?: () => void,
87
192
  ): Promise<void> {
193
+ const Database = getDatabaseCtor();
88
194
  const sourceDb = new Database(source, { readonly: true, fileMustExist: true });
89
195
  try {
90
196
  await sourceDb.backup(staged, {
@@ -120,7 +226,7 @@ function isDatabaseCorruption(error: unknown): boolean {
120
226
  }
121
227
 
122
228
  async function acquireMigrationLease(legacyRoot: string, targetRoot: string): Promise<AtomicLockLease> {
123
- const coordinator = new AtomicLockCoordinator(path.join(targetRoot, ".pi-hermes-locks.sqlite"));
229
+ const coordinator = AtomicLockCoordinator.shared(path.join(targetRoot, ".pi-hermes-locks.sqlite"));
124
230
  const sourceIdentity = canonicalStoragePathSync(path.join(legacyRoot, "sessions.db"));
125
231
  const targetIdentity = canonicalStoragePathSync(path.join(targetRoot, "sessions.db"));
126
232
  const key = `extension-root-migration:${sourceIdentity}:${targetIdentity}`;
@@ -373,7 +479,7 @@ async function migrateDatabaseGeneration(
373
479
  const staged = path.join(stagingDir, "sessions.db");
374
480
  const sourceState = await fs.lstat(source);
375
481
  try {
376
- writeLock = new Database(source, { fileMustExist: true, timeout: 0 });
482
+ writeLock = new (getDatabaseCtor())(source, { fileMustExist: true, timeout: 0 });
377
483
  writeLock.pragma("busy_timeout = 0");
378
484
  writeLock.exec("BEGIN IMMEDIATE");
379
485
  } catch (error) {
@@ -431,7 +537,15 @@ async function migrateDatabaseGeneration(
431
537
  published.set(target, await fileIdentity(target));
432
538
  }
433
539
 
434
- 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
+ }
435
549
  result.moved += generationNames.length;
436
550
  } catch (error) {
437
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,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 { isBunRuntime, loadBetterSqlite3 } from './sqlite-native.js';
6
7
 
7
8
  type StatementLike = {
8
9
  run: (...args: unknown[]) => unknown;
@@ -33,18 +34,25 @@ export interface AtomicLockCoordinatorOptions {
33
34
  probeIncarnation?: (pid: number) => string | null;
34
35
  }
35
36
 
36
- function loadDatabaseCtor(): DatabaseCtor {
37
- const require = createRequire(import.meta.url);
38
- if (typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined') {
39
- const bunSqlite = require('bun:sqlite') as { Database: DatabaseCtor };
40
- 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
+ }
41
52
  }
42
- const mod = require('better-sqlite3') as { default?: DatabaseCtor } | DatabaseCtor;
43
- return (mod as { default?: DatabaseCtor }).default ?? (mod 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
  }