pi-memoria 0.3.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # Working on pi-memoria
2
2
 
3
3
  This is a TypeScript Pi extension with five tools and **one global memory store**.
4
- README.md documents the product; this file documents how to change and release the repository.
4
+ README.md documents the product; this file documents how to change this package. Read the [monorepo instructions](https://github.com/rcsaquino/pi-extensions/blob/main/AGENTS.md) and [release process](https://github.com/rcsaquino/pi-extensions/blob/main/RELEASING.md) for shared tooling and authorized publication.
5
5
 
6
6
  ## North star
7
7
 
@@ -13,7 +13,9 @@ README.md documents the product; this file documents how to change and release t
13
13
 
14
14
  ## Commands
15
15
 
16
- - `npm ci --ignore-scripts`: install the locked development dependencies.
16
+ From this package directory; from the monorepo root, use `npm --prefix packages/pi-memoria` before the same operation.
17
+
18
+ - `npm install --ignore-scripts --legacy-peer-deps --workspaces=false`: install package-local development dependencies. No lockfile is currently committed; use `npm ci` only after a usable lock is deliberately supplied.
17
19
  - `npm run check`: strict TypeScript checking.
18
20
  - `npm test`: storage, concurrency, retrieval, and Pi integration tests; uses temporary directories.
19
21
  - `npm run verify`: typecheck and tests.
@@ -26,6 +28,7 @@ README.md documents the product; this file documents how to change and release t
26
28
  - `index.ts`: Pi lifecycle hooks, tool schemas, guidance, and bounded tool results.
27
29
  - `src/config.ts`: global paths and session-root configuration.
28
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.
29
32
  - `src/hot-memory.ts`: authoritative bullet file, stable IDs, priorities, duplicate-safe adds/edits, single-line validation, locking, atomic writes, cap enforcement.
30
33
  - `src/aliases.ts`: optional global `synonyms.json` loading/validation and bounded one-hop query expansion.
31
34
  - `src/errors.ts`: stable expected-error codes and SQLite failure classification.
@@ -51,59 +54,22 @@ README.md documents the product; this file documents how to change and release t
51
54
 
52
55
  ## Development conventions
53
56
 
54
- Use explicit `.ts` imports, strict types, Node built-ins, and small modules. Pi loads TypeScript directly; there is no build step. Keep the peer dependency ranges required by Pi packaging; pin the tested Pi version in devDependencies. Update package-lock.json through npm when dependencies change.
57
+ Use explicit `.ts` imports, strict types, Node built-ins, and small modules. Pi loads TypeScript directly; there is no build step. Keep the peer dependency ranges required by Pi packaging; pin the tested Pi version in devDependencies. If maintaining a lockfile, update it through npm when dependencies change; never edit it manually or introduce root dependency hoisting as an unrelated fix.
55
58
 
56
- Read the installed Pi documentation and relevant examples before changing integration code. The reference installation for this workspace is `/home/rcsaquino/.npm-global/lib/node_modules/@earendil-works/pi-coding-agent/`; resolve `docs/...` and `examples/...` there, not here. Read referenced Markdown files completely. Check exported declarations for exact API signatures.
59
+ Read the installed Pi documentation and relevant examples before changing integration code. Locate the host with `npm root -g`, then its `@earendil-works/pi-coding-agent` package (or the configured `PI_HOST_PACKAGE_DIR` for development tooling); resolve `docs/...` and `examples/...` there, not here. Read referenced Markdown files completely. Check exported declarations for exact API signatures.
57
60
 
58
61
  Add meaningful regression tests for cap boundaries, races, durable retrieval, pagination, and error paths when changing them. Run `npm run verify` before delivery. Benchmark claims must state corpus size and distinguish indexed SQLite lookups from linear session scans.
59
62
 
60
63
  ## Releasing
61
64
 
62
- Start a release only when the user decides to bump the version. Tags, GitHub releases, and npm versions are public and are not cleanly removable.
63
-
64
- 1. Bump `version` in `package.json`, then run `npm run verify`. A version-only bump does not change dependencies, so `package-lock.json` needs no update.
65
- 2. Commit as `Release X.Y.Z` and push `main`.
66
- 3. Create an annotated tag `vX.Y.Z` with the message `pi-memoria X.Y.Z` (the convention for `v0.2.0` and later) and push it with the commit:
67
-
68
- ```sh
69
- git tag -a vX.Y.Z -m "pi-memoria X.Y.Z"
70
- git push origin main vX.Y.Z
71
- ```
72
-
73
- 4. Create the GitHub release after the tag exists. Publishing the release triggers
74
- `.github/workflows/npm-publish.yml`, which checks the tag against `package.json`, runs
75
- `npm run verify`, and publishes to npm. `gh` is installed and authenticated as
76
- `rcsaquino`:
77
-
78
- ```sh
79
- gh release create vX.Y.Z --title "pi-memoria X.Y.Z" --notes-file - <<'EOF'
80
- ...
81
- EOF
82
- ```
83
-
84
- Keep notes factual: what changed, requirements, and limits. Do not restate the README.
85
-
86
- 5. Watch the workflow and confirm the registry:
87
-
88
- ```sh
89
- gh run list --workflow npm-publish.yml --limit 4
90
- npm view pi-memoria version dist-tags
91
- ```
92
-
93
- - The workflow authenticates with npm trusted publishing (OIDC). The trusted publisher
94
- on npmjs.com must name the repository `rcsaquino/pi-memoria` and the workflow file
95
- `npm-publish.yml`. The package's publishing access disallows traditional tokens,
96
- so OIDC is the only automated publish path.
97
- - The `files` field in `package.json` controls the tarball; inspect it with
98
- `npm pack --dry-run` first.
99
- - The registry can accept the tarball (`HTTP 202`, "being processed") and take a few
100
- minutes to expose it. Confirm with `npm view` before reporting success.
101
- - Fallback only if the workflow is unavailable: `npm publish` locally. Writes require
102
- 2FA, so it starts the npmjs web-auth flow and needs a real terminal or pseudo-TTY to
103
- open the browser; without one it fails with `EOTP`. An automation token with 2FA
104
- bypass also works.
105
-
106
- Only push tags, create releases, or publish to npm for a user-requested version bump.
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.
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.
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.
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.
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.
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.
72
+ - Confirm the exact published version and dist-tag from the public registry before reporting success. Keep release notes factual and specific to this package.
107
73
 
108
74
  ## Files and data to protect
109
75
 
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Rey Christian
3
+ Copyright (c) 2026 rcsaquino
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -5,7 +5,6 @@
5
5
 
6
6
  **Global memory for [Pi](https://github.com/earendil-works/pi): startup rules in `MEMORY.md`, durable facts in SQLite FTS5, and cited search across every past conversation.**
7
7
 
8
- [![GitHub release](https://img.shields.io/github/v/release/rcsaquino/pi-memoria?style=flat-square)](https://github.com/rcsaquino/pi-memoria/releases)
9
8
  [![Node.js >= 22.19](https://img.shields.io/badge/Node.js-%3E%3D22.19-3c873a?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org)
10
9
  [![TypeScript](https://img.shields.io/badge/TypeScript-3178c6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org)
11
10
  [![MIT license](https://img.shields.io/badge/license-MIT-yellow?style=flat-square)](LICENSE)
@@ -18,9 +17,9 @@ pi-memoria gives Pi one memory shared across every project: instructions that mu
18
17
 
19
18
  ## Requirements
20
19
 
21
- - **Node.js 22.19+** (uses `node:sqlite`; tested on 22.19 and 24.20).
22
- - **Pi** with the current extension API (tested with 0.87.1), Node-based. Standalone/Bun builds are not validated with `node:sqlite`.
23
- - **ripgrep (`rg`) on PATH** for session search.
20
+ - **Node.js 22.19+** (uses `node:sqlite`; earlier baselines 22.19/24.20, maintained suite 26.10.0).
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`)**, either on PATH or in Pi's managed `<agent-dir>/bin/` directory, for session search.
24
23
 
25
24
  ## Installation
26
25
 
@@ -28,20 +27,24 @@ pi-memoria gives Pi one memory shared across every project: instructions that mu
28
27
  pi install npm:pi-memoria
29
28
  ```
30
29
 
31
- Restart Pi or run `/reload`. To uninstall, run `pi remove npm:pi-memoria`; stored memory remains until you delete its directory.
30
+ This installs the current public npm release. The source version is recorded in `package.json`; unpublished checkout changes require a local install.
31
+
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.
32
33
 
33
34
  <details>
34
35
  <summary><strong>Install from a local checkout</strong></summary>
35
36
 
37
+ From the monorepo root:
38
+
36
39
  ```sh
37
- npm ci --ignore-scripts
38
- pi install /absolute/path/to/pi-memoria
40
+ npm --prefix packages/pi-memoria install --ignore-scripts --legacy-peer-deps --workspaces=false
41
+ pi install /absolute/path/to/pi-extensions/packages/pi-memoria
39
42
  ```
40
43
 
41
44
  Keep the checkout in place: a local install references it directly. To try it without installing:
42
45
 
43
46
  ```sh
44
- pi -e /absolute/path/to/pi-memoria/index.ts
47
+ pi -e /absolute/path/to/pi-extensions/packages/pi-memoria/index.ts
45
48
  ```
46
49
 
47
50
  </details>
@@ -129,7 +132,7 @@ Tool replies are compact; oversized results spill to a private `pi-memoria-resul
129
132
 
130
133
  | Variable | Effect |
131
134
  |---|---|
132
- | `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 |
133
136
  | `PI_MEMORIA_DIR` | Override the single global memory directory |
134
137
  | `PI_MEMORIA_SESSION_DIRS` | JSON array of additional absolute session roots |
135
138
 
@@ -143,6 +146,14 @@ The optional `synonyms.json` next to `memoria.sqlite` maps terms for `any`/`all`
143
146
  └── backups/
144
147
  ```
145
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
+
146
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.
147
158
 
148
159
  </details>
@@ -168,12 +179,17 @@ The default session root is `<agent-dir>/sessions`; roots Pi uses are remembered
168
179
 
169
180
  ## Development
170
181
 
182
+ From the monorepo root:
183
+
171
184
  ```sh
172
- npm ci --ignore-scripts
173
- npm run verify
174
- npm run bench
185
+ npm --prefix packages/pi-memoria install --ignore-scripts --legacy-peer-deps --workspaces=false
186
+ npm run link-host
187
+ npm --prefix packages/pi-memoria run verify
188
+ npm --prefix packages/pi-memoria run bench
175
189
  ```
176
190
 
191
+ No package lockfile is currently committed, so `npm ci` is not a fresh-checkout setup command. The shared host-link helper preserves working dependencies and only repairs missing/broken links.
192
+
177
193
  There is no build step: Pi loads `index.ts` directly. `src/database.ts`, `src/hot-memory.ts`, and `src/sessions.ts` own the storage layers; `index.ts` wires them to lifecycle events and the tool schemas. Tests use temporary directories, real SQLite FTS5, real `rg`, concurrent writers, and Pi's extension loader. See [AGENTS.md](AGENTS.md) for architecture and contribution rules.
178
194
 
179
195
  ## Support and contact
package/index.ts CHANGED
@@ -15,7 +15,7 @@ import { chunk, HOT_LIMIT, MEMORY_LIMIT } from "./src/text.ts";
15
15
  const SAVE_GUIDANCE = "Before saving a durable fact, search SQLite with memoria_search using topic keywords and aliases, and inspect relevant matches. If the same fact is already stored, do not add it again; use memoria_edit with its ID only when information changes or useful detail is missing. Different wording can express the same fact. For hot memory, check the injected MEMORY.md before adding a bullet.";
16
16
 
17
17
  export const MEMORY_GUIDANCE = `You have global, persistent memory shared across all projects.
18
- - MEMORY.md below is working memory, available before every user prompt. Store only requirements needed without a retrieval cue, e.g. "Start every sentence with beep_boop." Topic-triggered facts such as liking apples belong in SQLite, tagged with useful retrieval cues such as fruit and preferences.
18
+ - The hot_memory block contains standing instructions from MEMORY.md, available before every user prompt, not merely quoted reference material. Follow them subject to higher-priority instructions and the conflict guidance below. Store only requirements needed without a retrieval cue, e.g. "Start every sentence with beep_boop." Topic-triggered facts such as liking apples belong in SQLite, tagged with useful retrieval cues such as fruit and preferences.
19
19
  - Proactively save durable user instructions, preferences, and decisions with memoria_add; update corrections with memoria_edit and remove obsolete facts with memoria_delete. Do not save guesses as facts or secrets unless explicitly requested. Use the memory tools for writes, not direct file/database edits.
20
20
  - ${SAVE_GUIDANCE}
21
21
  - For hot writes use store="hot", content, and optional priority. Hot content must be one line and internal spacing is preserved. Tags and source are SQLite metadata; omit them for hot memory.
@@ -116,21 +116,23 @@ export default function memoria(pi: ExtensionAPI) {
116
116
  });
117
117
 
118
118
  pi.on("before_agent_start", async (event, ctx) => {
119
- let text = MEMORY_GUIDANCE;
119
+ let text = `<instructions>\n${MEMORY_GUIDANCE}\n</instructions>`;
120
+ const status: string[] = [];
120
121
  const current = stores(ctx);
121
122
  try {
122
123
  const snapshot = await current.hot.load();
123
- text += `\n\nGlobal MEMORY.md (${snapshot.characters}/${HOT_LIMIT} characters; ${current.config.hotPath}):\n${snapshot.text || "(empty)"}`;
124
+ text += `\n\n<hot_memory>\nGlobal MEMORY.md (${snapshot.characters}/${HOT_LIMIT} characters; ${current.config.hotPath}):\n${snapshot.text || "(empty)"}\n</hot_memory>`;
124
125
  for (const repaired of [startupRepair, snapshot]) {
125
- if (repaired?.archived.length) text += `\nRepaired an oversized manual edit: ${repaired.archived.length} bullets archived to SQLite; original backup: ${repaired.backup}.`;
126
+ if (repaired?.archived.length) status.push(`Repaired an oversized manual edit: ${repaired.archived.length} bullets archived to SQLite; original backup: ${repaired.backup}.`);
126
127
  }
127
128
  startupRepair = undefined;
128
129
  } catch (error) {
129
- text += `\n\nMEMORY UNAVAILABLE: ${(error as Error).message}. Hot instructions were not loaded; tell the user and do not assume that memory is empty.`;
130
+ status.push(`MEMORY UNAVAILABLE: ${(error as Error).message}. Hot instructions were not loaded; tell the user and do not assume that memory is empty.`);
130
131
  }
131
132
  if (!current.database && current.databaseError !== undefined) {
132
- text += `\n\nSQLITE LONG-TERM MEMORY UNAVAILABLE: ${(current.databaseError as Error).message}. Hot memory above remains valid; SQLite add/edit/delete/search and archive operations fail until the store is fixed. Session search uses default, configured, and current roots only, so remembered custom roots may be missing.`;
133
+ status.push(`SQLITE LONG-TERM MEMORY UNAVAILABLE: ${(current.databaseError as Error).message}. Any successfully loaded hot memory remains valid; SQLite add/edit/delete/search and archive operations fail until the store is fixed. Session search uses default, configured, and current roots only, so remembered custom roots may be missing.`);
133
134
  }
135
+ if (status.length) text += `\n\n<status>\n${status.join("\n")}\n</status>`;
134
136
  event.systemPromptOptions.sections.pi_memoria = text;
135
137
  // Respect earlier extensions that supply an opaque, complete system prompt.
136
138
  if (event.systemPromptOptions.forceSystemPrompt !== undefined) {
@@ -284,10 +286,10 @@ export default function memoria(pi: ExtensionAPI) {
284
286
  let warning: string | undefined;
285
287
  try {
286
288
  const db = database(current);
287
- sessions = new SessionSearch(() => db.roots());
289
+ sessions = new SessionSearch(() => db.roots(), undefined, current.config.ripgrepPath);
288
290
  } catch (error) {
289
291
  const sessionFile = ctx.sessionManager.getSessionFile();
290
- 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);
291
293
  warning = `SQLite unavailable: ${(error as Error).message}. Searching default/configured/current roots only; previously remembered custom roots may be missing.`;
292
294
  }
293
295
  if (params.action === "read") {
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "pi-memoria",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Global working memory, SQLite FTS5 recall, and complete Pi session search",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
- "repository": { "type": "git", "url": "git+https://github.com/rcsaquino/pi-memoria.git" },
7
+ "repository": { "type": "git", "url": "git+https://github.com/rcsaquino/pi-extensions.git", "directory": "packages/pi-memoria" },
8
+ "homepage": "https://github.com/rcsaquino/pi-extensions/tree/main/packages/pi-memoria#readme",
9
+ "bugs": { "url": "https://github.com/rcsaquino/pi-extensions/issues" },
10
+ "publishConfig": { "access": "public" },
8
11
  "keywords": ["pi-package", "pi-extension", "memory", "sqlite", "fts5"],
9
12
  "files": ["index.ts", "src", "README.md", "AGENTS.md", "LICENSE"],
10
13
  "pi": { "extensions": ["./index.ts"] },
@@ -21,7 +24,7 @@
21
24
  "typebox": "*"
22
25
  },
23
26
  "devDependencies": {
24
- "@earendil-works/pi-coding-agent": "0.87.1",
27
+ "@earendil-works/pi-coding-agent": "1.0.0",
25
28
  "@types/node": "^22.19.0",
26
29
  "@types/proper-lockfile": "^4.1.4",
27
30
  "tsx": "^4.20.0",
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
- try { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); }
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
- chmodSync(path, 0o600);
61
- this.db.exec("PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;");
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
- const version = Number(this.db.prepare("PRAGMA user_version").get()?.user_version);
65
- if (version > 2) throw new Error(`Database schema ${version} is newer than this pi-memoria version.`);
66
- if (version === 0) {
67
- this.db.exec(`
68
- CREATE TABLE memories (
69
- rowid INTEGER PRIMARY KEY,
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
- constructor(private roots: () => string[], private executable = "rg") {}
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 child = spawn(this.executable, args, { shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
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 (${this.executable}): ${spawnError.message}. Install rg and ensure it is on PATH.`);
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.`);