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 +17 -0
- package/package.json +1 -1
- package/src/constants.ts +10 -9
- package/src/extension-root-migration.ts +118 -4
- package/src/handlers/auto-consolidate.ts +1 -1
- package/src/index.ts +7 -6
- package/src/project.ts +104 -5
- package/src/store/atomic-lock-coordinator.ts +63 -29
- package/src/store/db.ts +23 -18
- package/src/store/fts-query.ts +31 -0
- package/src/store/markdown-mutation-lock.ts +1 -1
- package/src/store/memory-store.ts +71 -23
- package/src/store/session-search.ts +33 -2
- package/src/store/skill-store.ts +177 -35
- package/src/store/sqlite-memory-store.ts +30 -1
- package/src/store/sqlite-native.ts +241 -0
- package/src/tools/skill-tool.ts +59 -11
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.
|
|
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
|
|
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
|
|
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
|
|
319
|
-
-
|
|
320
|
-
-
|
|
321
|
-
-
|
|
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
|
|
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 =
|
|
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
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
67
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
42
|
-
if (!
|
|
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(
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|