pi-memory-stone 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/skills/pi-memory-stone/SKILL.md +101 -0
- package/src/commands/index.ts +6 -0
- package/src/db/index.ts +29 -3
- 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/package.json
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-memory-stone",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Global pi extension: preserves and retrieves useful memory across pi sessions",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": ["pi-package"],
|
|
8
8
|
"files": [
|
|
9
9
|
"src",
|
|
10
|
+
"skills/**/*",
|
|
10
11
|
"README.md",
|
|
11
12
|
"LICENSE"
|
|
12
13
|
],
|
|
13
14
|
"pi": {
|
|
14
|
-
"extensions": ["./src/index.ts"]
|
|
15
|
+
"extensions": ["./src/index.ts"],
|
|
16
|
+
"skills": ["./skills"]
|
|
15
17
|
},
|
|
16
18
|
"scripts": {
|
|
17
19
|
"test": "NODE_OPTIONS='--experimental-sqlite' tsx --test test/*.test.ts",
|
|
@@ -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
|
@@ -500,6 +500,12 @@ async function handleMemoryForget(args: string, ctx: ExtensionCommandContext): P
|
|
|
500
500
|
continue;
|
|
501
501
|
}
|
|
502
502
|
|
|
503
|
+
const currentProjectId = getProjectId(ctx.cwd);
|
|
504
|
+
if (record.status !== "active" || !isRecordVisibleInProject(record, currentProjectId)) {
|
|
505
|
+
ctx.ui.notify(`Memory record ${refId} is not available.`, "warning");
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
|
|
503
509
|
if (hardFlag) {
|
|
504
510
|
const confirmed = ctx.hasUI
|
|
505
511
|
? await ctx.ui.confirm(
|
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) => {
|
package/src/portable/index.ts
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Portable export/import/backup helpers for memory records.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, isAbsolute, resolve } from "node:path";
|
|
7
|
-
import { getDb, getDbPath, listRecords, upsertRecord, type RecordRow } from "../db/index.js";
|
|
7
|
+
import { getDb, getDbPath, hardenDbFilePermissions, listRecords, upsertRecord, type RecordRow } from "../db/index.js";
|
|
8
8
|
import { SCHEMA_VERSION, RECORD_KINDS, RECORD_SCOPES, RECORD_STATUSES, type RecordKind, type RecordScope, type RecordStatus } from "../db/schema.js";
|
|
9
|
+
import { isSensitiveForGlobalMemory } from "../privacy/index.js";
|
|
9
10
|
|
|
10
11
|
export type ExportFormat = "json" | "md";
|
|
11
12
|
|
|
@@ -68,8 +69,8 @@ export function exportMemory(format: ExportFormat, includeInactive = false): str
|
|
|
68
69
|
export function writeMemoryExport(path: string, format: ExportFormat, includeInactive = false): number {
|
|
69
70
|
const payload = buildMemoryExport(includeInactive);
|
|
70
71
|
const content = format === "json" ? JSON.stringify(payload, null, 2) + "\n" : exportMarkdown(payload);
|
|
71
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
72
|
-
writeFileSync(path, content, "utf8");
|
|
72
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
73
|
+
writeFileSync(path, content, { encoding: "utf8", mode: 0o600 });
|
|
73
74
|
return payload.records.length;
|
|
74
75
|
}
|
|
75
76
|
|
|
@@ -94,6 +95,14 @@ export function importMemoryJson(raw: string, options: ImportOptions = {}): Impo
|
|
|
94
95
|
|
|
95
96
|
const scope = options.scopeOverride ?? record.scope;
|
|
96
97
|
const projectId = scope === "global" ? null : (options.projectId !== undefined ? options.projectId : record.project_id);
|
|
98
|
+
if (scope === "project" && !projectId) {
|
|
99
|
+
result.skipped += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (scope === "global" && isSensitiveForGlobalMemory(`${record.text}\n${record.tags ?? ""}`)) {
|
|
103
|
+
result.skipped += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
97
106
|
const id = upsertRecord({
|
|
98
107
|
kind: record.kind,
|
|
99
108
|
scope,
|
|
@@ -117,9 +126,13 @@ export function importMemoryJson(raw: string, options: ImportOptions = {}): Impo
|
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
export function backupMemoryDatabase(path: string): void {
|
|
120
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
129
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
121
130
|
getDb().exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
131
|
+
hardenDbFilePermissions();
|
|
122
132
|
copyFileSync(getDbPath(), path);
|
|
133
|
+
try {
|
|
134
|
+
chmodSync(path, 0o600);
|
|
135
|
+
} catch {}
|
|
123
136
|
}
|
|
124
137
|
|
|
125
138
|
export function defaultPortablePath(cwd: string, prefix: string, extension: string): string {
|
package/src/privacy/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ const SECRET_PATTERNS: { name: string; regex: RegExp; replacement: SecretReplace
|
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
29
|
name: "aws-secret",
|
|
30
|
-
regex:
|
|
30
|
+
regex: /\b(?:aws[_-]?)?secret[_-]?access[_-]?key\b\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40,}['"]?/gi,
|
|
31
31
|
replacement: "[REDACTED:aws-secret]",
|
|
32
32
|
},
|
|
33
33
|
{
|
|
@@ -37,12 +37,12 @@ const SECRET_PATTERNS: { name: string; regex: RegExp; replacement: SecretReplace
|
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
name: "generic-api-key",
|
|
40
|
-
regex:
|
|
40
|
+
regex: /\b(?:api[_-]?key|apikey|api[_-]?secret|secret[_-]?key|client[_-]?secret|private[_-]?key|access[_-]?key|auth[_-]?key)\b\s*[=:]\s*['"]?[A-Za-z0-9_\-./+=]{16,}['"]?/gi,
|
|
41
41
|
replacement: "[REDACTED:api-key]",
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
44
|
name: "secret-assignment",
|
|
45
|
-
regex: /\b(?:secret|secret[_-]?key)\b\s*[=:]\s*(?:['"][^'"]+['"]|[^\s'"`]+)/gi,
|
|
45
|
+
regex: /\b(?:secret|secret[_-]?key|client[_-]?secret|app[_-]?secret|webhook[_-]?secret|signing[_-]?secret)\b\s*[=:]\s*(?:['"][^'"]+['"]|[^\s'"`]+)/gi,
|
|
46
46
|
replacement: "[REDACTED:secret]",
|
|
47
47
|
},
|
|
48
48
|
{
|
|
@@ -126,6 +126,22 @@ export function redactSecrets(text: string): string {
|
|
|
126
126
|
return result;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
export function isSensitiveForGlobalMemory(text: string): boolean {
|
|
130
|
+
if (redactSecrets(text) !== text) return true;
|
|
131
|
+
|
|
132
|
+
return [
|
|
133
|
+
// Local/absolute/relative filesystem paths and common repo paths.
|
|
134
|
+
/(?:^|\s)(?:~|\.|\.\.|[A-Za-z]:)?[/\\][^\s]+/,
|
|
135
|
+
/\b(?:src|lib|test|tests|packages|apps|docs|config)\/[\w./-]+\b/i,
|
|
136
|
+
/\b[\w.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|yaml|yml|toml|env|db|sqlite|pem|key|crt)\b/i,
|
|
137
|
+
// Hostnames and network endpoints.
|
|
138
|
+
/\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0|::1)\b/i,
|
|
139
|
+
/\b[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+(?::\d{2,5})?\b/i,
|
|
140
|
+
// Implementation/internal detail markers that should stay project-local.
|
|
141
|
+
/\b(?:internal|private|implementation detail|class|function|method|module|endpoint|schema|table|column)\b/i,
|
|
142
|
+
].some((pattern) => pattern.test(text));
|
|
143
|
+
}
|
|
144
|
+
|
|
129
145
|
export function isSensitivePath(path: string, extraPatterns: RegExp[] = []): boolean {
|
|
130
146
|
const allPatterns = [...DEFAULT_SENSITIVE_PATHS, ...extraPatterns];
|
|
131
147
|
|
package/src/retrieval/index.ts
CHANGED
|
@@ -23,6 +23,8 @@ const KIND_BOOST: Record<string, number> = {
|
|
|
23
23
|
// ─── Recency decay ──────────────────────────────────────────────────
|
|
24
24
|
|
|
25
25
|
const RECENCY_HALF_LIFE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
26
|
+
export const MAX_RETRIEVAL_LIMIT = 20;
|
|
27
|
+
const MAX_CANDIDATE_LIMIT = MAX_RETRIEVAL_LIMIT * 10;
|
|
26
28
|
|
|
27
29
|
function recencyDecay(createdAt: number): number {
|
|
28
30
|
const age = Date.now() - createdAt;
|
|
@@ -72,7 +74,7 @@ export function rankAndFilter(
|
|
|
72
74
|
// and require explicit cross-project retrieval.
|
|
73
75
|
if (rec.scope === "global") {
|
|
74
76
|
if (!crossProjectEnabled) continue;
|
|
75
|
-
} else if (rec.project_id
|
|
77
|
+
} else if (!rec.project_id || !currentProjectId || rec.project_id !== currentProjectId) {
|
|
76
78
|
continue;
|
|
77
79
|
}
|
|
78
80
|
|
|
@@ -121,6 +123,11 @@ export function rankAndFilter(
|
|
|
121
123
|
|
|
122
124
|
// ─── Full retrieval pipeline ────────────────────────────────────────
|
|
123
125
|
|
|
126
|
+
export function normalizeRetrievalLimit(value: unknown, fallback: number): number {
|
|
127
|
+
const numeric = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback;
|
|
128
|
+
return Math.max(1, Math.min(MAX_RETRIEVAL_LIMIT, numeric));
|
|
129
|
+
}
|
|
130
|
+
|
|
124
131
|
export function retrieve(
|
|
125
132
|
userPrompt: string,
|
|
126
133
|
currentProjectId: string | null,
|
|
@@ -133,13 +140,14 @@ export function retrieve(
|
|
|
133
140
|
},
|
|
134
141
|
): RankedResult[] {
|
|
135
142
|
const config = getConfig();
|
|
136
|
-
const limit = opts?.limit
|
|
143
|
+
const limit = normalizeRetrievalLimit(opts?.limit, config.maxInjectedRecords);
|
|
137
144
|
const crossProject = opts?.crossProjectEnabled ?? config.crossProjectEnabled;
|
|
138
145
|
|
|
139
146
|
const query = buildSearchQuery(userPrompt, recentFiles);
|
|
140
147
|
|
|
141
|
-
// Get more candidates than needed (ranking will filter)
|
|
142
|
-
const
|
|
148
|
+
// Get more candidates than needed (ranking will filter), but keep local work bounded.
|
|
149
|
+
const candidateLimit = Math.min(MAX_CANDIDATE_LIMIT, limit * 10);
|
|
150
|
+
const candidates = searchRecordsFts(query, candidateLimit, opts?.kindFilter, opts?.scopeFilter);
|
|
143
151
|
|
|
144
152
|
const ranked = rankAndFilter(candidates, currentProjectId, crossProject);
|
|
145
153
|
|
|
@@ -67,7 +67,8 @@ export function parseRefArgs(args: string): string[] {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
export function isRecordVisibleInProject(record: RecordRow, currentProjectId: string | null): boolean {
|
|
70
|
-
|
|
70
|
+
if (record.scope === "global") return true;
|
|
71
|
+
return Boolean(currentProjectId && record.project_id && record.project_id === currentProjectId);
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
export function manualRecordsToRankedResults(
|
package/src/tools/index.ts
CHANGED
|
@@ -6,8 +6,10 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
6
6
|
import { Type } from "typebox";
|
|
7
7
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
8
8
|
import { getRecord, softForgetRecord, upsertRecord } from "../db/index.js";
|
|
9
|
-
import { retrieve, buildInjectionPacket, formatInjectionForLlm } from "../retrieval/index.js";
|
|
9
|
+
import { retrieve, buildInjectionPacket, formatInjectionForLlm, normalizeRetrievalLimit } from "../retrieval/index.js";
|
|
10
10
|
import { getProjectId, getConfig } from "../config/index.js";
|
|
11
|
+
import { isSensitiveForGlobalMemory } from "../privacy/index.js";
|
|
12
|
+
import { isRecordVisibleInProject } from "../session-state/index.js";
|
|
11
13
|
import type { RecordKind, RecordScope } from "../db/schema.js";
|
|
12
14
|
|
|
13
15
|
export function registerTools(pi: ExtensionAPI): void {
|
|
@@ -37,12 +39,12 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
37
39
|
] as const),
|
|
38
40
|
),
|
|
39
41
|
scope: Type.Optional(StringEnum(["project", "global"] as const)),
|
|
40
|
-
limit: Type.Optional(Type.Number({ description: "Max results (default 5)" })),
|
|
42
|
+
limit: Type.Optional(Type.Number({ description: "Max results (default 5, max 20)", minimum: 1, maximum: 20 })),
|
|
41
43
|
}),
|
|
42
44
|
async execute(toolCallId, params, _signal, _onUpdate, ctx) {
|
|
43
45
|
const projectId = getProjectId(ctx.cwd);
|
|
44
46
|
const config = getConfig(ctx.cwd);
|
|
45
|
-
const limit = params.limit
|
|
47
|
+
const limit = normalizeRetrievalLimit(params.limit, 5);
|
|
46
48
|
|
|
47
49
|
const results = retrieve(params.query, projectId, [], {
|
|
48
50
|
limit,
|
|
@@ -102,10 +104,8 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
const currentProjectId = getProjectId(ctx.cwd);
|
|
105
|
-
const visibleInCurrentProject =
|
|
106
|
-
record.scope === "global" || record.project_id === null || record.project_id === currentProjectId;
|
|
107
107
|
|
|
108
|
-
if (record.status !== "active" || !
|
|
108
|
+
if (record.status !== "active" || !isRecordVisibleInProject(record, currentProjectId)) {
|
|
109
109
|
return {
|
|
110
110
|
content: [{ type: "text", text: `Memory record ${params.ref} is not available.` }],
|
|
111
111
|
details: { ref: params.ref, found: false, unavailable: true },
|
|
@@ -168,10 +168,7 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
168
168
|
let scope = params.scope ?? "project";
|
|
169
169
|
|
|
170
170
|
// Safety: never allow global for implementation details, paths, etc.
|
|
171
|
-
const isSensitiveForGlobal =
|
|
172
|
-
/\b(?:password|secret|token|key|\.env|localhost|127\.0\.0\.1|internal|private)\b/i.test(
|
|
173
|
-
params.text,
|
|
174
|
-
);
|
|
171
|
+
const isSensitiveForGlobal = isSensitiveForGlobalMemory(`${params.text}\n${params.tags ?? ""}`);
|
|
175
172
|
|
|
176
173
|
const downgradedToProject = isSensitiveForGlobal && scope === "global";
|
|
177
174
|
if (downgradedToProject) {
|
|
@@ -228,14 +225,22 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
228
225
|
};
|
|
229
226
|
}
|
|
230
227
|
|
|
228
|
+
const currentProjectId = getProjectId(ctx.cwd);
|
|
229
|
+
if (record.status !== "active" || !isRecordVisibleInProject(record, currentProjectId)) {
|
|
230
|
+
return {
|
|
231
|
+
content: [{ type: "text", text: `Memory record ${params.ref} is not available.` }],
|
|
232
|
+
details: { ref: params.ref, found: false, unavailable: true },
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
231
236
|
if (params.hard) {
|
|
232
237
|
// For hard delete via tool, we require the user to explicitly confirm
|
|
233
|
-
// The tool should note this requires user interaction
|
|
238
|
+
// The tool should note this requires user interaction without leaking record contents.
|
|
234
239
|
return {
|
|
235
240
|
content: [
|
|
236
241
|
{
|
|
237
242
|
type: "text",
|
|
238
|
-
text: `Permanent deletion requires explicit confirmation. Please use /memory-forget ${params.ref} --hard to permanently delete this record
|
|
243
|
+
text: `Permanent deletion requires explicit confirmation. Please use /memory-forget ${params.ref} --hard to permanently delete this record.`,
|
|
239
244
|
},
|
|
240
245
|
],
|
|
241
246
|
details: { ref: params.ref, requiresConfirmation: true },
|