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/README.md +53 -7
- package/package.json +4 -2
- package/skills/pi-memory-stone/SKILL.md +101 -0
- package/src/commands/index.ts +332 -4
- package/src/config/index.ts +7 -0
- package/src/db/index.ts +44 -6
- package/src/index.ts +34 -51
- package/src/portable/index.ts +217 -0
- package/src/privacy/index.ts +19 -3
- package/src/retrieval/index.ts +12 -4
- package/src/session-state/index.ts +89 -0
- package/src/tools/index.ts +17 -12
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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,
|
|
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
|
-
|
|
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,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
|
+
}
|
package/src/privacy/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ const SECRET_PATTERNS: { name: string; regex: RegExp; replacement: SecretReplace
|
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
29
|
name: "aws-secret",
|
|
30
|
-
regex:
|
|
30
|
+
regex: /\b(?:aws[_-]?)?secret[_-]?access[_-]?key\b\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40,}['"]?/gi,
|
|
31
31
|
replacement: "[REDACTED:aws-secret]",
|
|
32
32
|
},
|
|
33
33
|
{
|
|
@@ -37,12 +37,12 @@ const SECRET_PATTERNS: { name: string; regex: RegExp; replacement: SecretReplace
|
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
name: "generic-api-key",
|
|
40
|
-
regex:
|
|
40
|
+
regex: /\b(?:api[_-]?key|apikey|api[_-]?secret|secret[_-]?key|client[_-]?secret|private[_-]?key|access[_-]?key|auth[_-]?key)\b\s*[=:]\s*['"]?[A-Za-z0-9_\-./+=]{16,}['"]?/gi,
|
|
41
41
|
replacement: "[REDACTED:api-key]",
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
44
|
name: "secret-assignment",
|
|
45
|
-
regex: /\b(?:secret|secret[_-]?key)\b\s*[=:]\s*(?:['"][^'"]+['"]|[^\s'"`]+)/gi,
|
|
45
|
+
regex: /\b(?:secret|secret[_-]?key|client[_-]?secret|app[_-]?secret|webhook[_-]?secret|signing[_-]?secret)\b\s*[=:]\s*(?:['"][^'"]+['"]|[^\s'"`]+)/gi,
|
|
46
46
|
replacement: "[REDACTED:secret]",
|
|
47
47
|
},
|
|
48
48
|
{
|
|
@@ -126,6 +126,22 @@ export function redactSecrets(text: string): string {
|
|
|
126
126
|
return result;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
export function isSensitiveForGlobalMemory(text: string): boolean {
|
|
130
|
+
if (redactSecrets(text) !== text) return true;
|
|
131
|
+
|
|
132
|
+
return [
|
|
133
|
+
// Local/absolute/relative filesystem paths and common repo paths.
|
|
134
|
+
/(?:^|\s)(?:~|\.|\.\.|[A-Za-z]:)?[/\\][^\s]+/,
|
|
135
|
+
/\b(?:src|lib|test|tests|packages|apps|docs|config)\/[\w./-]+\b/i,
|
|
136
|
+
/\b[\w.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|yaml|yml|toml|env|db|sqlite|pem|key|crt)\b/i,
|
|
137
|
+
// Hostnames and network endpoints.
|
|
138
|
+
/\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0|::1)\b/i,
|
|
139
|
+
/\b[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+(?::\d{2,5})?\b/i,
|
|
140
|
+
// Implementation/internal detail markers that should stay project-local.
|
|
141
|
+
/\b(?:internal|private|implementation detail|class|function|method|module|endpoint|schema|table|column)\b/i,
|
|
142
|
+
].some((pattern) => pattern.test(text));
|
|
143
|
+
}
|
|
144
|
+
|
|
129
145
|
export function isSensitivePath(path: string, extraPatterns: RegExp[] = []): boolean {
|
|
130
146
|
const allPatterns = [...DEFAULT_SENSITIVE_PATHS, ...extraPatterns];
|
|
131
147
|
|
package/src/retrieval/index.ts
CHANGED
|
@@ -23,6 +23,8 @@ const KIND_BOOST: Record<string, number> = {
|
|
|
23
23
|
// ─── Recency decay ──────────────────────────────────────────────────
|
|
24
24
|
|
|
25
25
|
const RECENCY_HALF_LIFE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
26
|
+
export const MAX_RETRIEVAL_LIMIT = 20;
|
|
27
|
+
const MAX_CANDIDATE_LIMIT = MAX_RETRIEVAL_LIMIT * 10;
|
|
26
28
|
|
|
27
29
|
function recencyDecay(createdAt: number): number {
|
|
28
30
|
const age = Date.now() - createdAt;
|
|
@@ -72,7 +74,7 @@ export function rankAndFilter(
|
|
|
72
74
|
// and require explicit cross-project retrieval.
|
|
73
75
|
if (rec.scope === "global") {
|
|
74
76
|
if (!crossProjectEnabled) continue;
|
|
75
|
-
} else if (rec.project_id
|
|
77
|
+
} else if (!rec.project_id || !currentProjectId || rec.project_id !== currentProjectId) {
|
|
76
78
|
continue;
|
|
77
79
|
}
|
|
78
80
|
|
|
@@ -121,6 +123,11 @@ export function rankAndFilter(
|
|
|
121
123
|
|
|
122
124
|
// ─── Full retrieval pipeline ────────────────────────────────────────
|
|
123
125
|
|
|
126
|
+
export function normalizeRetrievalLimit(value: unknown, fallback: number): number {
|
|
127
|
+
const numeric = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback;
|
|
128
|
+
return Math.max(1, Math.min(MAX_RETRIEVAL_LIMIT, numeric));
|
|
129
|
+
}
|
|
130
|
+
|
|
124
131
|
export function retrieve(
|
|
125
132
|
userPrompt: string,
|
|
126
133
|
currentProjectId: string | null,
|
|
@@ -133,13 +140,14 @@ export function retrieve(
|
|
|
133
140
|
},
|
|
134
141
|
): RankedResult[] {
|
|
135
142
|
const config = getConfig();
|
|
136
|
-
const limit = opts?.limit
|
|
143
|
+
const limit = normalizeRetrievalLimit(opts?.limit, config.maxInjectedRecords);
|
|
137
144
|
const crossProject = opts?.crossProjectEnabled ?? config.crossProjectEnabled;
|
|
138
145
|
|
|
139
146
|
const query = buildSearchQuery(userPrompt, recentFiles);
|
|
140
147
|
|
|
141
|
-
// Get more candidates than needed (ranking will filter)
|
|
142
|
-
const
|
|
148
|
+
// Get more candidates than needed (ranking will filter), but keep local work bounded.
|
|
149
|
+
const candidateLimit = Math.min(MAX_CANDIDATE_LIMIT, limit * 10);
|
|
150
|
+
const candidates = searchRecordsFts(query, candidateLimit, opts?.kindFilter, opts?.scopeFilter);
|
|
143
151
|
|
|
144
152
|
const ranked = rankAndFilter(candidates, currentProjectId, crossProject);
|
|
145
153
|
|
|
@@ -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
|
+
}
|