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 +60 -6
- package/package.json +16 -3
- package/skills/pi-memory-stone/SKILL.md +101 -0
- package/src/commands/index.ts +167 -2
- package/src/db/index.ts +29 -3
- package/src/index.ts +79 -29
- package/src/portable/index.ts +18 -5
- package/src/privacy/index.ts +19 -3
- package/src/retrieval/index.ts +12 -4
- package/src/session-state/index.ts +2 -1
- package/src/tools/index.ts +17 -12
- package/src/vault/capture.ts +268 -0
- package/src/vault/extract.ts +259 -0
- package/src/vault/fetch.ts +155 -0
- package/src/vault/index.ts +306 -0
- package/src/vault/intent.ts +37 -0
- package/src/vault/markdown.ts +120 -0
- package/src/vault/paths.ts +44 -0
- package/src/vault/quality.ts +65 -0
- package/src/vault/url-resolvers.ts +113 -0
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/ #
|
|
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
|
-
|
|
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` |
|
|
270
|
-
| `privacy.test.ts` |
|
|
271
|
-
| `ranking.test.ts` |
|
|
272
|
-
| `session-state.test.ts` |
|
|
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
|
+
"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": [
|
|
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": [
|
|
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
|
package/src/commands/index.ts
CHANGED
|
@@ -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,
|
|
401
|
+
.all(terms, safeLimit) as unknown as (RecordRow & { rank: number })[];
|
|
376
402
|
|
|
377
403
|
// Apply post-filters
|
|
378
404
|
return results.filter((r) => {
|