pi-hermes-memory 0.8.2 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/constants.ts +2 -2
- package/src/extension-root-migration.ts +102 -5
- package/src/handlers/auto-consolidate.ts +1 -1
- package/src/index.ts +5 -2
- package/src/project.ts +104 -5
- package/src/store/atomic-lock-coordinator.ts +63 -29
- package/src/store/db.ts +19 -14
- package/src/store/fts-query.ts +31 -0
- package/src/store/markdown-mutation-lock.ts +1 -1
- package/src/store/session-search.ts +33 -2
- package/src/store/skill-store.ts +49 -45
- package/src/store/sqlite-memory-store.ts +30 -1
- package/src/store/sqlite-native.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hermes-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "🧠 Persistent memory + 🔍 session search + 🛡️ secret scanning for Pi. Token-aware policy-only memory by default, SQLite FTS5 search, auto-consolidation, procedural skills. 693 tests. Ported from Hermes agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/constants.ts
CHANGED
|
@@ -312,8 +312,8 @@ WHEN TO CREATE A SKILL:
|
|
|
312
312
|
- When the user teaches you a specific workflow or procedure
|
|
313
313
|
|
|
314
314
|
SCOPE:
|
|
315
|
-
- 'global': transferable procedures that can be reused across repositories
|
|
316
|
-
- 'project': procedures tied to this repo's paths, scripts, architecture, deploy flow, or conventions
|
|
315
|
+
- 'global': transferable procedures that can be reused across repositories. Written to ~/.pi/agent/pi-hermes-memory/skills/<slug>/SKILL.md, this extension's own directory, kept separate from skills the user installed themselves. Pi also loads its own ~/.pi/agent/skills/ first, so a name already used there is rejected rather than silently shadowed.
|
|
316
|
+
- 'project': procedures tied to this repo's paths, scripts, architecture, deploy flow, or conventions. Written to ~/.pi/agent/projects-memory/<project>/skills/<slug>/SKILL.md.
|
|
317
317
|
|
|
318
318
|
WHEN TO UPDATE A SKILL:
|
|
319
319
|
- Prefer 'patch' for one section when you can pass structured fields
|
|
@@ -4,7 +4,8 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { AtomicLockCoordinator, type AtomicLockLease } from "./store/atomic-lock-coordinator.js";
|
|
6
6
|
import { canonicalStoragePathSync } from "./store/canonical-storage-path.js";
|
|
7
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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)
|
|
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 =
|
|
64
|
+
const coordinator = AtomicLockCoordinator.shared(path.join(root, "locks.sqlite"));
|
|
65
65
|
const lease = coordinator.tryAcquire(
|
|
66
66
|
consolidationLockKey(target, toolTarget, storageIdentity),
|
|
67
67
|
{ staleMs: Math.max(timeoutMs, 0) + CONSOLIDATION_LOCK_STALE_GRACE_MS },
|
package/src/index.ts
CHANGED
|
@@ -63,9 +63,12 @@ export function resolveProjectSkillDiscovery(
|
|
|
63
63
|
const detected = detectProjectSkills(projectsMemoryDir, cwd);
|
|
64
64
|
skillStore.setProjectContext(detected.name, detected.skillsDir);
|
|
65
65
|
|
|
66
|
+
// Pi auto-discovers its own `~/.pi/agent/skills/`, but this extension keeps
|
|
67
|
+
// its generated skills in a directory of its own so users can audit, wipe, or
|
|
68
|
+
// ignore them without touching skills they installed themselves (#126). Both
|
|
69
|
+
// of ours must therefore be contributed here.
|
|
66
70
|
const skillPaths = [skillStore.getGlobalSkillsDir()];
|
|
67
71
|
if (detected.skillsDir) skillPaths.push(detected.skillsDir);
|
|
68
|
-
|
|
69
72
|
return { skillPaths };
|
|
70
73
|
}
|
|
71
74
|
|
|
@@ -103,10 +106,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
103
106
|
const projectName = project.name ?? "";
|
|
104
107
|
const skillStore = new SkillStore({
|
|
105
108
|
globalSkillsDir: path.join(globalDir, "skills"),
|
|
109
|
+
piGlobalSkillsDir: path.join(agentRoot, "skills"),
|
|
106
110
|
projectSkillsDir: project.memoryDir ? path.join(project.memoryDir, "skills") : null,
|
|
107
111
|
projectName: project.name,
|
|
108
112
|
legacySkillsDir: path.join(legacyGlobalDir, "skills"),
|
|
109
|
-
legacyPiGlobalSkillsDir: path.join(agentRoot, "skills"),
|
|
110
113
|
migrationSentinelPath: path.join(globalDir, ".skills-migrated-to-extension-storage"),
|
|
111
114
|
});
|
|
112
115
|
const dbManager = new DatabaseManager(globalDir);
|
package/src/project.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* represents a project and resolves its name.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import * as fs from "node:fs";
|
|
6
7
|
import * as path from "node:path";
|
|
7
8
|
import * as os from "node:os";
|
|
8
9
|
import { resolveProjectsRoot } from "./paths.js";
|
|
@@ -19,11 +20,80 @@ export interface ProjectSkillInfo extends ProjectInfo {
|
|
|
19
20
|
skillsDir: string | null;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the repository root shared by every linked worktree of `dir`'s repo.
|
|
25
|
+
*
|
|
26
|
+
* Mirrors what `git rev-parse --git-common-dir` reports, without spawning git:
|
|
27
|
+
* a linked worktree's `.git` is a file pointing at
|
|
28
|
+
* `<main>/.git/worktrees/<name>`, and that directory carries a `commondir`
|
|
29
|
+
* file pointing back at the shared `<main>/.git`. Returns null outside a
|
|
30
|
+
* repository, or for a bare/detached layout with no obvious working root.
|
|
31
|
+
*/
|
|
32
|
+
function findGitRepoRoot(dir: string): string | null {
|
|
33
|
+
let current = path.resolve(dir);
|
|
34
|
+
|
|
35
|
+
while (true) {
|
|
36
|
+
const dotGit = path.join(current, ".git");
|
|
37
|
+
let stat: fs.Stats | undefined;
|
|
38
|
+
try {
|
|
39
|
+
stat = fs.statSync(dotGit);
|
|
40
|
+
} catch {
|
|
41
|
+
stat = undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (stat?.isDirectory()) return current;
|
|
45
|
+
|
|
46
|
+
if (stat?.isFile()) {
|
|
47
|
+
const commonDir = resolveWorktreeCommonDir(current, dotGit);
|
|
48
|
+
if (!commonDir) return current;
|
|
49
|
+
return path.basename(commonDir) === ".git" ? path.dirname(commonDir) : commonDir;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const parent = path.dirname(current);
|
|
53
|
+
if (parent === current) return null;
|
|
54
|
+
current = parent;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveWorktreeCommonDir(worktreeRoot: string, dotGitFile: string): string | null {
|
|
59
|
+
let pointer: string;
|
|
60
|
+
try {
|
|
61
|
+
pointer = fs.readFileSync(dotGitFile, "utf-8");
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const match = /^gitdir:\s*(.+)$/m.exec(pointer);
|
|
67
|
+
if (!match) return null;
|
|
68
|
+
|
|
69
|
+
const gitDir = path.resolve(worktreeRoot, match[1].trim());
|
|
70
|
+
try {
|
|
71
|
+
const commonDir = fs.readFileSync(path.join(gitDir, "commondir"), "utf-8").trim();
|
|
72
|
+
if (commonDir) return path.resolve(gitDir, commonDir);
|
|
73
|
+
} catch {
|
|
74
|
+
// Not a linked worktree, or an older layout without `commondir`.
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// `<main>/.git/worktrees/<name>` — two levels up is the shared git dir.
|
|
78
|
+
const parent = path.dirname(gitDir);
|
|
79
|
+
return path.basename(parent) === "worktrees" ? path.dirname(parent) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const repoRootCache = new Map<string, string | null>();
|
|
83
|
+
|
|
22
84
|
/**
|
|
23
85
|
* Detect project from the current working directory.
|
|
24
86
|
*
|
|
25
|
-
* A "project" is any directory that is not the user's home directory.
|
|
26
|
-
*
|
|
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,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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
}
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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');
|
package/src/store/fts-query.ts
CHANGED
|
@@ -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 =
|
|
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 {
|
|
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
|
-
|
|
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);
|
package/src/store/skill-store.ts
CHANGED
|
@@ -23,11 +23,13 @@ import type { SkillDocument, SkillIndex, SkillResult, SkillScope } from "../type
|
|
|
23
23
|
import { AGENT_ROOT } from "../paths.js";
|
|
24
24
|
|
|
25
25
|
interface SkillStoreOptions {
|
|
26
|
+
/** Where this extension writes global skills. Never Pi's own root. */
|
|
26
27
|
globalSkillsDir?: string;
|
|
28
|
+
/** Pi's global skills root, consulted read-only to refuse shadowed writes. */
|
|
29
|
+
piGlobalSkillsDir?: string;
|
|
27
30
|
projectSkillsDir?: string | null;
|
|
28
31
|
projectName?: string | null;
|
|
29
32
|
legacySkillsDir?: string;
|
|
30
|
-
legacyPiGlobalSkillsDir?: string;
|
|
31
33
|
migrationSentinelPath?: string;
|
|
32
34
|
}
|
|
33
35
|
|
|
@@ -153,19 +155,19 @@ export function normalizeSkillPatchContent(
|
|
|
153
155
|
|
|
154
156
|
export class SkillStore {
|
|
155
157
|
private globalSkillsDir: string;
|
|
158
|
+
private piGlobalSkillsDir: string;
|
|
156
159
|
private projectSkillsDir: string | null;
|
|
157
160
|
private projectName: string | null;
|
|
158
161
|
private legacySkillsDir: string;
|
|
159
|
-
private legacyPiGlobalSkillsDir: string;
|
|
160
162
|
private migrationSentinelPath: string;
|
|
161
163
|
|
|
162
164
|
constructor(options: SkillStoreOptions = {}) {
|
|
163
165
|
const agentRoot = AGENT_ROOT;
|
|
164
|
-
this.globalSkillsDir = options.globalSkillsDir ?? path.join(agentRoot, "skills");
|
|
166
|
+
this.globalSkillsDir = options.globalSkillsDir ?? path.join(agentRoot, "pi-hermes-memory", "skills");
|
|
167
|
+
this.piGlobalSkillsDir = options.piGlobalSkillsDir ?? path.join(agentRoot, "skills");
|
|
165
168
|
this.projectSkillsDir = options.projectSkillsDir ?? null;
|
|
166
169
|
this.projectName = options.projectName ?? null;
|
|
167
170
|
this.legacySkillsDir = options.legacySkillsDir ?? path.join(agentRoot, "memory", "skills");
|
|
168
|
-
this.legacyPiGlobalSkillsDir = options.legacyPiGlobalSkillsDir ?? path.join(agentRoot, "skills");
|
|
169
171
|
this.migrationSentinelPath = options.migrationSentinelPath
|
|
170
172
|
?? path.join(agentRoot, "pi-hermes-memory", ".skills-migrated-to-extension-storage");
|
|
171
173
|
}
|
|
@@ -174,6 +176,15 @@ export class SkillStore {
|
|
|
174
176
|
return this.globalSkillsDir;
|
|
175
177
|
}
|
|
176
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Pi's own global skills root. Read-only from here: we never create, patch,
|
|
181
|
+
* or delete inside it. It is consulted solely to refuse writes that Pi would
|
|
182
|
+
* silently shadow (see `findShadowingPiGlobalSkill`).
|
|
183
|
+
*/
|
|
184
|
+
getPiGlobalSkillsDir(): string {
|
|
185
|
+
return this.piGlobalSkillsDir;
|
|
186
|
+
}
|
|
187
|
+
|
|
177
188
|
getProjectSkillsDir(): string | null {
|
|
178
189
|
return this.projectSkillsDir;
|
|
179
190
|
}
|
|
@@ -205,11 +216,13 @@ export class SkillStore {
|
|
|
205
216
|
|
|
206
217
|
await fs.mkdir(path.dirname(this.migrationSentinelPath), { recursive: true });
|
|
207
218
|
|
|
219
|
+
// Only this migration's own warnings may hold back its sentinel — a
|
|
220
|
+
// permanently shadowed skill reported above must not make it retry forever.
|
|
221
|
+
const warningsBefore = result.warnings.length;
|
|
208
222
|
try {
|
|
209
223
|
await this.migrateLegacyMarkdownSkills(result);
|
|
210
|
-
await this.migrateLegacyPiGlobalSkillDirs(result);
|
|
211
224
|
} finally {
|
|
212
|
-
if (result.warnings.length ===
|
|
225
|
+
if (result.warnings.length === warningsBefore) {
|
|
213
226
|
await fs.writeFile(this.migrationSentinelPath, `${new Date().toISOString()}\n`, "utf-8");
|
|
214
227
|
}
|
|
215
228
|
}
|
|
@@ -307,45 +320,23 @@ export class SkillStore {
|
|
|
307
320
|
}
|
|
308
321
|
}
|
|
309
322
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
try {
|
|
330
|
-
const raw = await fs.readFile(sourceSkill, "utf-8");
|
|
331
|
-
const parsed = parseFrontmatter(raw);
|
|
332
|
-
const hasExtensionManagedMeta = Boolean(parsed.meta.display_name)
|
|
333
|
-
&& Boolean(parsed.meta.created)
|
|
334
|
-
&& Boolean(parsed.meta.updated)
|
|
335
|
-
&& /^\d+$/.test(parsed.meta.version ?? "");
|
|
336
|
-
|
|
337
|
-
if (!hasExtensionManagedMeta) {
|
|
338
|
-
result.skipped++;
|
|
339
|
-
continue;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
await fs.mkdir(path.dirname(targetDir), { recursive: true });
|
|
343
|
-
await fs.rename(sourceDir, targetDir);
|
|
344
|
-
result.migrated++;
|
|
345
|
-
} catch (error) {
|
|
346
|
-
result.warnings.push(`${entry.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
323
|
+
/**
|
|
324
|
+
* Is `slug` already claimed by a skill in Pi's own global root?
|
|
325
|
+
*
|
|
326
|
+
* Pi keys skills by name, first-loaded wins, and `~/.pi/agent/skills/` is
|
|
327
|
+
* auto-discovered at higher precedence than anything an extension contributes
|
|
328
|
+
* via `resources_discover`. A global skill we write under a name that also
|
|
329
|
+
* exists there is never the copy Pi loads, so the write succeeds on disk and
|
|
330
|
+
* changes nothing about the agent's behaviour — silent write-loss (#125).
|
|
331
|
+
*
|
|
332
|
+
* Callers refuse the write and name both paths instead, which makes the
|
|
333
|
+
* shadowed state impossible to create rather than merely reported after the
|
|
334
|
+
* fact. Returns the shadowing path, or null when the name is free.
|
|
335
|
+
*/
|
|
336
|
+
private async findShadowingPiGlobalSkill(slug: string): Promise<string | null> {
|
|
337
|
+
if (path.resolve(this.piGlobalSkillsDir) === path.resolve(this.globalSkillsDir)) return null;
|
|
338
|
+
const candidate = path.join(this.piGlobalSkillsDir, slug, "SKILL.md");
|
|
339
|
+
return await exists(candidate) ? candidate : null;
|
|
349
340
|
}
|
|
350
341
|
|
|
351
342
|
async loadIndex(scope?: SkillScope): Promise<SkillIndex[]> {
|
|
@@ -428,6 +419,19 @@ export class SkillStore {
|
|
|
428
419
|
suggestedAction: "rename",
|
|
429
420
|
};
|
|
430
421
|
}
|
|
422
|
+
|
|
423
|
+
const shadowedBy = await this.findShadowingPiGlobalSkill(slug);
|
|
424
|
+
if (shadowedBy) {
|
|
425
|
+
return {
|
|
426
|
+
success: false,
|
|
427
|
+
error: `Pi already loads a global skill named '${slug}' from ${shadowedBy}. `
|
|
428
|
+
+ `Pi keys skills by name and loads its own root first, so a skill written to `
|
|
429
|
+
+ `${path.join(this.globalSkillsDir, slug, "SKILL.md")} would never be the copy in effect. `
|
|
430
|
+
+ `Choose a different name, or edit ${shadowedBy} directly.`,
|
|
431
|
+
conflictType: "name-collision",
|
|
432
|
+
suggestedAction: "rename",
|
|
433
|
+
};
|
|
434
|
+
}
|
|
431
435
|
}
|
|
432
436
|
|
|
433
437
|
const filePath = path.join(root, slug, "SKILL.md");
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { DatabaseManager } from './db.js';
|
|
2
|
-
import {
|
|
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);
|