pi-memory-stone 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,9 +46,10 @@ pi-memory-stone package source
46
46
  │ ├── retrieval/ # FTS search, hybrid ranking, injection builder
47
47
  │ ├── commands/ # /memory-* slash commands
48
48
  │ ├── tools/ # LLM-callable tools
49
+ │ ├── vault/ # Optional Obsidian-compatible markdown vaults
49
50
  │ ├── privacy/ # Secret redaction, sensitive path filtering
50
51
  │ └── config/ # Project identity, settings
51
- └── test/ # 51 tests across 6 test files
52
+ └── test/ # tests across memory, privacy, portable, and vault helpers
52
53
  ```
53
54
 
54
55
  ## How It Works
@@ -116,6 +117,10 @@ Before each agent turn, the extension:
116
117
  | `/memory-import <memory-export.json> --preserve-project` | | Import records while preserving exported project IDs |
117
118
  | `/memory-import <memory-export.json> --global` | | Import all records as global memories |
118
119
  | `/memory-backup [path]` | `/stone-backup` | Copy the SQLite memory database to a timestamped backup file |
120
+ | `/memory-vault-init [--project\|--personal]` | `/stone-vault-init` | Initialize an Obsidian-compatible markdown vault |
121
+ | `/memory-vault-sync [--project\|--personal]` | `/stone-vault-sync` | Generate vault pages from active memory records |
122
+ | `/memory-vault-status [--project\|--personal]` | `/stone-vault-status` | Show vault path, page counts, registry, and last sync |
123
+ | `/memory-vault-capture-url <url> [--project\|--personal]` | `/stone-vault-capture-url` | Capture a web page into the vault as a source page |
119
124
  | `/memory-on` | | Enable memory injection for this session |
120
125
  | `/memory-off` | | Disable memory injection for this session |
121
126
 
@@ -188,6 +193,52 @@ Use a database backup before pruning or hard deletion:
188
193
 
189
194
  Import defaults are intentionally practical for machine moves: project-scoped records are remapped to the current project. Use `--preserve-project` to keep exported project IDs, or `--global` to import everything as global memory.
190
195
 
196
+ ## Knowledge Vaults
197
+
198
+ Memory vaults are optional Obsidian-compatible markdown projections of active memory records. SQLite remains the source of truth; generated pages may be overwritten by `/memory-vault-sync`.
199
+
200
+ ```bash
201
+ # Project-local vault, written only after explicit init or capture request
202
+ /memory-vault-init --project
203
+ /memory-vault-sync --project
204
+ /memory-vault-status --project
205
+
206
+ # Capture a web page source into the vault
207
+ /memory-vault-capture-url https://example.com/article --project
208
+
209
+ # Capture resolves known source formats before generic HTML extraction.
210
+ # Examples: GitHub Gist pages are fetched from gist.githubusercontent.com/raw,
211
+ # GitHub blob URLs are fetched from raw.githubusercontent.com, and raw Markdown
212
+ # is preserved as Markdown.
213
+
214
+ # Natural-language capture also works from normal prompts:
215
+ # "Capture this article into vault https://example.com/article"
216
+ # "Add page to personal vault https://example.com/article"
217
+
218
+ # Private personal vault for global memories
219
+ /memory-vault-init --personal
220
+ /memory-vault-sync --personal
221
+ ```
222
+
223
+ Default locations:
224
+
225
+ ```txt
226
+ <repo>/.memory-stone/vault/ # project vault
227
+ ~/.pi/agent/memory/vaults/personal/ # personal vault
228
+ ```
229
+
230
+ Initial layout:
231
+
232
+ ```txt
233
+ index.md
234
+ WIKI_SCHEMA.md
235
+ records/{decisions,preferences,tasks,error-resolutions,turn-summaries,session-summaries}/
236
+ sources/ # captured web source pages
237
+ meta/registry.json
238
+ ```
239
+
240
+ URL capture writes curated source notes into `sources/`. Raw provenance packets (manifest, metadata, fetch attempts, original artifact, extracted markdown) are stored outside the Obsidian vault under `.memory-stone/source-packets/` for project vaults or `~/.pi/agent/memory/source-packets/personal/` for personal vaults. Capture extracts HTML articles with Mozilla Readability, converts to Markdown, redacts secrets, and marks extraction quality as `good` or `weak` with warnings.
241
+
191
242
  ## Privacy & Safety
192
243
 
193
244
  ### Secret Redaction
@@ -260,16 +311,18 @@ npm run typecheck
260
311
 
261
312
  These scripts are also runnable from a pi package clone installed from git; the required script runners are regular dependencies because pi package installs omit `devDependencies`.
262
313
 
263
- 51 tests across 6 test files:
314
+ 69 tests across 8 test files:
264
315
 
265
316
  | Suite | Tests | Focus |
266
317
  |---|---|---|
267
318
  | `indexing.test.ts` | 1 | Incremental session indexing |
268
319
  | `parser.test.ts` | 10 | Turn parsing, file activity detection, error extraction |
269
- | `portable.test.ts` | 3 | JSON/Markdown export, JSON import, SQLite backup |
270
- | `privacy.test.ts` | 17 | Secret redaction, sensitive path filtering |
271
- | `ranking.test.ts` | 16 | Hybrid ranking, cross-project filtering, injection formatting |
272
- | `session-state.test.ts` | 4 | Injection mode config, session ref selection, manual-only injection |
320
+ | `portable.test.ts` | 5 | JSON/Markdown export, JSON import, SQLite backup |
321
+ | `privacy.test.ts` | 21 | Secret redaction, sensitive path filtering |
322
+ | `ranking.test.ts` | 18 | Hybrid ranking, cross-project filtering, injection formatting |
323
+ | `session-state.test.ts` | 5 | Injection mode config, session ref selection, manual-only injection |
324
+ | `tools.test.ts` | 2 | Tool visibility and forgetting safety |
325
+ | `vault.test.ts` | 7 | Vault path resolution, initialization, sync, URL capture, registry generation |
273
326
 
274
327
  ## Configuration
275
328
 
@@ -302,6 +355,7 @@ For manual-only memory, keep `enabled: true` and set `injectionMode: "manual"`.
302
355
  - LLM extraction (decisions, preferences, tasks)
303
356
  - Historical backfill (`/memory-backfill`)
304
357
  - Embedding-based semantic search
358
+ - Vault backlinks/lint/search/capture commands
305
359
  - `/memory-edit`, `/memory-supersede`
306
360
  - Rich TUI memory browser
307
361
  - Multi-machine sync
package/package.json CHANGED
@@ -1,24 +1,37 @@
1
1
  {
2
2
  "name": "pi-memory-stone",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Global pi extension: preserves and retrieves useful memory across pi sessions",
6
6
  "license": "MIT",
7
- "keywords": ["pi-package"],
7
+ "keywords": [
8
+ "pi-package"
9
+ ],
8
10
  "files": [
9
11
  "src",
12
+ "skills/**/*",
10
13
  "README.md",
11
14
  "LICENSE"
12
15
  ],
13
16
  "pi": {
14
- "extensions": ["./src/index.ts"]
17
+ "extensions": [
18
+ "./src/index.ts"
19
+ ],
20
+ "skills": [
21
+ "./skills"
22
+ ]
15
23
  },
16
24
  "scripts": {
17
25
  "test": "NODE_OPTIONS='--experimental-sqlite' tsx --test test/*.test.ts",
18
26
  "typecheck": "tsc --noEmit"
19
27
  },
20
28
  "dependencies": {
29
+ "@mozilla/readability": "^0.6.0",
30
+ "@types/jsdom": "^28.0.3",
31
+ "@types/turndown": "^5.0.6",
32
+ "jsdom": "^29.1.1",
21
33
  "tsx": "^4.22.3",
34
+ "turndown": "^7.2.4",
22
35
  "typescript": "^5.9.3"
23
36
  },
24
37
  "peerDependencies": {
@@ -0,0 +1,101 @@
1
+ ---
2
+ name: pi-memory-stone
3
+ description: Persistent cross-session memory for pi via SQLite+FTS5. Remembers past decisions, preferences, turns, and error resolutions across sessions. Use when recalling context from prior sessions, using memory_search/memory_open/memory_remember/memory_forget tools, troubleshooting memory injection, or when user asks about pi-memory-stone, memory stone, session memory, or remembering context.
4
+ ---
5
+
6
+ # Pi Memory Stone
7
+
8
+ pi-memory-stone indexes session turns into a searchable SQLite+FTS5 database (`~/.pi/agent/memory/memory.db`) and auto-injects relevant past memories into the system prompt before each agent turn.
9
+
10
+ **Quick check:** `/memory-status` and `/memory-search <query>`.
11
+
12
+ ## Tools
13
+
14
+ ### `memory_search(query, kind?, scope?, limit?)` — find past context
15
+
16
+ Use **before** answering questions that may have been addressed in past sessions. FTS5-based: include concrete keywords, not abstract concepts. Use `kind` filter for precision: `"decision"`, `"error_resolution"`, `"preference"`, `"task"`, `"turn_summary"`, `"session_summary"`. Use `scope: "global"` for cross-project recall.
17
+
18
+ **Examples:** `memory_search("database schema user authentication")`, `memory_search("Next.js build error", kind: "error_resolution")`, `memory_search("deployment workflow", scope: "global")`.
19
+
20
+ ### `memory_open(ref)` — full record by ID
21
+
22
+ Use when an injection packet or search result references a record ID (e.g., `ref=abc123`). Returns full text and metadata. Only shows records visible in current project.
23
+
24
+ ### `memory_remember(kind, text, scope?, tags?, importance?)` — explicit storage
25
+
26
+ Use **only when the user explicitly asks** to remember something. Defaults to `scope: "project"`. Use `scope: "global"` only when the user says "for all projects". Auto-downgrades to project scope if text appears to contain secrets, hostnames, or internal details.
27
+
28
+ ### `memory_forget(ref, hard?)` — remove stale/wrong memories
29
+
30
+ Soft-forget hides from future searches. Hard delete (`hard: true`) requires user confirmation via the `/memory-forget --hard` command.
31
+
32
+ ## Ranking (craft better queries)
33
+
34
+ Hybrid score: FTS match × kind boost × recency decay × confidence × importance, then sorted descending.
35
+
36
+ | Factor | Boost | Implication |
37
+ |---|---|---|
38
+ | Same-project | ×1.5 | Current project memories rank highest |
39
+ | Kind: decision | ×1.5 | Decisions and errors surface prominently |
40
+ | Kind: error_resolution | ×1.4 | |
41
+ | Kind: preference | ×1.3 | |
42
+ | Recency | Half-life 7 days | Older memories fade; add time hints to queries |
43
+ | Confidence × Importance | 0.5–1.5× | Explicitly remembered items outrank auto-indexed turns |
44
+
45
+ Queries include the user's first ~200 chars + basenames of recently touched files. Include filenames when searching for file-specific context.
46
+
47
+ ## Injection modes
48
+
49
+ | Mode | Behavior |
50
+ |---|---|
51
+ | `auto` (default) | FTS5 search every turn + manual refs. Score threshold: 0.3. Auto-injected refs not re-injected (anti-feedback-loop). |
52
+ | `manual` | Only `/memory-inject` refs. Inject every turn until `/memory-clear-injected`. |
53
+
54
+ Session override (`/memory-mode`) takes precedence over config. Check: `/memory-status`.
55
+
56
+ ## Session state (per-session, persists across turns)
57
+
58
+ - **enabled** — toggle via `/memory-on` / `/memory-off`
59
+ - **injectionMode** — `auto` or `manual`, via `/memory-mode`, falls back to config
60
+ - **manualRefs** — via `/memory-inject <ref>`, cleared by `/memory-clear-injected`
61
+
62
+ ## Commands quick reference
63
+
64
+ | Command | Purpose |
65
+ |---|---|
66
+ | `/memory-status [-v]` | Stats, config, effective mode |
67
+ | `/memory-search <q>` | Search (top 20) |
68
+ | `/memory-open <ref>` | Full record |
69
+ | `/memory-inject <ref>...` | Add manual refs for session |
70
+ | `/memory-clear-injected` | Clear manual refs |
71
+ | `/memory-mode <auto\|manual>` | Override injection mode |
72
+ | `/memory-last` | Last injection packet |
73
+ | `/memory-forget <ref> [--hard]` | Soft/hard delete |
74
+ | `/memory-export [path] [--format json\|md] [--all]` | Portable export |
75
+ | `/memory-import <file> [--preserve-project\|--global]` | Import (default: remap to current project) |
76
+ | `/memory-backup [path]` | Backup SQLite DB |
77
+ | `/memory-on` / `/memory-off` | Enable/disable injection |
78
+
79
+ ## Troubleshooting
80
+
81
+ | Problem | Likely cause | Fix |
82
+ |---|---|---|
83
+ | No memories injected | Score < 0.3 threshold, manual mode, or `/memory-off` | Lower threshold, `/memory-mode auto`, or `/memory-on` |
84
+ | Cross-project memories missing | `crossProjectEnabled: false` in config | Enable in `.pi/settings.json` or `/memory-inject` manually |
85
+ | Search returns nothing | Keywords don't match FTS5, or record soft_forgotten | Use concrete terms; try `/memory-export --all` to find |
86
+ | Global `memory_remember` refused | Text matched as sensitive | The tool auto-downgrades safely; this is expected |
87
+
88
+ ## Config
89
+
90
+ `.pi/settings.json` in project root (all fields optional, shown with defaults):
91
+
92
+ ```json
93
+ { "memory": { "enabled": true, "maxInjectedRecords": 5, "maxInjectedTokens": 1000, "scoreThreshold": 0.3, "crossProjectEnabled": false, "injectionMode": "auto" } }
94
+ ```
95
+
96
+ ## Privacy guarantees
97
+
98
+ - All text redacted before storage: API keys, tokens, passwords, connection strings
99
+ - Sensitive paths never indexed: `.env`, keys, certs, `node_modules`, `.git`, `.ssh`, `.aws`
100
+ - Global scope refused for text containing secret/hostname/internal patterns
101
+ - `memory_open` only shows records visible in current project
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Commands: /memory-status, /memory-search, /memory-open, /memory-inject, /memory-mode, /memory-last,
3
- * /memory-export, /memory-import, /memory-backup
3
+ * /memory-export, /memory-import, /memory-backup, /memory-vault-*
4
4
  */
5
5
 
6
6
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
@@ -24,6 +24,15 @@ import {
24
24
  isRecordVisibleInProject,
25
25
  parseRefArgs,
26
26
  } from "../session-state/index.js";
27
+ import {
28
+ getVaultStatus,
29
+ initVault,
30
+ parseVaultScope,
31
+ syncVault,
32
+ type VaultScope,
33
+ } from "../vault/index.js";
34
+ import { captureUrlToVault } from "../vault/capture.js";
35
+ import { extractFirstUrl } from "../vault/intent.js";
27
36
 
28
37
  export function registerCommands(pi: ExtensionAPI): void {
29
38
  // ── /memory-status ──────────────────────────────────────────────
@@ -198,6 +207,64 @@ export function registerCommands(pi: ExtensionAPI): void {
198
207
  },
199
208
  });
200
209
 
210
+ // ── /memory-vault-* ─────────────────────────────────────────────
211
+
212
+ pi.registerCommand("memory-vault-init", {
213
+ description: "Initialize an Obsidian-compatible memory vault",
214
+ handler: async (args, ctx) => {
215
+ await handleMemoryVaultInit(args, ctx);
216
+ },
217
+ });
218
+
219
+ pi.registerCommand("stone-vault-init", {
220
+ description: "Alias for /memory-vault-init",
221
+ handler: async (args, ctx) => {
222
+ await handleMemoryVaultInit(args, ctx);
223
+ },
224
+ });
225
+
226
+ pi.registerCommand("memory-vault-sync", {
227
+ description: "Sync active memory records into the initialized markdown vault",
228
+ handler: async (args, ctx) => {
229
+ await handleMemoryVaultSync(args, ctx);
230
+ },
231
+ });
232
+
233
+ pi.registerCommand("stone-vault-sync", {
234
+ description: "Alias for /memory-vault-sync",
235
+ handler: async (args, ctx) => {
236
+ await handleMemoryVaultSync(args, ctx);
237
+ },
238
+ });
239
+
240
+ pi.registerCommand("memory-vault-status", {
241
+ description: "Show memory vault path, initialization, and sync status",
242
+ handler: async (args, ctx) => {
243
+ await handleMemoryVaultStatus(args, ctx);
244
+ },
245
+ });
246
+
247
+ pi.registerCommand("memory-vault-capture-url", {
248
+ description: "Capture a web page URL into the memory vault",
249
+ handler: async (args, ctx) => {
250
+ await handleMemoryVaultCaptureUrl(args, ctx);
251
+ },
252
+ });
253
+
254
+ pi.registerCommand("stone-vault-status", {
255
+ description: "Alias for /memory-vault-status",
256
+ handler: async (args, ctx) => {
257
+ await handleMemoryVaultStatus(args, ctx);
258
+ },
259
+ });
260
+
261
+ pi.registerCommand("stone-vault-capture-url", {
262
+ description: "Alias for /memory-vault-capture-url",
263
+ handler: async (args, ctx) => {
264
+ await handleMemoryVaultCaptureUrl(args, ctx);
265
+ },
266
+ });
267
+
201
268
  // ── /memory-on / /memory-off ────────────────────────────────────
202
269
 
203
270
  pi.registerCommand("memory-on", {
@@ -481,6 +548,98 @@ async function handleMemoryBackup(args: string, ctx: ExtensionCommandContext): P
481
548
  }
482
549
  }
483
550
 
551
+ async function handleMemoryVaultInit(args: string, ctx: ExtensionCommandContext): Promise<void> {
552
+ const parsed = parseCommandArgs(args);
553
+ const scope = parseVaultScopeOrNotify(parsed, ctx);
554
+ if (!scope) return;
555
+
556
+ try {
557
+ const projectId = getProjectId(ctx.cwd);
558
+ const result = initVault(scope, projectId, ctx.cwd);
559
+ ctx.ui.notify(
560
+ result.created
561
+ ? `Initialized ${scope} memory vault at ${result.path}`
562
+ : `${scope} memory vault already initialized at ${result.path}`,
563
+ "info",
564
+ );
565
+ } catch (err) {
566
+ ctx.ui.notify(`Memory vault init failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
567
+ }
568
+ }
569
+
570
+ async function handleMemoryVaultSync(args: string, ctx: ExtensionCommandContext): Promise<void> {
571
+ const parsed = parseCommandArgs(args);
572
+ const scope = parseVaultScopeOrNotify(parsed, ctx);
573
+ if (!scope) return;
574
+
575
+ try {
576
+ const projectId = getProjectId(ctx.cwd);
577
+ const result = syncVault(scope, projectId, ctx.cwd);
578
+ ctx.ui.notify(
579
+ `Synced ${result.records} memory records to ${result.path} (${result.pagesWritten} page${result.pagesWritten === 1 ? "" : "s"} written). Registry: ${result.registryPath}`,
580
+ "info",
581
+ );
582
+ } catch (err) {
583
+ ctx.ui.notify(`Memory vault sync failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
584
+ }
585
+ }
586
+
587
+ async function handleMemoryVaultStatus(args: string, ctx: ExtensionCommandContext): Promise<void> {
588
+ const parsed = parseCommandArgs(args);
589
+ const scope = parseVaultScopeOrNotify(parsed, ctx);
590
+ if (!scope) return;
591
+
592
+ const projectId = getProjectId(ctx.cwd);
593
+ const status = getVaultStatus(scope, projectId, ctx.cwd);
594
+ const lines: string[] = [];
595
+ lines.push("📚 Memory Vault Status");
596
+ lines.push("");
597
+ lines.push(` scope: ${scope}`);
598
+ lines.push(` path: ${status.path}`);
599
+ lines.push(` initialized: ${status.initialized}`);
600
+ lines.push(` registry: ${status.registryExists ? "present" : "missing"}`);
601
+ lines.push(` markdown pages: ${status.pageCount}`);
602
+ lines.push(` synced record pages: ${status.recordPageCount}`);
603
+ lines.push(` last sync: ${status.lastSyncedAt ?? "never"}`);
604
+ ctx.ui.notify(lines.join("\n"), "info");
605
+ }
606
+
607
+ async function handleMemoryVaultCaptureUrl(args: string, ctx: ExtensionCommandContext): Promise<void> {
608
+ const parsed = parseCommandArgs(args);
609
+ const scope = parseVaultScopeOrNotify(parsed, ctx);
610
+ if (!scope) return;
611
+
612
+ const url = extractFirstUrl(args);
613
+ if (!url) {
614
+ ctx.ui.notify("Usage: /memory-vault-capture-url <http-url> [--project|--personal]", "warning");
615
+ return;
616
+ }
617
+
618
+ try {
619
+ const projectId = getProjectId(ctx.cwd);
620
+ const result = await captureUrlToVault(scope, projectId, ctx.cwd, url);
621
+ const warnings = result.warnings.length > 0 ? `\nWarnings: ${result.warnings.join("; ")}` : "";
622
+ ctx.ui.notify(
623
+ `Captured ${result.title} into ${scope} memory vault${result.initialized ? " (initialized vault)" : ""}\nQuality: ${result.quality} (${result.qualityScore})${warnings}\nPage: ${result.pagePath}`,
624
+ "info",
625
+ );
626
+ } catch (err) {
627
+ ctx.ui.notify(`Memory vault URL capture failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
628
+ }
629
+ }
630
+
631
+ function parseVaultScopeOrNotify(
632
+ parsed: ReturnType<typeof parseCommandArgs>,
633
+ ctx: ExtensionCommandContext,
634
+ ): VaultScope | null {
635
+ const scope = parseVaultScope(parsed);
636
+ if (!scope) {
637
+ ctx.ui.notify("Usage: /memory-vault-<init|sync|status> [--project|--personal|--scope project|personal]", "warning");
638
+ return null;
639
+ }
640
+ return scope;
641
+ }
642
+
484
643
  async function handleMemoryForget(args: string, ctx: ExtensionCommandContext): Promise<void> {
485
644
  const { softForgetRecord, hardDeleteRecord, getRecord } = await import("../db/index.js");
486
645
 
@@ -500,6 +659,12 @@ async function handleMemoryForget(args: string, ctx: ExtensionCommandContext): P
500
659
  continue;
501
660
  }
502
661
 
662
+ const currentProjectId = getProjectId(ctx.cwd);
663
+ if (record.status !== "active" || !isRecordVisibleInProject(record, currentProjectId)) {
664
+ ctx.ui.notify(`Memory record ${refId} is not available.`, "warning");
665
+ continue;
666
+ }
667
+
503
668
  if (hardFlag) {
504
669
  const confirmed = ctx.hasUI
505
670
  ? await ctx.ui.confirm(
@@ -544,7 +709,7 @@ function parseCommandArgs(args: string): {
544
709
  }
545
710
 
546
711
  const next = parts[i + 1];
547
- if (next && !next.startsWith("--") && ["format"].includes(withoutPrefix)) {
712
+ if (next && !next.startsWith("--") && ["format", "scope"].includes(withoutPrefix)) {
548
713
  options.set(withoutPrefix, next);
549
714
  i += 1;
550
715
  } else {
package/src/db/index.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { DatabaseSync } from "node:sqlite";
7
- import { existsSync, mkdirSync } from "node:fs";
7
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
8
8
  import { dirname } from "node:path";
9
9
  import { createHash, randomUUID } from "node:crypto";
10
10
  import { redactSecrets } from "../privacy/index.js";
@@ -41,17 +41,38 @@ export function getDb(): DatabaseSync {
41
41
  if (!_db) {
42
42
  const dbDir = getDbDir();
43
43
  if (!existsSync(dbDir)) {
44
- mkdirSync(dbDir, { recursive: true });
44
+ mkdirSync(dbDir, { recursive: true, mode: 0o700 });
45
45
  }
46
+ hardenPathPermissions(dbDir, 0o700);
46
47
  _db = new DatabaseSync(getDbPath());
48
+ hardenDbFilePermissions();
47
49
  _db.exec("PRAGMA journal_mode = WAL");
48
50
  _db.exec("PRAGMA busy_timeout = 5000");
49
51
  _db.exec("PRAGMA foreign_keys = ON");
50
52
  runMigrations(_db);
53
+ hardenDbFilePermissions();
51
54
  }
52
55
  return _db;
53
56
  }
54
57
 
58
+ function hardenPathPermissions(path: string, mode: number): void {
59
+ try {
60
+ chmodSync(path, mode);
61
+ } catch {
62
+ // Best-effort only: do not make memory unavailable on filesystems that
63
+ // do not support POSIX permissions.
64
+ }
65
+ }
66
+
67
+ export function hardenDbFilePermissions(): void {
68
+ const dbPath = getDbPath();
69
+ for (const path of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
70
+ if (existsSync(path)) {
71
+ hardenPathPermissions(path, 0o600);
72
+ }
73
+ }
74
+ }
75
+
55
76
  export function closeDb(): void {
56
77
  if (_db) {
57
78
  try {
@@ -253,6 +274,9 @@ export function upsertRecord(record: {
253
274
  const updatedAt = record.updated_at ?? now;
254
275
  const scope = record.scope ?? "project";
255
276
  const projectId = scope === "global" ? null : (record.project_id ?? null);
277
+ if (scope === "project" && !projectId) {
278
+ throw new Error("Project-scoped memory records require a project_id");
279
+ }
256
280
  const redactedText = redactSecrets(record.text);
257
281
  const redactedTags = record.tags ? redactSecrets(record.tags) : null;
258
282
  const id = recordIdentityHash(redactedText, record.kind, scope, projectId);
@@ -363,6 +387,8 @@ export function searchRecordsFts(
363
387
 
364
388
  if (!terms) return [];
365
389
 
390
+ const safeLimit = Math.max(1, Math.min(200, Number.isFinite(limit) ? Math.floor(limit) : 20));
391
+
366
392
  const results = db
367
393
  .prepare(
368
394
  `SELECT r.*, fts.rank as rank
@@ -372,7 +398,7 @@ export function searchRecordsFts(
372
398
  ORDER BY rank
373
399
  LIMIT ?`
374
400
  )
375
- .all(terms, limit) as unknown as (RecordRow & { rank: number })[];
401
+ .all(terms, safeLimit) as unknown as (RecordRow & { rank: number })[];
376
402
 
377
403
  // Apply post-filters
378
404
  return results.filter((r) => {