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 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/ # 44 tests across 4 suites
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
- - Searches records via FTS5
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) as system prompt context
75
- - Tracks injected refs to prevent feedback loops
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
- 44 tests across 4 suites:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-memory-stone",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "Global pi extension: preserves and retrieves useful memory across pi sessions",
6
6
  "license": "MIT",
@@ -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("memory-stone:session-toggle", { enabled: true });
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("memory-stone:session-toggle", { enabled: false });
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
+ }
@@ -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
- now,
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
- now,
307
- now,
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
- // Check for session toggle entries before honoring the cached toggle so
76
- // /memory-on can re-enable injection after /memory-off in the same session.
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
- // Build search query
100
- const results = retrieve(prompt, projectId, [], {
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
- // Build and format injection packet
114
- const packet = buildInjectionPacket(thresholdResults);
108
+ const packet = buildInjectionPacket(selectedResults);
115
109
  const formatted = formatInjectionForLlm(packet, config.maxInjectedTokens);
116
110
 
117
- // Track injected refs (prevent feedback loop)
118
- for (const r of thresholdResults) {
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: thresholdResults.map((r) => r.record.id).join(","),
121
+ injected_refs: selectedResults.map((r) => r.record.id).join(","),
129
122
  packet: formatted,
130
- reasons: thresholdResults.map((r) => r.reasons.join(";")).join(" | "),
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
- for (const entry of ctx.sessionManager.getBranch()) {
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
+ }