pi-memory-stone 0.1.2 → 0.1.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/README.md +53 -7
- package/package.json +1 -1
- package/src/commands/index.ts +326 -4
- package/src/config/index.ts +7 -0
- package/src/db/index.ts +15 -3
- package/src/index.ts +34 -51
- package/src/portable/index.ts +204 -0
- package/src/session-state/index.ts +88 -0
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ pi-memory-stone package source
|
|
|
48
48
|
│ ├── tools/ # LLM-callable tools
|
|
49
49
|
│ ├── privacy/ # Secret redaction, sensitive path filtering
|
|
50
50
|
│ └── config/ # Project identity, settings
|
|
51
|
-
└── test/ #
|
|
51
|
+
└── test/ # 51 tests across 6 test files
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
## How It Works
|
|
@@ -69,10 +69,12 @@ Every time the agent finishes a response, the extension:
|
|
|
69
69
|
Before each agent turn, the extension:
|
|
70
70
|
|
|
71
71
|
- Builds a focused query from the user's prompt
|
|
72
|
-
-
|
|
72
|
+
- Uses `memory.injectionMode` to choose automatic or manual injection
|
|
73
|
+
- In `auto` mode, searches records via FTS5
|
|
73
74
|
- Ranks by hybrid score: FTS match + same-project boost + recency decay + kind weight + confidence
|
|
74
|
-
- Injects top results (max 5, threshold-limited)
|
|
75
|
-
-
|
|
75
|
+
- Injects top results (max 5, threshold-limited) plus any refs selected with `/memory-inject`
|
|
76
|
+
- In `manual` mode, skips search and injects only refs selected with `/memory-inject`
|
|
77
|
+
- Tracks auto-injected refs to prevent feedback loops
|
|
76
78
|
- Logs every injection for audit via `/memory-last`
|
|
77
79
|
|
|
78
80
|
### 3. Storage Model
|
|
@@ -102,9 +104,18 @@ Before each agent turn, the extension:
|
|
|
102
104
|
| `/memory-status` | `/stone-status` | Show index statistics, record counts by kind, config |
|
|
103
105
|
| `/memory-status --verbose` | | Include per-kind record breakdown |
|
|
104
106
|
| `/memory-search <query>` | `/stone-search` | Search memory for relevant records |
|
|
107
|
+
| `/memory-open <id>` | `/stone-open` | Open a specific memory record by reference ID |
|
|
108
|
+
| `/memory-inject <id> [id ...]` | `/stone-inject` | Manually inject specific refs into future turns this session |
|
|
109
|
+
| `/memory-clear-injected` | `/stone-clear-injected` | Clear manually injected refs for this session |
|
|
110
|
+
| `/memory-mode <auto\|manual>` | `/stone-mode` | Override injection mode for this session |
|
|
105
111
|
| `/memory-last` | `/stone-last` | Show the last memory injection packet |
|
|
106
112
|
| `/memory-forget <id>` | `/stone-forget` | Soft-forget a record (hide from searches) |
|
|
107
113
|
| `/memory-forget <id> --hard` | | Permanently delete (with confirmation) |
|
|
114
|
+
| `/memory-export [path] --format json\|md` | `/stone-export` | Export active records to a portable JSON or Markdown file (`--all` includes inactive records) |
|
|
115
|
+
| `/memory-import <memory-export.json>` | `/stone-import` | Import records from a JSON export, remapping project-scoped records to the current project by default |
|
|
116
|
+
| `/memory-import <memory-export.json> --preserve-project` | | Import records while preserving exported project IDs |
|
|
117
|
+
| `/memory-import <memory-export.json> --global` | | Import all records as global memories |
|
|
118
|
+
| `/memory-backup [path]` | `/stone-backup` | Copy the SQLite memory database to a timestamped backup file |
|
|
108
119
|
| `/memory-on` | | Enable memory injection for this session |
|
|
109
120
|
| `/memory-off` | | Disable memory injection for this session |
|
|
110
121
|
|
|
@@ -154,6 +165,29 @@ Parameters:
|
|
|
154
165
|
hard? Request permanent deletion (requires confirmation)
|
|
155
166
|
```
|
|
156
167
|
|
|
168
|
+
## Portable Export, Import, and Backup
|
|
169
|
+
|
|
170
|
+
Use JSON export/import when you want a portable, reviewable memory transfer between machines:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
/memory-export --format json
|
|
174
|
+
/memory-import memory-export.json
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Markdown export is for human review outside SQLite:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
/memory-export memory-export.md --format md
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Use a database backup before pruning or hard deletion:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
/memory-backup
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
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
|
+
|
|
157
191
|
## Privacy & Safety
|
|
158
192
|
|
|
159
193
|
### Secret Redaction
|
|
@@ -226,14 +260,16 @@ npm run typecheck
|
|
|
226
260
|
|
|
227
261
|
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`.
|
|
228
262
|
|
|
229
|
-
|
|
263
|
+
51 tests across 6 test files:
|
|
230
264
|
|
|
231
265
|
| Suite | Tests | Focus |
|
|
232
266
|
|---|---|---|
|
|
233
267
|
| `indexing.test.ts` | 1 | Incremental session indexing |
|
|
234
|
-
| `privacy.test.ts` | 17 | Secret redaction, sensitive path filtering |
|
|
235
268
|
| `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 |
|
|
236
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 |
|
|
237
273
|
|
|
238
274
|
## Configuration
|
|
239
275
|
|
|
@@ -246,11 +282,21 @@ Project settings in `.pi/settings.json`:
|
|
|
246
282
|
"maxInjectedRecords": 5,
|
|
247
283
|
"maxInjectedTokens": 1000,
|
|
248
284
|
"scoreThreshold": 0.3,
|
|
249
|
-
"crossProjectEnabled": false
|
|
285
|
+
"crossProjectEnabled": false,
|
|
286
|
+
"injectionMode": "auto"
|
|
250
287
|
}
|
|
251
288
|
}
|
|
252
289
|
```
|
|
253
290
|
|
|
291
|
+
`injectionMode` accepts:
|
|
292
|
+
|
|
293
|
+
| Value | Behavior |
|
|
294
|
+
|---|---|
|
|
295
|
+
| `auto` | Default. Automatically searches relevant memories and also includes refs selected with `/memory-inject`. |
|
|
296
|
+
| `manual` | Disables automatic search-based injection. Only refs selected with `/memory-inject` are injected. |
|
|
297
|
+
|
|
298
|
+
For manual-only memory, keep `enabled: true` and set `injectionMode: "manual"`.
|
|
299
|
+
|
|
254
300
|
## Deferred (Future Slices)
|
|
255
301
|
|
|
256
302
|
- LLM extraction (decisions, preferences, tasks)
|
package/package.json
CHANGED
package/src/commands/index.ts
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Commands: /memory-status, /memory-search, /memory-last
|
|
2
|
+
* Commands: /memory-status, /memory-search, /memory-open, /memory-inject, /memory-mode, /memory-last,
|
|
3
|
+
* /memory-export, /memory-import, /memory-backup
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { getStats, getLastInjection } from "../db/index.js";
|
|
7
|
+
import { getStats, getLastInjection, getRecord } from "../db/index.js";
|
|
7
8
|
import { retrieve } from "../retrieval/index.js";
|
|
8
9
|
import { getProjectId, getConfig } from "../config/index.js";
|
|
10
|
+
import {
|
|
11
|
+
backupMemoryDatabase,
|
|
12
|
+
defaultPortablePath,
|
|
13
|
+
importMemoryJsonFile,
|
|
14
|
+
resolvePortablePath,
|
|
15
|
+
writeMemoryExport,
|
|
16
|
+
type ExportFormat,
|
|
17
|
+
} from "../portable/index.js";
|
|
18
|
+
import {
|
|
19
|
+
INJECTION_MODE_ENTRY,
|
|
20
|
+
MANUAL_INJECTION_ENTRY,
|
|
21
|
+
SESSION_TOGGLE_ENTRY,
|
|
22
|
+
getMemorySessionState,
|
|
23
|
+
isInjectionMode,
|
|
24
|
+
isRecordVisibleInProject,
|
|
25
|
+
parseRefArgs,
|
|
26
|
+
} from "../session-state/index.js";
|
|
9
27
|
|
|
10
28
|
export function registerCommands(pi: ExtensionAPI): void {
|
|
11
29
|
// ── /memory-status ──────────────────────────────────────────────
|
|
@@ -40,6 +58,70 @@ export function registerCommands(pi: ExtensionAPI): void {
|
|
|
40
58
|
},
|
|
41
59
|
});
|
|
42
60
|
|
|
61
|
+
// ── /memory-open ────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
pi.registerCommand("memory-open", {
|
|
64
|
+
description: "Open a specific memory record by reference ID",
|
|
65
|
+
handler: async (args, ctx) => {
|
|
66
|
+
await handleMemoryOpen(args, ctx);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
pi.registerCommand("stone-open", {
|
|
71
|
+
description: "Alias for /memory-open",
|
|
72
|
+
handler: async (args, ctx) => {
|
|
73
|
+
await handleMemoryOpen(args, ctx);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── /memory-inject / /memory-clear-injected ────────────────────
|
|
78
|
+
|
|
79
|
+
pi.registerCommand("memory-inject", {
|
|
80
|
+
description: "Manually inject specific memory refs into future turns",
|
|
81
|
+
handler: async (args, ctx) => {
|
|
82
|
+
await handleMemoryInject(args, ctx, pi);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
pi.registerCommand("stone-inject", {
|
|
87
|
+
description: "Alias for /memory-inject",
|
|
88
|
+
handler: async (args, ctx) => {
|
|
89
|
+
await handleMemoryInject(args, ctx, pi);
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
pi.registerCommand("memory-clear-injected", {
|
|
94
|
+
description: "Clear manually injected memory refs for this session",
|
|
95
|
+
handler: async (_args, ctx) => {
|
|
96
|
+
pi.appendEntry(MANUAL_INJECTION_ENTRY, { action: "clear" });
|
|
97
|
+
ctx.ui.notify("Cleared manually injected memory refs for this session", "info");
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
pi.registerCommand("stone-clear-injected", {
|
|
102
|
+
description: "Alias for /memory-clear-injected",
|
|
103
|
+
handler: async (_args, ctx) => {
|
|
104
|
+
pi.appendEntry(MANUAL_INJECTION_ENTRY, { action: "clear" });
|
|
105
|
+
ctx.ui.notify("Cleared manually injected memory refs for this session", "info");
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── /memory-mode ────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
pi.registerCommand("memory-mode", {
|
|
112
|
+
description: "Set memory injection mode for this session: auto or manual",
|
|
113
|
+
handler: async (args, ctx) => {
|
|
114
|
+
await handleMemoryMode(args, ctx, pi);
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
pi.registerCommand("stone-mode", {
|
|
119
|
+
description: "Alias for /memory-mode",
|
|
120
|
+
handler: async (args, ctx) => {
|
|
121
|
+
await handleMemoryMode(args, ctx, pi);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
43
125
|
// ── /memory-last ────────────────────────────────────────────────
|
|
44
126
|
|
|
45
127
|
pi.registerCommand("memory-last", {
|
|
@@ -72,6 +154,50 @@ export function registerCommands(pi: ExtensionAPI): void {
|
|
|
72
154
|
},
|
|
73
155
|
});
|
|
74
156
|
|
|
157
|
+
// ── /memory-export / /memory-import / /memory-backup ───────────
|
|
158
|
+
|
|
159
|
+
pi.registerCommand("memory-export", {
|
|
160
|
+
description: "Export active memory records to JSON or Markdown",
|
|
161
|
+
handler: async (args, ctx) => {
|
|
162
|
+
await handleMemoryExport(args, ctx);
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
pi.registerCommand("stone-export", {
|
|
167
|
+
description: "Alias for /memory-export",
|
|
168
|
+
handler: async (args, ctx) => {
|
|
169
|
+
await handleMemoryExport(args, ctx);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
pi.registerCommand("memory-import", {
|
|
174
|
+
description: "Import memory records from a JSON export",
|
|
175
|
+
handler: async (args, ctx) => {
|
|
176
|
+
await handleMemoryImport(args, ctx);
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
pi.registerCommand("stone-import", {
|
|
181
|
+
description: "Alias for /memory-import",
|
|
182
|
+
handler: async (args, ctx) => {
|
|
183
|
+
await handleMemoryImport(args, ctx);
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
pi.registerCommand("memory-backup", {
|
|
188
|
+
description: "Copy the SQLite memory database to a timestamped backup file",
|
|
189
|
+
handler: async (args, ctx) => {
|
|
190
|
+
await handleMemoryBackup(args, ctx);
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
pi.registerCommand("stone-backup", {
|
|
195
|
+
description: "Alias for /memory-backup",
|
|
196
|
+
handler: async (args, ctx) => {
|
|
197
|
+
await handleMemoryBackup(args, ctx);
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
75
201
|
// ── /memory-on / /memory-off ────────────────────────────────────
|
|
76
202
|
|
|
77
203
|
pi.registerCommand("memory-on", {
|
|
@@ -79,7 +205,7 @@ export function registerCommands(pi: ExtensionAPI): void {
|
|
|
79
205
|
handler: async (_args, ctx) => {
|
|
80
206
|
ctx.ui.notify("Memory injection enabled for this session", "info");
|
|
81
207
|
// Session toggle — stored in extension state
|
|
82
|
-
pi.appendEntry(
|
|
208
|
+
pi.appendEntry(SESSION_TOGGLE_ENTRY, { enabled: true });
|
|
83
209
|
},
|
|
84
210
|
});
|
|
85
211
|
|
|
@@ -87,7 +213,7 @@ export function registerCommands(pi: ExtensionAPI): void {
|
|
|
87
213
|
description: "Disable memory injection for this session",
|
|
88
214
|
handler: async (_args, ctx) => {
|
|
89
215
|
ctx.ui.notify("Memory injection disabled for this session", "info");
|
|
90
|
-
pi.appendEntry(
|
|
216
|
+
pi.appendEntry(SESSION_TOGGLE_ENTRY, { enabled: false });
|
|
91
217
|
},
|
|
92
218
|
});
|
|
93
219
|
}
|
|
@@ -119,7 +245,11 @@ async function handleMemoryStatus(args: string, ctx: ExtensionCommandContext): P
|
|
|
119
245
|
lines.push(` enabled: ${config.enabled}`);
|
|
120
246
|
lines.push(` maxInjectedRecords: ${config.maxInjectedRecords}`);
|
|
121
247
|
lines.push(` maxInjectedTokens: ${config.maxInjectedTokens}`);
|
|
248
|
+
const sessionState = getMemorySessionState(ctx.sessionManager.getBranch());
|
|
122
249
|
lines.push(` crossProjectEnabled: ${config.crossProjectEnabled}`);
|
|
250
|
+
lines.push(` injectionMode: ${config.injectionMode}`);
|
|
251
|
+
lines.push(` effectiveInjectionMode: ${sessionState.injectionMode ?? config.injectionMode}`);
|
|
252
|
+
lines.push(` manuallyInjectedRefs: ${sessionState.manualRefs.length > 0 ? sessionState.manualRefs.join(", ") : "none"}`);
|
|
123
253
|
|
|
124
254
|
if (ctx.hasUI) {
|
|
125
255
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
@@ -169,6 +299,102 @@ async function handleMemorySearch(args: string, ctx: ExtensionCommandContext): P
|
|
|
169
299
|
}
|
|
170
300
|
}
|
|
171
301
|
|
|
302
|
+
async function handleMemoryOpen(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
303
|
+
const refId = args?.trim().split(/\s+/).filter(Boolean)[0];
|
|
304
|
+
|
|
305
|
+
if (!refId) {
|
|
306
|
+
ctx.ui.notify("Usage: /memory-open <ref-id>", "warning");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const record = getRecord(refId);
|
|
311
|
+
if (!record) {
|
|
312
|
+
ctx.ui.notify(`Memory record ${refId} not found.`, "warning");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const currentProjectId = getProjectId(ctx.cwd);
|
|
317
|
+
if (record.status !== "active" || !isRecordVisibleInProject(record, currentProjectId)) {
|
|
318
|
+
ctx.ui.notify(`Memory record ${refId} is not available.`, "warning");
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const lines: string[] = [];
|
|
323
|
+
lines.push(`Memory Record: ${record.id}`);
|
|
324
|
+
lines.push(`Kind: ${record.kind}`);
|
|
325
|
+
lines.push(`Scope: ${record.scope}`);
|
|
326
|
+
lines.push(`Project: ${record.project_id ?? "global"}`);
|
|
327
|
+
lines.push(`Created: ${new Date(record.created_at).toISOString()}`);
|
|
328
|
+
lines.push(`Status: ${record.status}`);
|
|
329
|
+
lines.push("");
|
|
330
|
+
lines.push(record.text);
|
|
331
|
+
|
|
332
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function handleMemoryInject(args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
|
|
336
|
+
const refIds = parseRefArgs(args);
|
|
337
|
+
|
|
338
|
+
if (refIds.length === 0) {
|
|
339
|
+
ctx.ui.notify("Usage: /memory-inject <ref-id> [ref-id ...]", "warning");
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const currentProjectId = getProjectId(ctx.cwd);
|
|
344
|
+
const acceptedRefs: string[] = [];
|
|
345
|
+
const rejectedRefs: string[] = [];
|
|
346
|
+
|
|
347
|
+
for (const refId of refIds) {
|
|
348
|
+
const record = getRecord(refId);
|
|
349
|
+
if (record && record.status === "active" && isRecordVisibleInProject(record, currentProjectId)) {
|
|
350
|
+
acceptedRefs.push(refId);
|
|
351
|
+
} else {
|
|
352
|
+
rejectedRefs.push(refId);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (acceptedRefs.length > 0) {
|
|
357
|
+
pi.appendEntry(MANUAL_INJECTION_ENTRY, { action: "add", refs: acceptedRefs });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const lines: string[] = [];
|
|
361
|
+
if (acceptedRefs.length > 0) {
|
|
362
|
+
lines.push(`Manually injected memory refs for this session: ${acceptedRefs.join(", ")}`);
|
|
363
|
+
}
|
|
364
|
+
if (rejectedRefs.length > 0) {
|
|
365
|
+
lines.push(`Unavailable refs skipped: ${rejectedRefs.join(", ")}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
ctx.ui.notify(lines.join("\n") || "No memory refs were injected.", acceptedRefs.length > 0 ? "info" : "warning");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function handleMemoryMode(args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
|
|
372
|
+
const mode = args?.trim().split(/\s+/).filter(Boolean)[0];
|
|
373
|
+
const config = getConfig(ctx.cwd);
|
|
374
|
+
const sessionState = getMemorySessionState(ctx.sessionManager.getBranch());
|
|
375
|
+
|
|
376
|
+
if (!mode) {
|
|
377
|
+
ctx.ui.notify(
|
|
378
|
+
`Memory injection mode: ${sessionState.injectionMode ?? config.injectionMode}\nUsage: /memory-mode <auto|manual>`,
|
|
379
|
+
"info",
|
|
380
|
+
);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (!isInjectionMode(mode)) {
|
|
385
|
+
ctx.ui.notify("Usage: /memory-mode <auto|manual>", "warning");
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
pi.appendEntry(INJECTION_MODE_ENTRY, { mode });
|
|
390
|
+
ctx.ui.notify(
|
|
391
|
+
mode === "manual"
|
|
392
|
+
? "Memory injection mode set to manual for this session. Use /memory-inject <ref-id> to choose memories."
|
|
393
|
+
: "Memory injection mode set to auto for this session.",
|
|
394
|
+
"info",
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
172
398
|
async function handleMemoryLast(ctx: ExtensionCommandContext): Promise<void> {
|
|
173
399
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
174
400
|
const last = getLastInjection(sessionId);
|
|
@@ -195,6 +421,66 @@ async function handleMemoryLast(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
195
421
|
}
|
|
196
422
|
}
|
|
197
423
|
|
|
424
|
+
async function handleMemoryExport(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
425
|
+
const parsed = parseCommandArgs(args);
|
|
426
|
+
const formatValue = parsed.options.get("format") ?? (parsed.flags.has("md") ? "md" : "json");
|
|
427
|
+
if (formatValue !== "json" && formatValue !== "md") {
|
|
428
|
+
ctx.ui.notify("Usage: /memory-export [path] [--format json|md] [--all]", "warning");
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const format = formatValue as ExportFormat;
|
|
433
|
+
const outputPath = resolvePortablePath(
|
|
434
|
+
ctx.cwd,
|
|
435
|
+
parsed.positionals[0] ?? defaultPortablePath(ctx.cwd, "memory-export", format),
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
try {
|
|
439
|
+
const count = writeMemoryExport(outputPath, format, parsed.flags.has("all"));
|
|
440
|
+
ctx.ui.notify(`Exported ${count} memory records to ${outputPath}`, "info");
|
|
441
|
+
} catch (err) {
|
|
442
|
+
ctx.ui.notify(`Memory export failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function handleMemoryImport(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
447
|
+
const parsed = parseCommandArgs(args);
|
|
448
|
+
const inputPathArg = parsed.positionals[0];
|
|
449
|
+
if (!inputPathArg) {
|
|
450
|
+
ctx.ui.notify("Usage: /memory-import <memory-export.json> [--preserve-project|--global]", "warning");
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const inputPath = resolvePortablePath(ctx.cwd, inputPathArg);
|
|
455
|
+
try {
|
|
456
|
+
const result = importMemoryJsonFile(inputPath, {
|
|
457
|
+
projectId: parsed.flags.has("preserve-project") ? undefined : getProjectId(ctx.cwd),
|
|
458
|
+
scopeOverride: parsed.flags.has("global") ? "global" : undefined,
|
|
459
|
+
});
|
|
460
|
+
ctx.ui.notify(
|
|
461
|
+
`Imported ${result.imported} memory records from ${inputPath}${result.skipped ? ` (${result.skipped} skipped)` : ""}`,
|
|
462
|
+
"info",
|
|
463
|
+
);
|
|
464
|
+
} catch (err) {
|
|
465
|
+
ctx.ui.notify(`Memory import failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function handleMemoryBackup(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
470
|
+
const parsed = parseCommandArgs(args);
|
|
471
|
+
const outputPath = resolvePortablePath(
|
|
472
|
+
ctx.cwd,
|
|
473
|
+
parsed.positionals[0] ?? defaultPortablePath(ctx.cwd, "memory-backup", "db"),
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
backupMemoryDatabase(outputPath);
|
|
478
|
+
ctx.ui.notify(`Backed up memory database to ${outputPath}`, "info");
|
|
479
|
+
} catch (err) {
|
|
480
|
+
ctx.ui.notify(`Memory backup failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
198
484
|
async function handleMemoryForget(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
199
485
|
const { softForgetRecord, hardDeleteRecord, getRecord } = await import("../db/index.js");
|
|
200
486
|
|
|
@@ -232,3 +518,39 @@ async function handleMemoryForget(args: string, ctx: ExtensionCommandContext): P
|
|
|
232
518
|
}
|
|
233
519
|
}
|
|
234
520
|
}
|
|
521
|
+
|
|
522
|
+
function parseCommandArgs(args: string): {
|
|
523
|
+
flags: Set<string>;
|
|
524
|
+
options: Map<string, string>;
|
|
525
|
+
positionals: string[];
|
|
526
|
+
} {
|
|
527
|
+
const flags = new Set<string>();
|
|
528
|
+
const options = new Map<string, string>();
|
|
529
|
+
const positionals: string[] = [];
|
|
530
|
+
const parts = args?.trim().split(/\s+/).filter(Boolean) ?? [];
|
|
531
|
+
|
|
532
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
533
|
+
const part = parts[i];
|
|
534
|
+
if (!part.startsWith("--")) {
|
|
535
|
+
positionals.push(part);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const withoutPrefix = part.slice(2);
|
|
540
|
+
const eqIndex = withoutPrefix.indexOf("=");
|
|
541
|
+
if (eqIndex >= 0) {
|
|
542
|
+
options.set(withoutPrefix.slice(0, eqIndex), withoutPrefix.slice(eqIndex + 1));
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const next = parts[i + 1];
|
|
547
|
+
if (next && !next.startsWith("--") && ["format"].includes(withoutPrefix)) {
|
|
548
|
+
options.set(withoutPrefix, next);
|
|
549
|
+
i += 1;
|
|
550
|
+
} else {
|
|
551
|
+
flags.add(withoutPrefix);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return { flags, options, positionals };
|
|
556
|
+
}
|
package/src/config/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { execSync } from "node:child_process";
|
|
|
8
8
|
import { existsSync, readFileSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
|
+
import type { InjectionMode } from "../session-state/index.js";
|
|
11
12
|
|
|
12
13
|
// ─── Project identity ───────────────────────────────────────────────
|
|
13
14
|
|
|
@@ -51,6 +52,8 @@ export interface MemoryConfig {
|
|
|
51
52
|
scoreThreshold: number;
|
|
52
53
|
/** Whether cross-project injection is enabled */
|
|
53
54
|
crossProjectEnabled: boolean;
|
|
55
|
+
/** Search-based automatic injection, or only explicit /memory-inject refs */
|
|
56
|
+
injectionMode: InjectionMode;
|
|
54
57
|
/** Extra ignore patterns */
|
|
55
58
|
ignorePatterns: string[];
|
|
56
59
|
}
|
|
@@ -61,6 +64,7 @@ const DEFAULT_CONFIG: MemoryConfig = {
|
|
|
61
64
|
maxInjectedTokens: 1000,
|
|
62
65
|
scoreThreshold: 0.3,
|
|
63
66
|
crossProjectEnabled: false,
|
|
67
|
+
injectionMode: "auto",
|
|
64
68
|
ignorePatterns: [],
|
|
65
69
|
};
|
|
66
70
|
|
|
@@ -80,6 +84,9 @@ export function getConfig(cwd: string = process.cwd()): MemoryConfig {
|
|
|
80
84
|
const settings = JSON.parse(raw);
|
|
81
85
|
if (settings.memory) {
|
|
82
86
|
const config = { ...DEFAULT_CONFIG, ...settings.memory };
|
|
87
|
+
if (config.injectionMode !== "auto" && config.injectionMode !== "manual") {
|
|
88
|
+
config.injectionMode = DEFAULT_CONFIG.injectionMode;
|
|
89
|
+
}
|
|
83
90
|
_configCache.set(cacheKey, config);
|
|
84
91
|
return config;
|
|
85
92
|
}
|
package/src/db/index.ts
CHANGED
|
@@ -242,11 +242,15 @@ export function upsertRecord(record: {
|
|
|
242
242
|
status?: RecordStatus;
|
|
243
243
|
confidence?: number;
|
|
244
244
|
importance?: number;
|
|
245
|
+
created_at?: number;
|
|
246
|
+
updated_at?: number;
|
|
245
247
|
superseded_by?: string | null;
|
|
246
248
|
derived_from_memory_refs?: string | null;
|
|
247
249
|
}): string {
|
|
248
250
|
const db = getDb();
|
|
249
251
|
const now = Date.now();
|
|
252
|
+
const createdAt = record.created_at ?? now;
|
|
253
|
+
const updatedAt = record.updated_at ?? now;
|
|
250
254
|
const scope = record.scope ?? "project";
|
|
251
255
|
const projectId = scope === "global" ? null : (record.project_id ?? null);
|
|
252
256
|
const redactedText = redactSecrets(record.text);
|
|
@@ -276,7 +280,7 @@ export function upsertRecord(record: {
|
|
|
276
280
|
redactedTags,
|
|
277
281
|
record.confidence ?? 1.0,
|
|
278
282
|
record.importance ?? 0.5,
|
|
279
|
-
|
|
283
|
+
updatedAt,
|
|
280
284
|
record.status ?? existing.status,
|
|
281
285
|
record.superseded_by ?? null,
|
|
282
286
|
record.derived_from_memory_refs ?? null,
|
|
@@ -303,8 +307,8 @@ export function upsertRecord(record: {
|
|
|
303
307
|
record.status ?? "active",
|
|
304
308
|
record.confidence ?? 1.0,
|
|
305
309
|
record.importance ?? 0.5,
|
|
306
|
-
|
|
307
|
-
|
|
310
|
+
createdAt,
|
|
311
|
+
updatedAt,
|
|
308
312
|
record.superseded_by ?? null,
|
|
309
313
|
record.derived_from_memory_refs ?? null,
|
|
310
314
|
);
|
|
@@ -331,6 +335,14 @@ export function getRecord(id: string): RecordRow | undefined {
|
|
|
331
335
|
);
|
|
332
336
|
}
|
|
333
337
|
|
|
338
|
+
export function listRecords(options: { includeInactive?: boolean } = {}): RecordRow[] {
|
|
339
|
+
const db = getDb();
|
|
340
|
+
const sql = options.includeInactive
|
|
341
|
+
? "SELECT * FROM records ORDER BY created_at ASC, id ASC"
|
|
342
|
+
: "SELECT * FROM records WHERE status = 'active' ORDER BY created_at ASC, id ASC";
|
|
343
|
+
return db.prepare(sql).all() as unknown as RecordRow[];
|
|
344
|
+
}
|
|
345
|
+
|
|
334
346
|
export function searchRecordsFts(
|
|
335
347
|
query: string,
|
|
336
348
|
limit = 20,
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - SQLite schema + migrations
|
|
9
9
|
* - Deterministic turn_summary and file_activity capture on agent_end
|
|
10
10
|
* - FTS5 search
|
|
11
|
-
* - /memory-status, /memory-search, /memory-last commands
|
|
11
|
+
* - /memory-status, /memory-search, /memory-open, /memory-inject, /memory-last commands
|
|
12
12
|
* - memory_search, memory_open, memory_remember, memory_forget tools
|
|
13
13
|
* - Conservative same-project before_agent_start injection
|
|
14
14
|
*/
|
|
@@ -19,7 +19,8 @@ import { registerTools } from "./tools/index.js";
|
|
|
19
19
|
import { indexSessionOnAgentEnd } from "./indexing/index.js";
|
|
20
20
|
import { retrieve, buildInjectionPacket, formatInjectionForLlm } from "./retrieval/index.js";
|
|
21
21
|
import { getProjectId, getConfig, clearProjectCache } from "./config/index.js";
|
|
22
|
-
import { closeDb } from "./db/index.js";
|
|
22
|
+
import { closeDb, getRecord, insertInjection } from "./db/index.js";
|
|
23
|
+
import { getMemorySessionState, manualRecordsToRankedResults } from "./session-state/index.js";
|
|
23
24
|
import { createHash } from "node:crypto";
|
|
24
25
|
|
|
25
26
|
// ─── Session-scoped state ───────────────────────────────────────────
|
|
@@ -72,62 +73,54 @@ export default function (pi: ExtensionAPI) {
|
|
|
72
73
|
const config = getConfig(ctx.cwd);
|
|
73
74
|
if (!config.enabled) return;
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
for (const entry of ctx.sessionManager.getBranch()) {
|
|
78
|
-
if (
|
|
79
|
-
entry.type === "custom" &&
|
|
80
|
-
entry.customType === "memory-stone:session-toggle"
|
|
81
|
-
) {
|
|
82
|
-
const data = entry.data as { enabled?: boolean } | undefined;
|
|
83
|
-
if (typeof data?.enabled === "boolean") {
|
|
84
|
-
sessionEnabled = data.enabled;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
76
|
+
const sessionState = getMemorySessionState(ctx.sessionManager.getBranch());
|
|
77
|
+
sessionEnabled = sessionState.enabled;
|
|
88
78
|
|
|
89
79
|
if (!sessionEnabled) return;
|
|
90
80
|
|
|
91
81
|
const prompt = event.prompt || "";
|
|
92
|
-
if (!prompt.trim()) return;
|
|
93
|
-
|
|
94
|
-
// Hash prompt to detect repeated injections
|
|
95
82
|
const promptHash = createHash("sha256").update(prompt).digest("hex").slice(0, 12);
|
|
96
|
-
|
|
97
83
|
const projectId = getProjectId(ctx.cwd);
|
|
84
|
+
const injectionMode = sessionState.injectionMode ?? config.injectionMode;
|
|
85
|
+
|
|
86
|
+
const manualRecords = sessionState.manualRefs
|
|
87
|
+
.map((ref) => getRecord(ref))
|
|
88
|
+
.filter((record): record is NonNullable<typeof record> => Boolean(record));
|
|
89
|
+
const manualResults = manualRecordsToRankedResults(manualRecords, projectId);
|
|
90
|
+
const manualRefSet = new Set(manualResults.map((r) => r.record.id));
|
|
91
|
+
|
|
92
|
+
let autoResults: ReturnType<typeof retrieve> = [];
|
|
93
|
+
if (injectionMode === "auto" && prompt.trim()) {
|
|
94
|
+
const results = retrieve(prompt, projectId, [], {
|
|
95
|
+
limit: config.maxInjectedRecords,
|
|
96
|
+
crossProjectEnabled: config.crossProjectEnabled,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
autoResults = results
|
|
100
|
+
.filter((r) => !manualRefSet.has(r.record.id))
|
|
101
|
+
.filter((r) => !injectedRefsThisSession.has(r.record.id))
|
|
102
|
+
.filter((r) => r.score >= config.scoreThreshold);
|
|
103
|
+
}
|
|
98
104
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
limit: config.maxInjectedRecords,
|
|
102
|
-
crossProjectEnabled: config.crossProjectEnabled,
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
// Filter: skip already-injected refs
|
|
106
|
-
const newResults = results.filter((r) => !injectedRefsThisSession.has(r.record.id));
|
|
107
|
-
|
|
108
|
-
// Score threshold
|
|
109
|
-
const thresholdResults = newResults.filter((r) => r.score >= config.scoreThreshold);
|
|
110
|
-
|
|
111
|
-
if (thresholdResults.length === 0) return;
|
|
105
|
+
const selectedResults = [...manualResults, ...autoResults];
|
|
106
|
+
if (selectedResults.length === 0) return;
|
|
112
107
|
|
|
113
|
-
|
|
114
|
-
const packet = buildInjectionPacket(thresholdResults);
|
|
108
|
+
const packet = buildInjectionPacket(selectedResults);
|
|
115
109
|
const formatted = formatInjectionForLlm(packet, config.maxInjectedTokens);
|
|
116
110
|
|
|
117
|
-
// Track
|
|
118
|
-
|
|
111
|
+
// Track only search-selected refs. Manually chosen refs are intentionally
|
|
112
|
+
// injected on every turn until /memory-clear-injected is used.
|
|
113
|
+
for (const r of autoResults) {
|
|
119
114
|
injectedRefsThisSession.add(r.record.id);
|
|
120
115
|
}
|
|
121
116
|
|
|
122
|
-
// Log injection to DB
|
|
123
|
-
const { insertInjection } = await import("./db/index.js");
|
|
124
117
|
insertInjection({
|
|
125
118
|
session_id: ctx.sessionManager.getSessionId(),
|
|
126
119
|
turn_entry_id: ctx.sessionManager.getLeafId() ?? undefined,
|
|
127
120
|
prompt_hash: promptHash,
|
|
128
|
-
injected_refs:
|
|
121
|
+
injected_refs: selectedResults.map((r) => r.record.id).join(","),
|
|
129
122
|
packet: formatted,
|
|
130
|
-
reasons:
|
|
123
|
+
reasons: selectedResults.map((r) => r.reasons.join(";")).join(" | "),
|
|
131
124
|
});
|
|
132
125
|
|
|
133
126
|
// Inject as a non-context audit custom entry (separate from LLM context)
|
|
@@ -156,17 +149,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
156
149
|
sessionEnabled = true;
|
|
157
150
|
|
|
158
151
|
// Restore session toggle from branch
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
entry.type === "custom" &&
|
|
162
|
-
entry.customType === "memory-stone:session-toggle"
|
|
163
|
-
) {
|
|
164
|
-
const data = entry.data as { enabled?: boolean } | undefined;
|
|
165
|
-
if (typeof data?.enabled === "boolean") {
|
|
166
|
-
sessionEnabled = data.enabled;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
152
|
+
sessionEnabled = getMemorySessionState(ctx.sessionManager.getBranch()).enabled;
|
|
170
153
|
|
|
171
154
|
// Clear project ID cache on session change
|
|
172
155
|
clearProjectCache();
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable export/import/backup helpers for memory records.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
7
|
+
import { getDb, getDbPath, listRecords, upsertRecord, type RecordRow } from "../db/index.js";
|
|
8
|
+
import { SCHEMA_VERSION, RECORD_KINDS, RECORD_SCOPES, RECORD_STATUSES, type RecordKind, type RecordScope, type RecordStatus } from "../db/schema.js";
|
|
9
|
+
|
|
10
|
+
export type ExportFormat = "json" | "md";
|
|
11
|
+
|
|
12
|
+
export interface PortableMemoryRecord {
|
|
13
|
+
id: string;
|
|
14
|
+
kind: RecordKind;
|
|
15
|
+
scope: RecordScope;
|
|
16
|
+
project_id: string | null;
|
|
17
|
+
text: string;
|
|
18
|
+
tags: string | null;
|
|
19
|
+
status: RecordStatus;
|
|
20
|
+
confidence: number;
|
|
21
|
+
importance: number;
|
|
22
|
+
created_at: number;
|
|
23
|
+
updated_at: number;
|
|
24
|
+
superseded_by: string | null;
|
|
25
|
+
derived_from_memory_refs: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PortableMemoryExport {
|
|
29
|
+
format: "pi-memory-stone-export";
|
|
30
|
+
version: 1;
|
|
31
|
+
exported_at: string;
|
|
32
|
+
schema_version: number;
|
|
33
|
+
records: PortableMemoryRecord[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ImportOptions {
|
|
37
|
+
/** Remap project-scoped records to this project id. Use undefined to preserve exported project ids. */
|
|
38
|
+
projectId?: string | null;
|
|
39
|
+
/** Force every imported record into a scope. */
|
|
40
|
+
scopeOverride?: RecordScope;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ImportResult {
|
|
44
|
+
imported: number;
|
|
45
|
+
skipped: number;
|
|
46
|
+
ids: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildMemoryExport(includeInactive = false): PortableMemoryExport {
|
|
50
|
+
return {
|
|
51
|
+
format: "pi-memory-stone-export",
|
|
52
|
+
version: 1,
|
|
53
|
+
exported_at: new Date().toISOString(),
|
|
54
|
+
schema_version: SCHEMA_VERSION,
|
|
55
|
+
records: listRecords({ includeInactive }).map(toPortableRecord),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function exportMemory(format: ExportFormat, includeInactive = false): string {
|
|
60
|
+
const payload = buildMemoryExport(includeInactive);
|
|
61
|
+
if (format === "json") {
|
|
62
|
+
return JSON.stringify(payload, null, 2) + "\n";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return exportMarkdown(payload);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function writeMemoryExport(path: string, format: ExportFormat, includeInactive = false): number {
|
|
69
|
+
const payload = buildMemoryExport(includeInactive);
|
|
70
|
+
const content = format === "json" ? JSON.stringify(payload, null, 2) + "\n" : exportMarkdown(payload);
|
|
71
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
72
|
+
writeFileSync(path, content, "utf8");
|
|
73
|
+
return payload.records.length;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function importMemoryJsonFile(path: string, options: ImportOptions = {}): ImportResult {
|
|
77
|
+
const raw = readFileSync(path, "utf8");
|
|
78
|
+
return importMemoryJson(raw, options);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function importMemoryJson(raw: string, options: ImportOptions = {}): ImportResult {
|
|
82
|
+
const parsed = JSON.parse(raw) as Partial<PortableMemoryExport>;
|
|
83
|
+
if (parsed.format !== "pi-memory-stone-export" || parsed.version !== 1 || !Array.isArray(parsed.records)) {
|
|
84
|
+
throw new Error("Unsupported memory export file. Expected pi-memory-stone-export version 1 JSON.");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const result: ImportResult = { imported: 0, skipped: 0, ids: [] };
|
|
88
|
+
for (const candidate of parsed.records) {
|
|
89
|
+
const record = normalizePortableRecord(candidate);
|
|
90
|
+
if (!record) {
|
|
91
|
+
result.skipped += 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const scope = options.scopeOverride ?? record.scope;
|
|
96
|
+
const projectId = scope === "global" ? null : (options.projectId !== undefined ? options.projectId : record.project_id);
|
|
97
|
+
const id = upsertRecord({
|
|
98
|
+
kind: record.kind,
|
|
99
|
+
scope,
|
|
100
|
+
project_id: projectId,
|
|
101
|
+
text: record.text,
|
|
102
|
+
tags: record.tags,
|
|
103
|
+
status: record.status,
|
|
104
|
+
confidence: record.confidence,
|
|
105
|
+
importance: record.importance,
|
|
106
|
+
created_at: record.created_at,
|
|
107
|
+
updated_at: record.updated_at,
|
|
108
|
+
superseded_by: record.superseded_by,
|
|
109
|
+
derived_from_memory_refs: record.derived_from_memory_refs,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
result.imported += 1;
|
|
113
|
+
result.ids.push(id);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function backupMemoryDatabase(path: string): void {
|
|
120
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
121
|
+
getDb().exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
122
|
+
copyFileSync(getDbPath(), path);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function defaultPortablePath(cwd: string, prefix: string, extension: string): string {
|
|
126
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
127
|
+
return resolve(cwd, `${prefix}-${stamp}.${extension}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function resolvePortablePath(cwd: string, path: string): string {
|
|
131
|
+
return isAbsolute(path) ? path : resolve(cwd, path);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function toPortableRecord(row: RecordRow): PortableMemoryRecord {
|
|
135
|
+
return {
|
|
136
|
+
id: row.id,
|
|
137
|
+
kind: row.kind,
|
|
138
|
+
scope: row.scope,
|
|
139
|
+
project_id: row.project_id,
|
|
140
|
+
text: row.text,
|
|
141
|
+
tags: row.tags,
|
|
142
|
+
status: row.status,
|
|
143
|
+
confidence: row.confidence,
|
|
144
|
+
importance: row.importance,
|
|
145
|
+
created_at: row.created_at,
|
|
146
|
+
updated_at: row.updated_at,
|
|
147
|
+
superseded_by: row.superseded_by,
|
|
148
|
+
derived_from_memory_refs: row.derived_from_memory_refs,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function exportMarkdown(payload: PortableMemoryExport): string {
|
|
153
|
+
const lines: string[] = [];
|
|
154
|
+
lines.push("# Memory Stone Export");
|
|
155
|
+
lines.push("");
|
|
156
|
+
lines.push(`Exported: ${payload.exported_at}`);
|
|
157
|
+
lines.push(`Records: ${payload.records.length}`);
|
|
158
|
+
lines.push("");
|
|
159
|
+
|
|
160
|
+
for (const record of payload.records) {
|
|
161
|
+
lines.push(`## [${record.kind}] ${record.id}`);
|
|
162
|
+
lines.push("");
|
|
163
|
+
lines.push(`- Scope: ${record.scope}`);
|
|
164
|
+
lines.push(`- Project: ${record.project_id ?? "global"}`);
|
|
165
|
+
lines.push(`- Status: ${record.status}`);
|
|
166
|
+
lines.push(`- Importance: ${record.importance}`);
|
|
167
|
+
lines.push(`- Created: ${new Date(record.created_at).toISOString()}`);
|
|
168
|
+
if (record.tags) lines.push(`- Tags: ${record.tags}`);
|
|
169
|
+
lines.push("");
|
|
170
|
+
lines.push(record.text);
|
|
171
|
+
lines.push("");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return lines.join("\n");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function normalizePortableRecord(candidate: unknown): PortableMemoryRecord | null {
|
|
178
|
+
if (!candidate || typeof candidate !== "object") return null;
|
|
179
|
+
const r = candidate as Record<string, unknown>;
|
|
180
|
+
if (typeof r.text !== "string" || r.text.trim() === "") return null;
|
|
181
|
+
if (!isStringMember(r.kind, RECORD_KINDS)) return null;
|
|
182
|
+
if (!isStringMember(r.scope, RECORD_SCOPES)) return null;
|
|
183
|
+
if (!isStringMember(r.status, RECORD_STATUSES)) return null;
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
id: typeof r.id === "string" ? r.id : "",
|
|
187
|
+
kind: r.kind,
|
|
188
|
+
scope: r.scope,
|
|
189
|
+
project_id: typeof r.project_id === "string" ? r.project_id : null,
|
|
190
|
+
text: r.text,
|
|
191
|
+
tags: typeof r.tags === "string" ? r.tags : null,
|
|
192
|
+
status: r.status,
|
|
193
|
+
confidence: typeof r.confidence === "number" ? r.confidence : 1,
|
|
194
|
+
importance: typeof r.importance === "number" ? r.importance : 0.5,
|
|
195
|
+
created_at: typeof r.created_at === "number" ? r.created_at : Date.now(),
|
|
196
|
+
updated_at: typeof r.updated_at === "number" ? r.updated_at : Date.now(),
|
|
197
|
+
superseded_by: typeof r.superseded_by === "string" ? r.superseded_by : null,
|
|
198
|
+
derived_from_memory_refs: typeof r.derived_from_memory_refs === "string" ? r.derived_from_memory_refs : null,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isStringMember<T extends readonly string[]>(value: unknown, allowed: T): value is T[number] {
|
|
203
|
+
return typeof value === "string" && (allowed as readonly string[]).includes(value);
|
|
204
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { RecordRow } from "../db/index.js";
|
|
2
|
+
import type { RankedResult } from "../retrieval/index.js";
|
|
3
|
+
|
|
4
|
+
export type InjectionMode = "auto" | "manual";
|
|
5
|
+
|
|
6
|
+
export const SESSION_TOGGLE_ENTRY = "memory-stone:session-toggle";
|
|
7
|
+
export const INJECTION_MODE_ENTRY = "memory-stone:injection-mode";
|
|
8
|
+
export const MANUAL_INJECTION_ENTRY = "memory-stone:manual-injection";
|
|
9
|
+
|
|
10
|
+
export interface MemorySessionState {
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
injectionMode?: InjectionMode;
|
|
13
|
+
manualRefs: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isInjectionMode(value: unknown): value is InjectionMode {
|
|
17
|
+
return value === "auto" || value === "manual";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getMemorySessionState(branch: unknown[]): MemorySessionState {
|
|
21
|
+
let enabled = true;
|
|
22
|
+
let injectionMode: InjectionMode | undefined;
|
|
23
|
+
let manualRefs: string[] = [];
|
|
24
|
+
|
|
25
|
+
for (const entry of branch) {
|
|
26
|
+
if (!isCustomEntry(entry)) continue;
|
|
27
|
+
|
|
28
|
+
if (entry.customType === SESSION_TOGGLE_ENTRY) {
|
|
29
|
+
const data = entry.data as { enabled?: unknown } | undefined;
|
|
30
|
+
if (typeof data?.enabled === "boolean") {
|
|
31
|
+
enabled = data.enabled;
|
|
32
|
+
}
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (entry.customType === INJECTION_MODE_ENTRY) {
|
|
37
|
+
const data = entry.data as { mode?: unknown } | undefined;
|
|
38
|
+
if (isInjectionMode(data?.mode)) {
|
|
39
|
+
injectionMode = data.mode;
|
|
40
|
+
}
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (entry.customType === MANUAL_INJECTION_ENTRY) {
|
|
45
|
+
const data = entry.data as { action?: unknown; refs?: unknown } | undefined;
|
|
46
|
+
if (data?.action === "clear") {
|
|
47
|
+
manualRefs = [];
|
|
48
|
+
} else if (data?.action === "add" && Array.isArray(data.refs)) {
|
|
49
|
+
for (const ref of data.refs) {
|
|
50
|
+
if (typeof ref === "string" && ref.trim() && !manualRefs.includes(ref)) {
|
|
51
|
+
manualRefs.push(ref);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { enabled, injectionMode, manualRefs };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function parseRefArgs(args: string): string[] {
|
|
62
|
+
return (args ?? "")
|
|
63
|
+
.trim()
|
|
64
|
+
.split(/\s+/)
|
|
65
|
+
.map((part) => part.trim())
|
|
66
|
+
.filter((part) => part.length > 0 && !part.startsWith("--"));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isRecordVisibleInProject(record: RecordRow, currentProjectId: string | null): boolean {
|
|
70
|
+
return record.scope === "global" || record.project_id === null || record.project_id === currentProjectId;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function manualRecordsToRankedResults(
|
|
74
|
+
records: RecordRow[],
|
|
75
|
+
currentProjectId: string | null,
|
|
76
|
+
): RankedResult[] {
|
|
77
|
+
return records
|
|
78
|
+
.filter((record) => record.status === "active" && isRecordVisibleInProject(record, currentProjectId))
|
|
79
|
+
.map((record) => ({
|
|
80
|
+
record,
|
|
81
|
+
score: Number.POSITIVE_INFINITY,
|
|
82
|
+
reasons: ["manual-ref"],
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isCustomEntry(entry: unknown): entry is { type?: string; customType?: string; data?: unknown } {
|
|
87
|
+
return typeof entry === "object" && entry !== null && (entry as { type?: unknown }).type === "custom";
|
|
88
|
+
}
|