pi-memory-stone 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/db/index.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { DatabaseSync } from "node:sqlite";
7
- import { existsSync, mkdirSync } from "node:fs";
7
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
8
8
  import { dirname } from "node:path";
9
9
  import { createHash, randomUUID } from "node:crypto";
10
10
  import { redactSecrets } from "../privacy/index.js";
@@ -41,17 +41,38 @@ export function getDb(): DatabaseSync {
41
41
  if (!_db) {
42
42
  const dbDir = getDbDir();
43
43
  if (!existsSync(dbDir)) {
44
- mkdirSync(dbDir, { recursive: true });
44
+ mkdirSync(dbDir, { recursive: true, mode: 0o700 });
45
45
  }
46
+ hardenPathPermissions(dbDir, 0o700);
46
47
  _db = new DatabaseSync(getDbPath());
48
+ hardenDbFilePermissions();
47
49
  _db.exec("PRAGMA journal_mode = WAL");
48
50
  _db.exec("PRAGMA busy_timeout = 5000");
49
51
  _db.exec("PRAGMA foreign_keys = ON");
50
52
  runMigrations(_db);
53
+ hardenDbFilePermissions();
51
54
  }
52
55
  return _db;
53
56
  }
54
57
 
58
+ function hardenPathPermissions(path: string, mode: number): void {
59
+ try {
60
+ chmodSync(path, mode);
61
+ } catch {
62
+ // Best-effort only: do not make memory unavailable on filesystems that
63
+ // do not support POSIX permissions.
64
+ }
65
+ }
66
+
67
+ export function hardenDbFilePermissions(): void {
68
+ const dbPath = getDbPath();
69
+ for (const path of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
70
+ if (existsSync(path)) {
71
+ hardenPathPermissions(path, 0o600);
72
+ }
73
+ }
74
+ }
75
+
55
76
  export function closeDb(): void {
56
77
  if (_db) {
57
78
  try {
@@ -242,13 +263,20 @@ export function upsertRecord(record: {
242
263
  status?: RecordStatus;
243
264
  confidence?: number;
244
265
  importance?: number;
266
+ created_at?: number;
267
+ updated_at?: number;
245
268
  superseded_by?: string | null;
246
269
  derived_from_memory_refs?: string | null;
247
270
  }): string {
248
271
  const db = getDb();
249
272
  const now = Date.now();
273
+ const createdAt = record.created_at ?? now;
274
+ const updatedAt = record.updated_at ?? now;
250
275
  const scope = record.scope ?? "project";
251
276
  const projectId = scope === "global" ? null : (record.project_id ?? null);
277
+ if (scope === "project" && !projectId) {
278
+ throw new Error("Project-scoped memory records require a project_id");
279
+ }
252
280
  const redactedText = redactSecrets(record.text);
253
281
  const redactedTags = record.tags ? redactSecrets(record.tags) : null;
254
282
  const id = recordIdentityHash(redactedText, record.kind, scope, projectId);
@@ -276,7 +304,7 @@ export function upsertRecord(record: {
276
304
  redactedTags,
277
305
  record.confidence ?? 1.0,
278
306
  record.importance ?? 0.5,
279
- now,
307
+ updatedAt,
280
308
  record.status ?? existing.status,
281
309
  record.superseded_by ?? null,
282
310
  record.derived_from_memory_refs ?? null,
@@ -303,8 +331,8 @@ export function upsertRecord(record: {
303
331
  record.status ?? "active",
304
332
  record.confidence ?? 1.0,
305
333
  record.importance ?? 0.5,
306
- now,
307
- now,
334
+ createdAt,
335
+ updatedAt,
308
336
  record.superseded_by ?? null,
309
337
  record.derived_from_memory_refs ?? null,
310
338
  );
@@ -331,6 +359,14 @@ export function getRecord(id: string): RecordRow | undefined {
331
359
  );
332
360
  }
333
361
 
362
+ export function listRecords(options: { includeInactive?: boolean } = {}): RecordRow[] {
363
+ const db = getDb();
364
+ const sql = options.includeInactive
365
+ ? "SELECT * FROM records ORDER BY created_at ASC, id ASC"
366
+ : "SELECT * FROM records WHERE status = 'active' ORDER BY created_at ASC, id ASC";
367
+ return db.prepare(sql).all() as unknown as RecordRow[];
368
+ }
369
+
334
370
  export function searchRecordsFts(
335
371
  query: string,
336
372
  limit = 20,
@@ -351,6 +387,8 @@ export function searchRecordsFts(
351
387
 
352
388
  if (!terms) return [];
353
389
 
390
+ const safeLimit = Math.max(1, Math.min(200, Number.isFinite(limit) ? Math.floor(limit) : 20));
391
+
354
392
  const results = db
355
393
  .prepare(
356
394
  `SELECT r.*, fts.rank as rank
@@ -360,7 +398,7 @@ export function searchRecordsFts(
360
398
  ORDER BY rank
361
399
  LIMIT ?`
362
400
  )
363
- .all(terms, limit) as unknown as (RecordRow & { rank: number })[];
401
+ .all(terms, safeLimit) as unknown as (RecordRow & { rank: number })[];
364
402
 
365
403
  // Apply post-filters
366
404
  return results.filter((r) => {
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,217 @@
1
+ /**
2
+ * Portable export/import/backup helpers for memory records.
3
+ */
4
+
5
+ import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { dirname, isAbsolute, resolve } from "node:path";
7
+ import { getDb, getDbPath, hardenDbFilePermissions, 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
+ import { isSensitiveForGlobalMemory } from "../privacy/index.js";
10
+
11
+ export type ExportFormat = "json" | "md";
12
+
13
+ export interface PortableMemoryRecord {
14
+ id: string;
15
+ kind: RecordKind;
16
+ scope: RecordScope;
17
+ project_id: string | null;
18
+ text: string;
19
+ tags: string | null;
20
+ status: RecordStatus;
21
+ confidence: number;
22
+ importance: number;
23
+ created_at: number;
24
+ updated_at: number;
25
+ superseded_by: string | null;
26
+ derived_from_memory_refs: string | null;
27
+ }
28
+
29
+ export interface PortableMemoryExport {
30
+ format: "pi-memory-stone-export";
31
+ version: 1;
32
+ exported_at: string;
33
+ schema_version: number;
34
+ records: PortableMemoryRecord[];
35
+ }
36
+
37
+ export interface ImportOptions {
38
+ /** Remap project-scoped records to this project id. Use undefined to preserve exported project ids. */
39
+ projectId?: string | null;
40
+ /** Force every imported record into a scope. */
41
+ scopeOverride?: RecordScope;
42
+ }
43
+
44
+ export interface ImportResult {
45
+ imported: number;
46
+ skipped: number;
47
+ ids: string[];
48
+ }
49
+
50
+ export function buildMemoryExport(includeInactive = false): PortableMemoryExport {
51
+ return {
52
+ format: "pi-memory-stone-export",
53
+ version: 1,
54
+ exported_at: new Date().toISOString(),
55
+ schema_version: SCHEMA_VERSION,
56
+ records: listRecords({ includeInactive }).map(toPortableRecord),
57
+ };
58
+ }
59
+
60
+ export function exportMemory(format: ExportFormat, includeInactive = false): string {
61
+ const payload = buildMemoryExport(includeInactive);
62
+ if (format === "json") {
63
+ return JSON.stringify(payload, null, 2) + "\n";
64
+ }
65
+
66
+ return exportMarkdown(payload);
67
+ }
68
+
69
+ export function writeMemoryExport(path: string, format: ExportFormat, includeInactive = false): number {
70
+ const payload = buildMemoryExport(includeInactive);
71
+ const content = format === "json" ? JSON.stringify(payload, null, 2) + "\n" : exportMarkdown(payload);
72
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
73
+ writeFileSync(path, content, { encoding: "utf8", mode: 0o600 });
74
+ return payload.records.length;
75
+ }
76
+
77
+ export function importMemoryJsonFile(path: string, options: ImportOptions = {}): ImportResult {
78
+ const raw = readFileSync(path, "utf8");
79
+ return importMemoryJson(raw, options);
80
+ }
81
+
82
+ export function importMemoryJson(raw: string, options: ImportOptions = {}): ImportResult {
83
+ const parsed = JSON.parse(raw) as Partial<PortableMemoryExport>;
84
+ if (parsed.format !== "pi-memory-stone-export" || parsed.version !== 1 || !Array.isArray(parsed.records)) {
85
+ throw new Error("Unsupported memory export file. Expected pi-memory-stone-export version 1 JSON.");
86
+ }
87
+
88
+ const result: ImportResult = { imported: 0, skipped: 0, ids: [] };
89
+ for (const candidate of parsed.records) {
90
+ const record = normalizePortableRecord(candidate);
91
+ if (!record) {
92
+ result.skipped += 1;
93
+ continue;
94
+ }
95
+
96
+ const scope = options.scopeOverride ?? record.scope;
97
+ const projectId = scope === "global" ? null : (options.projectId !== undefined ? options.projectId : record.project_id);
98
+ if (scope === "project" && !projectId) {
99
+ result.skipped += 1;
100
+ continue;
101
+ }
102
+ if (scope === "global" && isSensitiveForGlobalMemory(`${record.text}\n${record.tags ?? ""}`)) {
103
+ result.skipped += 1;
104
+ continue;
105
+ }
106
+ const id = upsertRecord({
107
+ kind: record.kind,
108
+ scope,
109
+ project_id: projectId,
110
+ text: record.text,
111
+ tags: record.tags,
112
+ status: record.status,
113
+ confidence: record.confidence,
114
+ importance: record.importance,
115
+ created_at: record.created_at,
116
+ updated_at: record.updated_at,
117
+ superseded_by: record.superseded_by,
118
+ derived_from_memory_refs: record.derived_from_memory_refs,
119
+ });
120
+
121
+ result.imported += 1;
122
+ result.ids.push(id);
123
+ }
124
+
125
+ return result;
126
+ }
127
+
128
+ export function backupMemoryDatabase(path: string): void {
129
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
130
+ getDb().exec("PRAGMA wal_checkpoint(TRUNCATE)");
131
+ hardenDbFilePermissions();
132
+ copyFileSync(getDbPath(), path);
133
+ try {
134
+ chmodSync(path, 0o600);
135
+ } catch {}
136
+ }
137
+
138
+ export function defaultPortablePath(cwd: string, prefix: string, extension: string): string {
139
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
140
+ return resolve(cwd, `${prefix}-${stamp}.${extension}`);
141
+ }
142
+
143
+ export function resolvePortablePath(cwd: string, path: string): string {
144
+ return isAbsolute(path) ? path : resolve(cwd, path);
145
+ }
146
+
147
+ function toPortableRecord(row: RecordRow): PortableMemoryRecord {
148
+ return {
149
+ id: row.id,
150
+ kind: row.kind,
151
+ scope: row.scope,
152
+ project_id: row.project_id,
153
+ text: row.text,
154
+ tags: row.tags,
155
+ status: row.status,
156
+ confidence: row.confidence,
157
+ importance: row.importance,
158
+ created_at: row.created_at,
159
+ updated_at: row.updated_at,
160
+ superseded_by: row.superseded_by,
161
+ derived_from_memory_refs: row.derived_from_memory_refs,
162
+ };
163
+ }
164
+
165
+ function exportMarkdown(payload: PortableMemoryExport): string {
166
+ const lines: string[] = [];
167
+ lines.push("# Memory Stone Export");
168
+ lines.push("");
169
+ lines.push(`Exported: ${payload.exported_at}`);
170
+ lines.push(`Records: ${payload.records.length}`);
171
+ lines.push("");
172
+
173
+ for (const record of payload.records) {
174
+ lines.push(`## [${record.kind}] ${record.id}`);
175
+ lines.push("");
176
+ lines.push(`- Scope: ${record.scope}`);
177
+ lines.push(`- Project: ${record.project_id ?? "global"}`);
178
+ lines.push(`- Status: ${record.status}`);
179
+ lines.push(`- Importance: ${record.importance}`);
180
+ lines.push(`- Created: ${new Date(record.created_at).toISOString()}`);
181
+ if (record.tags) lines.push(`- Tags: ${record.tags}`);
182
+ lines.push("");
183
+ lines.push(record.text);
184
+ lines.push("");
185
+ }
186
+
187
+ return lines.join("\n");
188
+ }
189
+
190
+ function normalizePortableRecord(candidate: unknown): PortableMemoryRecord | null {
191
+ if (!candidate || typeof candidate !== "object") return null;
192
+ const r = candidate as Record<string, unknown>;
193
+ if (typeof r.text !== "string" || r.text.trim() === "") return null;
194
+ if (!isStringMember(r.kind, RECORD_KINDS)) return null;
195
+ if (!isStringMember(r.scope, RECORD_SCOPES)) return null;
196
+ if (!isStringMember(r.status, RECORD_STATUSES)) return null;
197
+
198
+ return {
199
+ id: typeof r.id === "string" ? r.id : "",
200
+ kind: r.kind,
201
+ scope: r.scope,
202
+ project_id: typeof r.project_id === "string" ? r.project_id : null,
203
+ text: r.text,
204
+ tags: typeof r.tags === "string" ? r.tags : null,
205
+ status: r.status,
206
+ confidence: typeof r.confidence === "number" ? r.confidence : 1,
207
+ importance: typeof r.importance === "number" ? r.importance : 0.5,
208
+ created_at: typeof r.created_at === "number" ? r.created_at : Date.now(),
209
+ updated_at: typeof r.updated_at === "number" ? r.updated_at : Date.now(),
210
+ superseded_by: typeof r.superseded_by === "string" ? r.superseded_by : null,
211
+ derived_from_memory_refs: typeof r.derived_from_memory_refs === "string" ? r.derived_from_memory_refs : null,
212
+ };
213
+ }
214
+
215
+ function isStringMember<T extends readonly string[]>(value: unknown, allowed: T): value is T[number] {
216
+ return typeof value === "string" && (allowed as readonly string[]).includes(value);
217
+ }
@@ -27,7 +27,7 @@ const SECRET_PATTERNS: { name: string; regex: RegExp; replacement: SecretReplace
27
27
  },
28
28
  {
29
29
  name: "aws-secret",
30
- regex: /(?<=SecretAccessKey[=:]\s*)[A-Za-z0-9/+]{40,}/g,
30
+ regex: /\b(?:aws[_-]?)?secret[_-]?access[_-]?key\b\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40,}['"]?/gi,
31
31
  replacement: "[REDACTED:aws-secret]",
32
32
  },
33
33
  {
@@ -37,12 +37,12 @@ const SECRET_PATTERNS: { name: string; regex: RegExp; replacement: SecretReplace
37
37
  },
38
38
  {
39
39
  name: "generic-api-key",
40
- regex: /(?:api[_-]?key|apikey|api[_-]?secret|secret[_-]?key)[=:]\s*['"]?[A-Za-z0-9_\-.]{16,}['"]?/gi,
40
+ regex: /\b(?:api[_-]?key|apikey|api[_-]?secret|secret[_-]?key|client[_-]?secret|private[_-]?key|access[_-]?key|auth[_-]?key)\b\s*[=:]\s*['"]?[A-Za-z0-9_\-./+=]{16,}['"]?/gi,
41
41
  replacement: "[REDACTED:api-key]",
42
42
  },
43
43
  {
44
44
  name: "secret-assignment",
45
- regex: /\b(?:secret|secret[_-]?key)\b\s*[=:]\s*(?:['"][^'"]+['"]|[^\s'"`]+)/gi,
45
+ regex: /\b(?:secret|secret[_-]?key|client[_-]?secret|app[_-]?secret|webhook[_-]?secret|signing[_-]?secret)\b\s*[=:]\s*(?:['"][^'"]+['"]|[^\s'"`]+)/gi,
46
46
  replacement: "[REDACTED:secret]",
47
47
  },
48
48
  {
@@ -126,6 +126,22 @@ export function redactSecrets(text: string): string {
126
126
  return result;
127
127
  }
128
128
 
129
+ export function isSensitiveForGlobalMemory(text: string): boolean {
130
+ if (redactSecrets(text) !== text) return true;
131
+
132
+ return [
133
+ // Local/absolute/relative filesystem paths and common repo paths.
134
+ /(?:^|\s)(?:~|\.|\.\.|[A-Za-z]:)?[/\\][^\s]+/,
135
+ /\b(?:src|lib|test|tests|packages|apps|docs|config)\/[\w./-]+\b/i,
136
+ /\b[\w.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|yaml|yml|toml|env|db|sqlite|pem|key|crt)\b/i,
137
+ // Hostnames and network endpoints.
138
+ /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0|::1)\b/i,
139
+ /\b[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+(?::\d{2,5})?\b/i,
140
+ // Implementation/internal detail markers that should stay project-local.
141
+ /\b(?:internal|private|implementation detail|class|function|method|module|endpoint|schema|table|column)\b/i,
142
+ ].some((pattern) => pattern.test(text));
143
+ }
144
+
129
145
  export function isSensitivePath(path: string, extraPatterns: RegExp[] = []): boolean {
130
146
  const allPatterns = [...DEFAULT_SENSITIVE_PATHS, ...extraPatterns];
131
147
 
@@ -23,6 +23,8 @@ const KIND_BOOST: Record<string, number> = {
23
23
  // ─── Recency decay ──────────────────────────────────────────────────
24
24
 
25
25
  const RECENCY_HALF_LIFE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
26
+ export const MAX_RETRIEVAL_LIMIT = 20;
27
+ const MAX_CANDIDATE_LIMIT = MAX_RETRIEVAL_LIMIT * 10;
26
28
 
27
29
  function recencyDecay(createdAt: number): number {
28
30
  const age = Date.now() - createdAt;
@@ -72,7 +74,7 @@ export function rankAndFilter(
72
74
  // and require explicit cross-project retrieval.
73
75
  if (rec.scope === "global") {
74
76
  if (!crossProjectEnabled) continue;
75
- } else if (rec.project_id && currentProjectId && rec.project_id !== currentProjectId) {
77
+ } else if (!rec.project_id || !currentProjectId || rec.project_id !== currentProjectId) {
76
78
  continue;
77
79
  }
78
80
 
@@ -121,6 +123,11 @@ export function rankAndFilter(
121
123
 
122
124
  // ─── Full retrieval pipeline ────────────────────────────────────────
123
125
 
126
+ export function normalizeRetrievalLimit(value: unknown, fallback: number): number {
127
+ const numeric = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback;
128
+ return Math.max(1, Math.min(MAX_RETRIEVAL_LIMIT, numeric));
129
+ }
130
+
124
131
  export function retrieve(
125
132
  userPrompt: string,
126
133
  currentProjectId: string | null,
@@ -133,13 +140,14 @@ export function retrieve(
133
140
  },
134
141
  ): RankedResult[] {
135
142
  const config = getConfig();
136
- const limit = opts?.limit ?? config.maxInjectedRecords;
143
+ const limit = normalizeRetrievalLimit(opts?.limit, config.maxInjectedRecords);
137
144
  const crossProject = opts?.crossProjectEnabled ?? config.crossProjectEnabled;
138
145
 
139
146
  const query = buildSearchQuery(userPrompt, recentFiles);
140
147
 
141
- // Get more candidates than needed (ranking will filter)
142
- const candidates = searchRecordsFts(query, limit * 10, opts?.kindFilter, opts?.scopeFilter);
148
+ // Get more candidates than needed (ranking will filter), but keep local work bounded.
149
+ const candidateLimit = Math.min(MAX_CANDIDATE_LIMIT, limit * 10);
150
+ const candidates = searchRecordsFts(query, candidateLimit, opts?.kindFilter, opts?.scopeFilter);
143
151
 
144
152
  const ranked = rankAndFilter(candidates, currentProjectId, crossProject);
145
153
 
@@ -0,0 +1,89 @@
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
+ if (record.scope === "global") return true;
71
+ return Boolean(currentProjectId && record.project_id && record.project_id === currentProjectId);
72
+ }
73
+
74
+ export function manualRecordsToRankedResults(
75
+ records: RecordRow[],
76
+ currentProjectId: string | null,
77
+ ): RankedResult[] {
78
+ return records
79
+ .filter((record) => record.status === "active" && isRecordVisibleInProject(record, currentProjectId))
80
+ .map((record) => ({
81
+ record,
82
+ score: Number.POSITIVE_INFINITY,
83
+ reasons: ["manual-ref"],
84
+ }));
85
+ }
86
+
87
+ function isCustomEntry(entry: unknown): entry is { type?: string; customType?: string; data?: unknown } {
88
+ return typeof entry === "object" && entry !== null && (entry as { type?: unknown }).type === "custom";
89
+ }