pi-memoria 0.3.2 → 0.3.3
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/AGENTS.md +3 -2
- package/README.md +11 -3
- package/index.ts +2 -2
- package/package.json +1 -1
- package/src/config.ts +8 -1
- package/src/database-path.ts +89 -0
- package/src/database-schema.ts +64 -0
- package/src/database.ts +27 -46
- package/src/sessions.ts +27 -3
package/AGENTS.md
CHANGED
|
@@ -28,6 +28,7 @@ From this package directory; from the monorepo root, use `npm --prefix packages/
|
|
|
28
28
|
- `index.ts`: Pi lifecycle hooks, tool schemas, guidance, and bounded tool results.
|
|
29
29
|
- `src/config.ts`: global paths and session-root configuration.
|
|
30
30
|
- `src/database.ts`: SQLite WAL, transactional FTS5 triggers, CRUD, retrieval, alias-expanded keyword queries, exact tag predicates, session-root registry.
|
|
31
|
+
- `src/database-path.ts`, `src/database-schema.ts`: pre-mutation path/companion alias guards, private snapshot attestation, exact supported-schema signatures and legacy/current initialization.
|
|
31
32
|
- `src/hot-memory.ts`: authoritative bullet file, stable IDs, priorities, duplicate-safe adds/edits, single-line validation, locking, atomic writes, cap enforcement.
|
|
32
33
|
- `src/aliases.ts`: optional global `synonyms.json` loading/validation and bounded one-hop query expansion.
|
|
33
34
|
- `src/errors.ts`: stable expected-error codes and SQLite failure classification.
|
|
@@ -61,10 +62,10 @@ Add meaningful regression tests for cap boundaries, races, durable retrieval, pa
|
|
|
61
62
|
|
|
62
63
|
## Releasing
|
|
63
64
|
|
|
64
|
-
Follow the [monorepo release process](https://github.com/rcsaquino/pi-extensions/blob/main/RELEASING.md) only after explicit approval. Version 0.3.
|
|
65
|
+
Follow the [monorepo release process](https://github.com/rcsaquino/pi-extensions/blob/main/RELEASING.md) only after explicit approval. Version 0.3.3 is the patch after the public 0.3.2 release; it is not published by editing this file.
|
|
65
66
|
|
|
66
67
|
- Verify this package and the root suite, inspect `npm pack --dry-run --ignore-scripts --workspaces=false`, and check the chosen version is not already on npm.
|
|
67
|
-
- Use package-specific tags such as `pi-memoria-v0.3.
|
|
68
|
+
- Use package-specific tags such as `pi-memoria-v0.3.3`, not a shared root `v0.3.3` tag. Commit, push, tag, release creation, and npm publication remain separately authorized actions; pushing a `pi-memoria-v<version>` tag is the publication authorization for the workflow.
|
|
68
69
|
- `.github/workflows/publish-pi-memoria.yml` publishes this package on a `pi-memoria-v<version>` tag after the full suite verifies, using npm trusted publishing (OIDC) with automatic provenance. Creating a GitHub release does not itself publish npm packages.
|
|
69
70
|
- The old trusted-publisher configuration targeting the standalone `rcsaquino/pi-memoria` repository does not apply. The npm trusted publisher must target `rcsaquino/pi-extensions` with workflow filename `publish-pi-memoria.yml` and allow `npm publish`; verify it on npm before the first tagged release and never claim it is configured until then.
|
|
70
71
|
- Manual publication needs the account's current npm authorization and any required interactive 2FA. A dry run cannot verify those permissions. Never include credentials in commands, documentation, logs, or model context.
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ pi-memoria gives Pi one memory shared across every project: instructions that mu
|
|
|
19
19
|
|
|
20
20
|
- **Node.js 22.19+** (uses `node:sqlite`; earlier baselines 22.19/24.20, maintained suite 26.10.0).
|
|
21
21
|
- A Node-based **Pi** host. The pinned development baseline is 1.0.0, the maintained Pi host. Standalone/Bun builds are not validated with `node:sqlite`.
|
|
22
|
-
- **ripgrep (`rg`) on PATH
|
|
22
|
+
- **ripgrep (`rg`)**, either on PATH or in Pi's managed `<agent-dir>/bin/` directory, for session search.
|
|
23
23
|
|
|
24
24
|
## Installation
|
|
25
25
|
|
|
@@ -27,7 +27,7 @@ pi-memoria gives Pi one memory shared across every project: instructions that mu
|
|
|
27
27
|
pi install npm:pi-memoria
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
This installs the current public npm release.
|
|
30
|
+
This installs the current public npm release. The source version is recorded in `package.json`; unpublished checkout changes require a local install.
|
|
31
31
|
|
|
32
32
|
Activate with `/reload` in an idle session when intended, or a separately approved restart. To uninstall, run `pi remove npm:pi-memoria`; stored memory remains until you delete its directory.
|
|
33
33
|
|
|
@@ -132,7 +132,7 @@ Tool replies are compact; oversized results spill to a private `pi-memoria-resul
|
|
|
132
132
|
|
|
133
133
|
| Variable | Effect |
|
|
134
134
|
|---|---|
|
|
135
|
-
| `PI_CODING_AGENT_DIR` | Pi's agent directory; memoria defaults to its `memoria/` child |
|
|
135
|
+
| `PI_CODING_AGENT_DIR` | Pi's agent directory; memoria defaults to its `memoria/` child and looks for managed ripgrep in its `bin/` child |
|
|
136
136
|
| `PI_MEMORIA_DIR` | Override the single global memory directory |
|
|
137
137
|
| `PI_MEMORIA_SESSION_DIRS` | JSON array of additional absolute session roots |
|
|
138
138
|
|
|
@@ -146,6 +146,14 @@ The optional `synonyms.json` next to `memoria.sqlite` maps terms for `any`/`all`
|
|
|
146
146
|
└── backups/
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
+
Database startup recognizes truly empty stores and the exact shipped memoria v1/v2 schemas, including legacy stores without an application identifier. Current stores receive the `MEMO` application identifier without a schema-version bump. Foreign, future, customized or unsupported schemas are refused before chmod, journal-mode changes or migration. Supported v1 migration preserves facts, archives, duplicates and FTS indexes.
|
|
150
|
+
|
|
151
|
+
Database/companion files must be regular single-link files, and existing directory components cannot be symlinks. Unsafe `-wal`, `-shm`, `-journal` aliases and orphan companions are refused. Ownership inspection uses a private mode-0700 temporary snapshot of the database/WAL/journal, because even SQLite READONLY can alter original SHM read marks. Snapshot files use 0600, copying uses a fixed-size buffer with bounded change retries, and cleanup runs on success/failure. Startup inspection is linear in stored bytes and needs temporary disk capacity; normal queries do not copy the store. Concurrent supported startup still re-attests under its migration transaction. These are focused startup checks, not an OS lease against uncoordinated path/schema mutations by another process.
|
|
152
|
+
|
|
153
|
+
Session search prefers Pi's managed `<agent-dir>/bin/rg` (`rg.exe` on Windows), then inherited PATH if the managed executable is missing (ENOENT). Permission errors or other operational failures are reported, not hidden by fallback. Resolution is retried on each search so a binary provisioned after extension loading is recognized. `PI_CODING_AGENT_DIR` uses the same absolute/tilde normalization as the memory configuration; `PI_MEMORIA_DIR` does not move the managed-bin location. Explicit executable overrides for custom callers/tests remain authoritative.
|
|
154
|
+
|
|
155
|
+
Memoria never downloads binaries or changes PATH. Normal interactive Pi startup can provision missing ripgrep on supported platforms. On an offline first start, after a failed download, or where Pi cannot provision it (such as Android/Termux), install system ripgrep or provision the managed binary separately. Android/Termux users can use `pkg install ripgrep`. Reading an original entry with `action=read` does not require ripgrep.
|
|
156
|
+
|
|
149
157
|
The default session root is `<agent-dir>/sessions`; roots Pi uses are remembered across projects. Changing `PI_MEMORIA_DIR` selects a different store without migrating the old one, project `cwd` is never used to choose it, and no data is sent over the network.
|
|
150
158
|
|
|
151
159
|
</details>
|
package/index.ts
CHANGED
|
@@ -286,10 +286,10 @@ export default function memoria(pi: ExtensionAPI) {
|
|
|
286
286
|
let warning: string | undefined;
|
|
287
287
|
try {
|
|
288
288
|
const db = database(current);
|
|
289
|
-
sessions = new SessionSearch(() => db.roots());
|
|
289
|
+
sessions = new SessionSearch(() => db.roots(), undefined, current.config.ripgrepPath);
|
|
290
290
|
} catch (error) {
|
|
291
291
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
292
|
-
sessions = new SessionSearch(() => [...current.config.sessionRoots, ctx.sessionManager.getSessionDir(), ...(sessionFile ? [dirname(sessionFile)] : [])]);
|
|
292
|
+
sessions = new SessionSearch(() => [...current.config.sessionRoots, ctx.sessionManager.getSessionDir(), ...(sessionFile ? [dirname(sessionFile)] : [])], undefined, current.config.ripgrepPath);
|
|
293
293
|
warning = `SQLite unavailable: ${(error as Error).message}. Searching default/configured/current roots only; previously remembered custom roots may be missing.`;
|
|
294
294
|
}
|
|
295
295
|
if (params.action === "read") {
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import { isAbsolute, join, resolve } from "node:path";
|
|
2
|
+
import { isAbsolute, join, posix, resolve, win32 } from "node:path";
|
|
3
3
|
|
|
4
4
|
export function absolutePath(value: string, name: string): string {
|
|
5
5
|
const expanded = value === "~" ? homedir() : value.startsWith("~/") ? join(homedir(), value.slice(2)) : value;
|
|
@@ -7,6 +7,12 @@ export function absolutePath(value: string, name: string): string {
|
|
|
7
7
|
return resolve(expanded);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/** Pi's managed binary belongs to the agent directory, not the memory store. */
|
|
11
|
+
export function managedRipgrepPath(agentDir: string, platform: NodeJS.Platform = process.platform): string {
|
|
12
|
+
const paths = platform === "win32" ? win32 : posix;
|
|
13
|
+
return paths.join(agentDir, "bin", platform === "win32" ? "rg.exe" : "rg");
|
|
14
|
+
}
|
|
15
|
+
|
|
10
16
|
export function configuration(agentDir: string, env: NodeJS.ProcessEnv = process.env) {
|
|
11
17
|
agentDir = absolutePath(agentDir, "Pi agent directory");
|
|
12
18
|
const directory = env.PI_MEMORIA_DIR ? absolutePath(env.PI_MEMORIA_DIR, "PI_MEMORIA_DIR") : join(agentDir, "memoria");
|
|
@@ -20,6 +26,7 @@ export function configuration(agentDir: string, env: NodeJS.ProcessEnv = process
|
|
|
20
26
|
}
|
|
21
27
|
return {
|
|
22
28
|
directory,
|
|
29
|
+
ripgrepPath: managedRipgrepPath(agentDir),
|
|
23
30
|
hotPath: join(directory, "MEMORY.md"),
|
|
24
31
|
databasePath: join(directory, "memoria.sqlite"),
|
|
25
32
|
aliasesPath: join(directory, "synonyms.json"),
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { constants, closeSync, fchmodSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, rmSync, writeSync, type Stats } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, join, parse, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Conservative startup checks, not exclusion of uncoordinated same-user processes.
|
|
6
|
+
* No chmod or SQLite open occurs until the entire existing chain and companions
|
|
7
|
+
* have passed. SQLite needs named companions, so it still owns pathname I/O.
|
|
8
|
+
*/
|
|
9
|
+
export function databasePath(input: string) {
|
|
10
|
+
const path = resolve(input), dir = dirname(path);
|
|
11
|
+
const directories = () => {
|
|
12
|
+
let current = parse(dir).root;
|
|
13
|
+
for (const part of dir.slice(current.length).split(/[\\/]/).filter(Boolean)) {
|
|
14
|
+
current = join(current, part);
|
|
15
|
+
try { const info = lstatSync(current); if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Unsafe database directory."); }
|
|
16
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const entry = (name: string) => {
|
|
20
|
+
try {
|
|
21
|
+
const info = lstatSync(name);
|
|
22
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) throw new Error("Unsafe database or SQLite companion alias.");
|
|
23
|
+
return info;
|
|
24
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; return undefined; }
|
|
25
|
+
};
|
|
26
|
+
const check = () => {
|
|
27
|
+
directories(); const info = entry(path);
|
|
28
|
+
for (const suffix of ["-wal", "-shm", "-journal"]) if (entry(path + suffix) && !info) throw new Error("Orphaned SQLite companion without a database.");
|
|
29
|
+
return info;
|
|
30
|
+
};
|
|
31
|
+
check(); mkdirSync(dir, { recursive: true, mode: 0o700 }); check();
|
|
32
|
+
return {
|
|
33
|
+
path, check,
|
|
34
|
+
inspect(work: (snapshot: string) => void) {
|
|
35
|
+
// SQLite READONLY still writes WAL shared-memory read marks (and may create
|
|
36
|
+
// companions). Inspect a private coherent copy instead of touching foreign
|
|
37
|
+
// state. Do not copy -shm: SQLite reconstructs it ONLY in this private tree.
|
|
38
|
+
const scratch = mkdtempSync(join(tmpdir(), "pi-memoria-inspection-"));
|
|
39
|
+
const snapshot = join(scratch, "inspect.sqlite");
|
|
40
|
+
try {
|
|
41
|
+
for (let attempt = 0; ; attempt++) {
|
|
42
|
+
const originals: { name: string; info: Stats }[] = [];
|
|
43
|
+
const missing: string[] = [];
|
|
44
|
+
let changed = false;
|
|
45
|
+
for (const suffix of ["", "-wal", "-journal"]) {
|
|
46
|
+
const name = path + suffix, info = entry(name);
|
|
47
|
+
if (!info) { missing.push(name); continue; }
|
|
48
|
+
const source = openSync(name, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
49
|
+
let target: number | undefined;
|
|
50
|
+
try {
|
|
51
|
+
const held = fstatSync(source);
|
|
52
|
+
if (!held.isFile() || held.nlink !== 1 || held.ino !== info.ino || held.dev !== info.dev) throw new Error("Unsafe database inspection identity.");
|
|
53
|
+
target = openSync(snapshot + suffix, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
54
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
55
|
+
let offset = 0;
|
|
56
|
+
while (offset < held.size) {
|
|
57
|
+
const count = readSync(source, buffer, 0, Math.min(buffer.length, Number(held.size) - offset), offset);
|
|
58
|
+
if (!count) { changed = true; break; }
|
|
59
|
+
let written = 0;
|
|
60
|
+
while (written < count) written += writeSync(target, buffer, written, count - written);
|
|
61
|
+
offset += count;
|
|
62
|
+
}
|
|
63
|
+
originals.push({ name, info: held });
|
|
64
|
+
} finally { closeSync(source); if (target !== undefined) closeSync(target); }
|
|
65
|
+
}
|
|
66
|
+
check();
|
|
67
|
+
if (missing.some(name => entry(name))) changed = true;
|
|
68
|
+
for (const { name, info } of originals) {
|
|
69
|
+
const now = entry(name);
|
|
70
|
+
if (!now || now.ino !== info.ino || now.dev !== info.dev || now.size !== info.size || now.mtimeMs !== info.mtimeMs || now.ctimeMs !== info.ctimeMs) changed = true;
|
|
71
|
+
}
|
|
72
|
+
if (!changed) { work(snapshot); return; }
|
|
73
|
+
for (const suffix of ["", "-wal", "-journal", "-shm"]) rmSync(snapshot + suffix, { force: true });
|
|
74
|
+
if (attempt >= 4) throw new Error("Database changed during ownership inspection.");
|
|
75
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
76
|
+
}
|
|
77
|
+
} finally { rmSync(scratch, { recursive: true, force: true }); }
|
|
78
|
+
},
|
|
79
|
+
privateMode() {
|
|
80
|
+
check();
|
|
81
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
82
|
+
try {
|
|
83
|
+
const held = fstatSync(fd), named = check();
|
|
84
|
+
if (!held.isFile() || held.nlink !== 1 || !named || named.ino !== held.ino || named.dev !== held.dev) throw new Error("Database identity changed.");
|
|
85
|
+
fchmodSync(fd, 0o600);
|
|
86
|
+
} finally { closeSync(fd); }
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
|
|
3
|
+
export const APPLICATION_ID = 0x4d454d4f; // MEMO. Pre-identifier v1/v2 stores remain supported.
|
|
4
|
+
export const INITIAL_SCHEMA = `
|
|
5
|
+
CREATE TABLE memories (
|
|
6
|
+
rowid INTEGER PRIMARY KEY,
|
|
7
|
+
id TEXT NOT NULL UNIQUE,
|
|
8
|
+
content TEXT NOT NULL,
|
|
9
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
10
|
+
tag_text TEXT NOT NULL DEFAULT '',
|
|
11
|
+
source TEXT NOT NULL DEFAULT '',
|
|
12
|
+
created_at TEXT NOT NULL,
|
|
13
|
+
updated_at TEXT NOT NULL,
|
|
14
|
+
revision INTEGER NOT NULL DEFAULT 1
|
|
15
|
+
) STRICT;
|
|
16
|
+
CREATE VIRTUAL TABLE memories_fts USING fts5(
|
|
17
|
+
content, tag_text, source, content='memories', content_rowid='rowid',
|
|
18
|
+
tokenize='porter unicode61 remove_diacritics 2', prefix='2 3'
|
|
19
|
+
);
|
|
20
|
+
CREATE TRIGGER memories_insert AFTER INSERT ON memories BEGIN
|
|
21
|
+
INSERT INTO memories_fts(rowid, content, tag_text, source) VALUES(new.rowid, new.content, new.tag_text, new.source);
|
|
22
|
+
END;
|
|
23
|
+
CREATE TRIGGER memories_delete AFTER DELETE ON memories BEGIN
|
|
24
|
+
INSERT INTO memories_fts(memories_fts, rowid, content, tag_text, source)
|
|
25
|
+
VALUES('delete', old.rowid, old.content, old.tag_text, old.source);
|
|
26
|
+
END;
|
|
27
|
+
CREATE TRIGGER memories_update AFTER UPDATE ON memories BEGIN
|
|
28
|
+
INSERT INTO memories_fts(memories_fts, rowid, content, tag_text, source)
|
|
29
|
+
VALUES('delete', old.rowid, old.content, old.tag_text, old.source);
|
|
30
|
+
INSERT INTO memories_fts(rowid, content, tag_text, source) VALUES(new.rowid, new.content, new.tag_text, new.source);
|
|
31
|
+
END;
|
|
32
|
+
CREATE TABLE session_roots (path TEXT PRIMARY KEY) STRICT;
|
|
33
|
+
PRAGMA user_version=1;
|
|
34
|
+
`;
|
|
35
|
+
export const VERSION_TWO = "CREATE INDEX memories_content ON memories(content); PRAGMA user_version=2;";
|
|
36
|
+
|
|
37
|
+
const schemas = new Map<number, string>();
|
|
38
|
+
function fingerprint(db: DatabaseSync): string {
|
|
39
|
+
return JSON.stringify(db.prepare("SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name").all().map(row => ({
|
|
40
|
+
...row, sql: typeof row.sql === "string" ? row.sql.replace(/\s+/g, " ").trim() : row.sql,
|
|
41
|
+
})));
|
|
42
|
+
}
|
|
43
|
+
function recognized(version: number): string {
|
|
44
|
+
let value = schemas.get(version);
|
|
45
|
+
if (value) return value;
|
|
46
|
+
// Build signatures of the schemas actually shipped by this package, including
|
|
47
|
+
// FTS shadow objects/autoindexes. This template is memory-only, never a fixture
|
|
48
|
+
// or mutation of the configured path.
|
|
49
|
+
const template = new DatabaseSync(":memory:");
|
|
50
|
+
try {
|
|
51
|
+
template.exec(INITIAL_SCHEMA);
|
|
52
|
+
if (version === 2) template.exec(VERSION_TWO);
|
|
53
|
+
value = fingerprint(template); schemas.set(version, value); return value;
|
|
54
|
+
} finally { template.close(); }
|
|
55
|
+
}
|
|
56
|
+
export function attestSchema(db: DatabaseSync): number {
|
|
57
|
+
const version = Number(db.prepare("PRAGMA user_version").get()?.user_version);
|
|
58
|
+
const application = Number(db.prepare("PRAGMA application_id").get()?.application_id);
|
|
59
|
+
if (version > 2) throw new Error(`Database schema ${version} is newer than this pi-memoria version.`);
|
|
60
|
+
if (application !== 0 && application !== APPLICATION_ID) throw new Error("Unrecognized database application.");
|
|
61
|
+
if (version === 0 && application === 0 && db.prepare("SELECT COUNT(*) AS n FROM sqlite_master").get()?.n === 0) return 0;
|
|
62
|
+
if ((version !== 1 && version !== 2) || fingerprint(db) !== recognized(version)) throw new Error("Unsupported or unrelated memoria database schema.");
|
|
63
|
+
return version;
|
|
64
|
+
}
|
package/src/database.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { chmodSync, mkdirSync } from "node:fs";
|
|
3
2
|
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { databasePath } from "./database-path.ts";
|
|
4
|
+
import { APPLICATION_ID, INITIAL_SCHEMA, VERSION_TWO, attestSchema } from "./database-schema.ts";
|
|
4
5
|
import { DatabaseSync, type StatementSync } from "node:sqlite";
|
|
5
6
|
import { expandQuery, loadAliases, type AliasConfiguration } from "./aliases.ts";
|
|
6
7
|
import { ExpectedError, isSqliteError, storeUnavailable } from "./errors.ts";
|
|
@@ -52,55 +53,35 @@ export class MemoryDatabase {
|
|
|
52
53
|
private closed = false;
|
|
53
54
|
|
|
54
55
|
constructor(readonly path: string, private readonly aliasesPath?: string) {
|
|
55
|
-
|
|
56
|
-
catch (error) { throw storeUnavailable("open", path, error); }
|
|
57
|
-
try { this.db = new DatabaseSync(path); }
|
|
58
|
-
catch (error) { throw storeUnavailable("open", path, error); }
|
|
56
|
+
let location: ReturnType<typeof databasePath>;
|
|
59
57
|
try {
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
location = databasePath(path);
|
|
59
|
+
// A rejected existing database never receives a writable connection, chmod,
|
|
60
|
+
// journal-mode change or migration. Version zero alone is not ownership.
|
|
61
|
+
const existing = location.check();
|
|
62
|
+
if (existing) {
|
|
63
|
+
location.inspect(snapshot => {
|
|
64
|
+
const inspection = new DatabaseSync(snapshot);
|
|
65
|
+
try { inspection.exec("PRAGMA busy_timeout=5000; BEGIN"); attestSchema(inspection); }
|
|
66
|
+
finally { inspection.close(); }
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
location.check();
|
|
70
|
+
this.db = new DatabaseSync(location.path);
|
|
71
|
+
} catch (error) { throw storeUnavailable("open", path, error); }
|
|
72
|
+
try {
|
|
73
|
+
this.db.exec("PRAGMA busy_timeout=5000;");
|
|
62
74
|
this.db.function("memoria_fold", { deterministic: true }, (value) => String(value).toLowerCase());
|
|
63
75
|
this.transaction(() => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
id TEXT NOT NULL UNIQUE,
|
|
71
|
-
content TEXT NOT NULL,
|
|
72
|
-
tags TEXT NOT NULL DEFAULT '[]',
|
|
73
|
-
tag_text TEXT NOT NULL DEFAULT '',
|
|
74
|
-
source TEXT NOT NULL DEFAULT '',
|
|
75
|
-
created_at TEXT NOT NULL,
|
|
76
|
-
updated_at TEXT NOT NULL,
|
|
77
|
-
revision INTEGER NOT NULL DEFAULT 1
|
|
78
|
-
) STRICT;
|
|
79
|
-
CREATE VIRTUAL TABLE memories_fts USING fts5(
|
|
80
|
-
content, tag_text, source, content='memories', content_rowid='rowid',
|
|
81
|
-
tokenize='porter unicode61 remove_diacritics 2', prefix='2 3'
|
|
82
|
-
);
|
|
83
|
-
CREATE TRIGGER memories_insert AFTER INSERT ON memories BEGIN
|
|
84
|
-
INSERT INTO memories_fts(rowid, content, tag_text, source) VALUES(new.rowid, new.content, new.tag_text, new.source);
|
|
85
|
-
END;
|
|
86
|
-
CREATE TRIGGER memories_delete AFTER DELETE ON memories BEGIN
|
|
87
|
-
INSERT INTO memories_fts(memories_fts, rowid, content, tag_text, source)
|
|
88
|
-
VALUES('delete', old.rowid, old.content, old.tag_text, old.source);
|
|
89
|
-
END;
|
|
90
|
-
CREATE TRIGGER memories_update AFTER UPDATE ON memories BEGIN
|
|
91
|
-
INSERT INTO memories_fts(memories_fts, rowid, content, tag_text, source)
|
|
92
|
-
VALUES('delete', old.rowid, old.content, old.tag_text, old.source);
|
|
93
|
-
INSERT INTO memories_fts(rowid, content, tag_text, source) VALUES(new.rowid, new.content, new.tag_text, new.source);
|
|
94
|
-
END;
|
|
95
|
-
CREATE TABLE session_roots (path TEXT PRIMARY KEY) STRICT;
|
|
96
|
-
PRAGMA user_version=1;
|
|
97
|
-
`);
|
|
98
|
-
}
|
|
99
|
-
if (version < 2) {
|
|
100
|
-
// Keep existing duplicates and archive evidence intact; serialize new adds below.
|
|
101
|
-
this.db.exec("CREATE INDEX memories_content ON memories(content); PRAGMA user_version=2;");
|
|
102
|
-
}
|
|
76
|
+
// Re-attest under the writer transaction for concurrent initialization.
|
|
77
|
+
const version = attestSchema(this.db);
|
|
78
|
+
location.check();
|
|
79
|
+
if (version === 0) this.db.exec(INITIAL_SCHEMA);
|
|
80
|
+
if (version < 2) this.db.exec(VERSION_TWO);
|
|
81
|
+
this.db.exec(`PRAGMA application_id=${APPLICATION_ID};`);
|
|
103
82
|
});
|
|
83
|
+
location.privateMode();
|
|
84
|
+
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;");
|
|
104
85
|
} catch (error) {
|
|
105
86
|
this.closed = true;
|
|
106
87
|
try { this.db.close(); } catch { /* keep the original failure */ }
|
package/src/sessions.ts
CHANGED
|
@@ -98,8 +98,30 @@ function rgText(value: unknown): string {
|
|
|
98
98
|
throw new Error("ripgrep returned an invalid text record.");
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/** Try at most two binaries, only falling back when spawning reports ENOENT. */
|
|
102
|
+
async function startRipgrep(executables: string[], args: string[], signal?: AbortSignal) {
|
|
103
|
+
for (const [index, executable] of executables.entries()) {
|
|
104
|
+
signal?.throwIfAborted();
|
|
105
|
+
const child = spawn(executable, args, { shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
106
|
+
try {
|
|
107
|
+
await new Promise<void>((done, fail) => {
|
|
108
|
+
child.once("spawn", done);
|
|
109
|
+
child.once("error", fail);
|
|
110
|
+
});
|
|
111
|
+
return { child, executable };
|
|
112
|
+
} catch (error) {
|
|
113
|
+
signal?.throwIfAborted();
|
|
114
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT" && index + 1 < executables.length) continue;
|
|
115
|
+
throw new Error(`Cannot run ripgrep (${executable}): ${(error as Error).message}. Install rg and ensure it is on PATH, or provision Pi's managed binary. Checked: ${executables.join(", ")}. Session search is incomplete.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
throw new Error("No ripgrep executable configured.");
|
|
119
|
+
}
|
|
120
|
+
|
|
101
121
|
export class SessionSearch {
|
|
102
|
-
|
|
122
|
+
// An explicit executable remains authoritative. Otherwise prefer Pi's managed
|
|
123
|
+
// binary when supplied, then inherited PATH. Resolve anew on every search.
|
|
124
|
+
constructor(private roots: () => string[], private executable?: string, private managedExecutable?: string) {}
|
|
103
125
|
|
|
104
126
|
async search(input: SessionQuery, signal?: AbortSignal) {
|
|
105
127
|
signal?.throwIfAborted();
|
|
@@ -137,7 +159,9 @@ export class SessionSearch {
|
|
|
137
159
|
const patterns = query && terms.length ? [...new Set([...rawTerms, ...terms.map((term) => JSON.stringify(term).slice(1, -1)), "\\u", "\\/"])] : [""];
|
|
138
160
|
for (const pattern of patterns) args.push("-e", pattern);
|
|
139
161
|
args.push("--", ...coverage.roots);
|
|
140
|
-
const
|
|
162
|
+
const executables = this.executable !== undefined ? [this.executable]
|
|
163
|
+
: this.managedExecutable ? [this.managedExecutable, "rg"] : ["rg"];
|
|
164
|
+
const { child, executable } = await startRipgrep(executables, args, signal);
|
|
141
165
|
let spawnError: Error | undefined;
|
|
142
166
|
let stderr = "";
|
|
143
167
|
child.stderr.setEncoding("utf8");
|
|
@@ -201,7 +225,7 @@ export class SessionSearch {
|
|
|
201
225
|
signal?.removeEventListener("abort", abort);
|
|
202
226
|
}
|
|
203
227
|
signal?.throwIfAborted();
|
|
204
|
-
if (spawnError) throw new Error(`Cannot run ripgrep (${
|
|
228
|
+
if (spawnError) throw new Error(`Cannot run ripgrep (${executable}): ${spawnError.message}. Session search is incomplete.`);
|
|
205
229
|
const code = await completion;
|
|
206
230
|
if (stderr.trim()) coverage.warnings.push(`ripgrep: ${stderr.trim()}`);
|
|
207
231
|
if (!hasMore && !timedOut && code !== 0 && code !== 1) coverage.warnings.push(`ripgrep stopped with exit code ${code}; search is incomplete.`);
|