pi-memory-stone 0.1.1 → 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/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
+ }
@@ -1,97 +0,0 @@
1
- /**
2
- * Tests for incremental session indexing.
3
- */
4
-
5
- import { describe, it, beforeEach, after } from "node:test";
6
- import assert from "node:assert/strict";
7
- import { existsSync, unlinkSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
- import { tmpdir } from "node:os";
9
- import { join } from "node:path";
10
- import { indexSessionOnAgentEnd } from "../src/indexing/index.js";
11
- import { closeDb, getDb, getDbPath } from "../src/db/index.js";
12
-
13
- const testMemoryDir = mkdtempSync(join(tmpdir(), "pi-memory-stone-indexing-"));
14
- process.env.PI_MEMORY_STONE_DB_PATH = join(testMemoryDir, "memory.db");
15
-
16
- function cleanDb() {
17
- const dbPath = getDbPath();
18
- closeDb();
19
- for (const f of [dbPath, dbPath + "-wal", dbPath + "-shm"]) {
20
- try { if (existsSync(f)) unlinkSync(f); } catch {}
21
- }
22
- }
23
-
24
- function makeUserEntry(id: string, text: string) {
25
- return {
26
- type: "message",
27
- id,
28
- parentId: null,
29
- message: {
30
- role: "user",
31
- content: text,
32
- timestamp: Date.now(),
33
- },
34
- };
35
- }
36
-
37
- function makeAssistantEntry(id: string, text: string) {
38
- return {
39
- type: "message",
40
- id,
41
- parentId: null,
42
- message: {
43
- role: "assistant",
44
- content: [{ type: "text", text }],
45
- timestamp: Date.now(),
46
- },
47
- };
48
- }
49
-
50
- function makeContext(sessionFile: string, branch: unknown[]) {
51
- return {
52
- cwd: testMemoryDir,
53
- sessionManager: {
54
- getSessionFile: () => sessionFile,
55
- getSessionId: () => "session-1",
56
- getBranch: () => branch,
57
- getLeafId: () => (branch.at(-1) as { id?: string } | undefined)?.id,
58
- },
59
- } as any;
60
- }
61
-
62
- describe("indexSessionOnAgentEnd", () => {
63
- beforeEach(() => cleanDb());
64
-
65
- after(() => {
66
- cleanDb();
67
- rmSync(testMemoryDir, { recursive: true, force: true });
68
- });
69
-
70
- it("indexes only entries after the last indexed entry when timestamps are absent", async () => {
71
- const sessionFile = join(testMemoryDir, "session.jsonl");
72
- writeFileSync(sessionFile, "");
73
-
74
- const firstBranch = [
75
- makeUserEntry("001", "First question"),
76
- makeAssistantEntry("002", "First answer"),
77
- ];
78
- const first = await indexSessionOnAgentEnd(makeContext(sessionFile, firstBranch), {});
79
- assert.equal(first.errors.length, 0);
80
-
81
- const fullBranch = [
82
- ...firstBranch,
83
- makeUserEntry("003", "Second question"),
84
- makeAssistantEntry("004", "Second answer"),
85
- ];
86
- const second = await indexSessionOnAgentEnd(makeContext(sessionFile, fullBranch), {});
87
- assert.equal(second.errors.length, 0);
88
-
89
- const rows = getDb()
90
- .prepare("SELECT text FROM records WHERE kind = 'turn_summary' ORDER BY created_at")
91
- .all() as Array<{ text: string }>;
92
-
93
- assert.equal(rows.length, 2);
94
- assert.ok(rows.some((r) => r.text.includes("First question")));
95
- assert.ok(rows.some((r) => r.text.includes("Second question")));
96
- });
97
- });