memwarden 0.0.1

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.
Files changed (119) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +402 -0
  3. package/dist/bundle/bundle.d.ts +28 -0
  4. package/dist/bundle/bundle.js +85 -0
  5. package/dist/cli/bin.d.ts +2 -0
  6. package/dist/cli/bin.js +593 -0
  7. package/dist/cli/connect.d.ts +63 -0
  8. package/dist/cli/connect.js +121 -0
  9. package/dist/cli/hook.d.ts +24 -0
  10. package/dist/cli/hook.js +186 -0
  11. package/dist/cli/tools.d.ts +47 -0
  12. package/dist/cli/tools.js +246 -0
  13. package/dist/daemon/ensure.d.ts +12 -0
  14. package/dist/daemon/ensure.js +54 -0
  15. package/dist/daemon/service.d.ts +15 -0
  16. package/dist/daemon/service.js +210 -0
  17. package/dist/embedding/index.d.ts +10 -0
  18. package/dist/embedding/index.js +33 -0
  19. package/dist/embedding/local-embedding.d.ts +14 -0
  20. package/dist/embedding/local-embedding.js +80 -0
  21. package/dist/functions/access-tracker.d.ts +13 -0
  22. package/dist/functions/access-tracker.js +92 -0
  23. package/dist/functions/audit.d.ts +46 -0
  24. package/dist/functions/audit.js +0 -0
  25. package/dist/functions/cjk-segmenter.d.ts +6 -0
  26. package/dist/functions/cjk-segmenter.js +120 -0
  27. package/dist/functions/compress-synthetic.d.ts +2 -0
  28. package/dist/functions/compress-synthetic.js +104 -0
  29. package/dist/functions/config.d.ts +68 -0
  30. package/dist/functions/config.js +231 -0
  31. package/dist/functions/conflicts.d.ts +19 -0
  32. package/dist/functions/conflicts.js +328 -0
  33. package/dist/functions/context.d.ts +3 -0
  34. package/dist/functions/context.js +155 -0
  35. package/dist/functions/dedup.d.ts +11 -0
  36. package/dist/functions/dedup.js +51 -0
  37. package/dist/functions/dejafix.d.ts +96 -0
  38. package/dist/functions/dejafix.js +356 -0
  39. package/dist/functions/doctor.d.ts +29 -0
  40. package/dist/functions/doctor.js +137 -0
  41. package/dist/functions/forget.d.ts +3 -0
  42. package/dist/functions/forget.js +87 -0
  43. package/dist/functions/hybrid-search.d.ts +17 -0
  44. package/dist/functions/hybrid-search.js +205 -0
  45. package/dist/functions/index.d.ts +32 -0
  46. package/dist/functions/index.js +44 -0
  47. package/dist/functions/keyed-mutex.d.ts +1 -0
  48. package/dist/functions/keyed-mutex.js +21 -0
  49. package/dist/functions/logger.d.ts +6 -0
  50. package/dist/functions/logger.js +37 -0
  51. package/dist/functions/memory-utils.d.ts +2 -0
  52. package/dist/functions/memory-utils.js +29 -0
  53. package/dist/functions/observe.d.ts +5 -0
  54. package/dist/functions/observe.js +326 -0
  55. package/dist/functions/paths.d.ts +1 -0
  56. package/dist/functions/paths.js +38 -0
  57. package/dist/functions/privacy.d.ts +1 -0
  58. package/dist/functions/privacy.js +30 -0
  59. package/dist/functions/provenance.d.ts +9 -0
  60. package/dist/functions/provenance.js +57 -0
  61. package/dist/functions/quantized-vector-index.d.ts +60 -0
  62. package/dist/functions/quantized-vector-index.js +275 -0
  63. package/dist/functions/receipt.d.ts +31 -0
  64. package/dist/functions/receipt.js +95 -0
  65. package/dist/functions/search-index.d.ts +27 -0
  66. package/dist/functions/search-index.js +217 -0
  67. package/dist/functions/search.d.ts +25 -0
  68. package/dist/functions/search.js +523 -0
  69. package/dist/functions/stemmer.d.ts +1 -0
  70. package/dist/functions/stemmer.js +110 -0
  71. package/dist/functions/synonyms.d.ts +1 -0
  72. package/dist/functions/synonyms.js +69 -0
  73. package/dist/functions/turboquant.d.ts +53 -0
  74. package/dist/functions/turboquant.js +278 -0
  75. package/dist/functions/types.d.ts +217 -0
  76. package/dist/functions/types.js +8 -0
  77. package/dist/functions/vector-index.d.ts +25 -0
  78. package/dist/functions/vector-index.js +125 -0
  79. package/dist/functions/vector-persistence.d.ts +14 -0
  80. package/dist/functions/vector-persistence.js +75 -0
  81. package/dist/functions/verify.d.ts +13 -0
  82. package/dist/functions/verify.js +104 -0
  83. package/dist/index.d.ts +1 -0
  84. package/dist/index.js +219 -0
  85. package/dist/kernel/http.d.ts +24 -0
  86. package/dist/kernel/http.js +261 -0
  87. package/dist/kernel/index.d.ts +19 -0
  88. package/dist/kernel/index.js +21 -0
  89. package/dist/kernel/kernel.d.ts +80 -0
  90. package/dist/kernel/kernel.js +297 -0
  91. package/dist/kernel/pubsub.d.ts +21 -0
  92. package/dist/kernel/pubsub.js +38 -0
  93. package/dist/kernel/types.d.ts +139 -0
  94. package/dist/kernel/types.js +20 -0
  95. package/dist/mcp/bin.d.ts +2 -0
  96. package/dist/mcp/bin.js +27 -0
  97. package/dist/mcp/server.d.ts +34 -0
  98. package/dist/mcp/server.js +377 -0
  99. package/dist/observability/metrics.d.ts +26 -0
  100. package/dist/observability/metrics.js +104 -0
  101. package/dist/proxy/server.d.ts +30 -0
  102. package/dist/proxy/server.js +331 -0
  103. package/dist/state/kv.d.ts +41 -0
  104. package/dist/state/kv.js +50 -0
  105. package/dist/state/oplog.d.ts +25 -0
  106. package/dist/state/oplog.js +57 -0
  107. package/dist/state/schema.d.ts +60 -0
  108. package/dist/state/schema.js +88 -0
  109. package/dist/state/store-libsql.d.ts +46 -0
  110. package/dist/state/store-libsql.js +263 -0
  111. package/dist/state/store-memory.d.ts +23 -0
  112. package/dist/state/store-memory.js +121 -0
  113. package/dist/state/store.d.ts +87 -0
  114. package/dist/state/store.js +58 -0
  115. package/dist/triggers/api.d.ts +14 -0
  116. package/dist/triggers/api.js +510 -0
  117. package/dist/triggers/auth.d.ts +1 -0
  118. package/dist/triggers/auth.js +13 -0
  119. package/package.json +58 -0
@@ -0,0 +1,356 @@
1
+ //
2
+ // Déjà Fix — the cross-agent "don't repeat a mistake I already fixed" engine.
3
+ //
4
+ // The daemon sees EVERY agent's sessions, so it can do what no per-tool memory
5
+ // can: when any agent (Claude Code, Codex, Cursor, …) resolves an error, it
6
+ // captures {error signature -> root cause + fix} with provenance file-hashes.
7
+ // Later, when ANY agent hits a matching error signature, the verified fix is
8
+ // surfaced — but ONLY if the fix's referenced files still hash-match. A stale
9
+ // fix is never surfaced (Verified Recall, reusing classifyProvenance).
10
+ //
11
+ // Storage: one dedicated KV scope (DEJAFIX_SCOPE), keyed by error signature.
12
+ // Each key holds an append-only list of FixMemory records (newest appended
13
+ // last). This is the exact StateKV scope/serialization pattern used by
14
+ // access-tracker.ts (KV.accessLog keyed by id) — no new storage mechanism.
15
+ //
16
+ // Project scoping: a FixMemory records its capture cwd; lookup canonicalizes
17
+ // both the stored cwd and the caller cwd (paths.ts) so a fix learned in one
18
+ // repo is never surfaced in another.
19
+ import { classifyProvenance, hashFiles } from "./verify.js";
20
+ import { canonicalizePath } from "./paths.js";
21
+ import { withKeyedLock } from "./keyed-mutex.js";
22
+ import { generateId } from "../state/schema.js";
23
+ import { logger } from "./logger.js";
24
+ // Dedicated KV scope, in the `mem:` namespace like every other scope in
25
+ // schema.ts. Kept local to this module so the feature is self-contained; the
26
+ // underlying mechanism (StateKV scope + key) is identical to access-tracker.
27
+ export const DEJAFIX_SCOPE = "mem:dejafix";
28
+ // Cap the number of records retained per signature so a hot, repeatedly-fixed
29
+ // error can't grow a key without bound. Newest are kept.
30
+ const MAX_FIXES_PER_SIGNATURE = 25;
31
+ // ---------------------------------------------------------------------------
32
+ // Signature extraction
33
+ // ---------------------------------------------------------------------------
34
+ // Volatile-token scrubbers, applied to candidate stable text. Order matters:
35
+ // timestamps and UUIDs/hex before bare numbers, so the broad number rule can't
36
+ // shred them first.
37
+ function scrubVolatile(s) {
38
+ return (s
39
+ // ISO-ish timestamps: 2026-06-10T12:34:56.789Z / 2026-06-10 12:34:56
40
+ .replace(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?/g, "<TS>")
41
+ // UUIDs
42
+ .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, "<UUID>")
43
+ // Hex addresses / pointers: 0x1a2b3c
44
+ .replace(/0x[0-9a-f]+/gi, "<HEX>")
45
+ // Long bare hex blobs (>=8 hex chars), e.g. content hashes
46
+ .replace(/\b[0-9a-f]{8,}\b/gi, "<HEX>")
47
+ // Durations: 12ms, 1.5s, 200µs, 3m
48
+ .replace(/\b\d+(?:\.\d+)?\s?(?:ns|µs|us|ms|s|m|h)\b/gi, "<DUR>")
49
+ // Ports on a host:port — keep host, drop port
50
+ .replace(/(:)\d{2,5}\b/g, "$1<PORT>")
51
+ // Any remaining bare numbers (line/col, counts, sizes)
52
+ .replace(/\b\d+(?:\.\d+)?\b/g, "<N>")
53
+ // Collapse whitespace
54
+ .replace(/\s+/g, " ")
55
+ .trim());
56
+ }
57
+ // Replace absolute/relative path tokens with their basename so a stack frame's
58
+ // location is stable across machines and checkouts. Runs BEFORE number scrub so
59
+ // the path's own line/col digits get normalized as part of the path token.
60
+ function basenamePaths(s) {
61
+ // A path-ish token: optional drive, slashes, ending in a dotted file. Capture
62
+ // the trailing file and any :line:col suffix.
63
+ return s.replace(/(?:[A-Za-z]:)?(?:\/|\\)?(?:[\w.\-]+(?:\/|\\))+([\w.\-]+\.\w{1,8})(?::\d+(?::\d+)?)?/g, (_m, file) => file);
64
+ }
65
+ const STACK_FRAME_RE = /^\s*at\s+/;
66
+ // Keep only the error message itself, dropping any trailing prose on the same
67
+ // line (e.g. "TypeError: x is undefined. Fixed by adding a guard."). Cutting at
68
+ // the first sentence terminator means an error captured WITH its resolution
69
+ // narrative produces the same signature as the bare error looked up later. The
70
+ // "Error: ENOENT:" double-colon shape is preserved (we only cut on . ! ?).
71
+ function firstSentence(msg) {
72
+ // First drop a trailing INLINE stack frame ("… at src/x.ts:9:14" or
73
+ // "… at Object.foo (src/x.ts:9:14)") so a message captured with an inline
74
+ // location matches the same message looked up without one. Only strip when
75
+ // the tail carries a :line-number, so plain prose like "failed at startup"
76
+ // or "retried at line 5" (no colon+digits) is left intact.
77
+ const deframed = msg.replace(/\s+at\s+\S.*$/, (m) => (/:\d+/.test(m) ? "" : m));
78
+ const cut = deframed.search(/[.!?](?:\s|$)/);
79
+ return cut > 0 ? deframed.slice(0, cut) : deframed;
80
+ }
81
+ /**
82
+ * Extract a STABLE signature from an error message, stack trace, or failing
83
+ * test output. Returns null when nothing recognizable as an error is present.
84
+ *
85
+ * The signature normalizes away volatile parts (absolute paths -> basename,
86
+ * line/column numbers, hex addresses, timestamps, UUIDs, ports, durations) and
87
+ * keeps the stable core: the error/exception class, the failing test name, and
88
+ * the key message tokens. Deterministic: same logical error -> same signature.
89
+ */
90
+ export function errorSignature(text) {
91
+ if (typeof text !== "string")
92
+ return null;
93
+ const trimmed = text.trim();
94
+ if (!trimmed)
95
+ return null;
96
+ const lines = trimmed.split(/\r?\n/);
97
+ // 1) TypeScript compiler error: "file.ts(12,5): error TS2304: Cannot find …"
98
+ // or "file.ts:12:5 - error TS2304: …"
99
+ for (const raw of lines) {
100
+ const m = raw.match(/error\s+(TS\d{3,5})\s*:\s*(.+)$/i);
101
+ if (m && m[1] && m[2]) {
102
+ const code = m[1].toUpperCase();
103
+ const msg = scrubVolatile(basenamePaths(m[2]));
104
+ return sig(`ts ${code}: ${msg}`);
105
+ }
106
+ }
107
+ // 2) Vitest / Jest failing-test line. Vitest: " FAIL test/x.test.ts > suite
108
+ // > name". Jest: " ✕ suite › name (12 ms)". Also "● suite › name".
109
+ for (const raw of lines) {
110
+ const vitest = raw.match(/\bFAIL\b\s+(.+?)\s*>\s*(.+)$/);
111
+ if (vitest && vitest[2]) {
112
+ const name = scrubVolatile(basenamePaths(vitest[2]));
113
+ return sig(`test fail: ${name}`);
114
+ }
115
+ const jest = raw.match(/^[\s│]*(?:[✕✗×]|●)\s+(.+)$/);
116
+ if (jest && jest[1]) {
117
+ const name = scrubVolatile(basenamePaths(jest[1]));
118
+ if (name)
119
+ return sig(`test fail: ${name}`);
120
+ }
121
+ }
122
+ // 3) Node / generic exception line: "TypeError: x is not a function",
123
+ // "Error: ENOENT: no such file …", "ReferenceError: y is not defined".
124
+ // Prefer the first stack-trace header over a later "at" frame.
125
+ for (const raw of lines) {
126
+ if (STACK_FRAME_RE.test(raw))
127
+ continue;
128
+ const m = raw.match(/\b([A-Z][A-Za-z]*(?:Error|Exception))\b\s*:\s*(.+)$/);
129
+ if (m && m[1] && m[2]) {
130
+ const cls = m[1];
131
+ const msg = scrubVolatile(basenamePaths(firstSentence(m[2])));
132
+ return sig(`${cls}: ${msg}`);
133
+ }
134
+ }
135
+ // 4) Bare "<Class>Error" / "<Class>Exception" with no colon message.
136
+ for (const raw of lines) {
137
+ if (STACK_FRAME_RE.test(raw))
138
+ continue;
139
+ const m = raw.match(/\b([A-Z][A-Za-z]*(?:Error|Exception))\b/);
140
+ if (m && m[1])
141
+ return sig(m[1]);
142
+ }
143
+ // 5) Generic "Error: …" / "error: …" anywhere, last resort.
144
+ for (const raw of lines) {
145
+ const m = raw.match(/\berror\s*:\s*(.+)$/i);
146
+ if (m && m[1]) {
147
+ const msg = scrubVolatile(basenamePaths(firstSentence(m[1])));
148
+ if (msg)
149
+ return sig(`error: ${msg}`);
150
+ }
151
+ }
152
+ return null;
153
+ }
154
+ // Final normalization: lowercase + truncate so casing/length never split a
155
+ // signature. Truncation keeps the head (error class + first message tokens).
156
+ function sig(core) {
157
+ return core.toLowerCase().replace(/\s+/g, " ").trim().slice(0, 200);
158
+ }
159
+ /** True when `text` contains both an error AND resolution language — the
160
+ * shape that observe.ts treats as a recorded fix worth signature-tagging. */
161
+ export function looksLikeResolvedFix(text) {
162
+ if (typeof text !== "string" || !text.trim())
163
+ return false;
164
+ if (errorSignature(text) === null)
165
+ return false;
166
+ return /\b(fix(?:ed|es)?|resolv(?:e|ed|es)|solv(?:e|ed|es)|root cause|the issue was|turned out|caused by|patch(?:ed)?)\b/i.test(text);
167
+ }
168
+ // ---------------------------------------------------------------------------
169
+ // Store
170
+ // ---------------------------------------------------------------------------
171
+ function keyFor(signature) {
172
+ // The signature is already normalized + capped; use it directly as the KV
173
+ // key. Spaces and ":" are fine in StateKV keys (it's an opaque string key).
174
+ return signature;
175
+ }
176
+ function lockKeyFor(signature) {
177
+ return `dejafix:${signature}`;
178
+ }
179
+ /** Parse a stored value into a FixMemory[] (tolerant of legacy/garbage). */
180
+ function asFixList(raw) {
181
+ if (!Array.isArray(raw))
182
+ return [];
183
+ const out = [];
184
+ for (const r of raw) {
185
+ if (r &&
186
+ typeof r === "object" &&
187
+ typeof r.signature === "string" &&
188
+ typeof r.fix === "string" &&
189
+ typeof r.cwd === "string") {
190
+ out.push(r);
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+ /**
196
+ * Store a FixMemory under its signature. Returns the stored record, or null
197
+ * when no signature can be derived (nothing to key on). Hashes the referenced
198
+ * files at capture time so later recall can detect drift — exactly like
199
+ * observe.ts does for synthetic observations.
200
+ */
201
+ export async function recordFix(kv, input) {
202
+ const signature = input.signature ??
203
+ (input.errorText ? errorSignature(input.errorText) : null);
204
+ if (!signature)
205
+ return null;
206
+ if (!input.fix || !input.fix.trim())
207
+ return null;
208
+ if (!input.cwd || !input.cwd.trim())
209
+ return null;
210
+ // Build provenance: prefer an explicit one, else derive from files. Hash the
211
+ // referenced files under cwd now so content drift is detectable at recall.
212
+ let provenance;
213
+ if (input.provenance) {
214
+ provenance = { ...input.provenance };
215
+ }
216
+ else {
217
+ // An explicitly recorded fix is itself the evidence — the agent/user
218
+ // asserted "this resolved the error". That makes it sourced (userConfirmed)
219
+ // even with no referenced files, so it surfaces as "sourced, unverified"
220
+ // rather than being dropped as unsourced. File-backed fixes additionally
221
+ // verify by content hash.
222
+ provenance = { userConfirmed: true, cwd: input.cwd };
223
+ if (input.files && input.files.length > 0)
224
+ provenance.files = input.files;
225
+ }
226
+ if (!provenance.cwd)
227
+ provenance.cwd = input.cwd;
228
+ const files = provenance.files;
229
+ if (files && files.length > 0) {
230
+ const hashes = hashFiles(files, input.cwd);
231
+ if (Object.keys(hashes).length > 0)
232
+ provenance.fileHashes = hashes;
233
+ }
234
+ const record = {
235
+ signature,
236
+ observationId: input.observationId ?? generateId("fix"),
237
+ fix: input.fix.trim(),
238
+ provenance,
239
+ cwd: input.cwd,
240
+ timestamp: input.timestamp ?? new Date().toISOString(),
241
+ };
242
+ if (input.rootCause && input.rootCause.trim())
243
+ record.rootCause = input.rootCause.trim();
244
+ if (input.tool)
245
+ record.tool = input.tool;
246
+ if (input.sessionId)
247
+ record.sessionId = input.sessionId;
248
+ await withKeyedLock(lockKeyFor(signature), async () => {
249
+ const existing = asFixList(await kv.get(DEJAFIX_SCOPE, keyFor(signature)));
250
+ existing.push(record);
251
+ const trimmed = existing.slice(-MAX_FIXES_PER_SIGNATURE);
252
+ await kv.set(DEJAFIX_SCOPE, keyFor(signature), trimmed);
253
+ });
254
+ return record;
255
+ }
256
+ /**
257
+ * Look up verified fixes for an error. Computes the signature, fetches the
258
+ * candidate FixMemories scoped to the caller's project (canonicalized cwd),
259
+ * runs each through classifyProvenance, and returns ONLY verified /
260
+ * sourced_unverified ones — never stale, never if referenced files vanished.
261
+ * Each is annotated with a freshness badge. Newest first.
262
+ */
263
+ export async function lookupFix(kv, errorText, cwd) {
264
+ const signature = errorSignature(errorText);
265
+ if (!signature)
266
+ return [];
267
+ if (!cwd || !cwd.trim())
268
+ return [];
269
+ const candidates = asFixList(await kv.get(DEJAFIX_SCOPE, keyFor(signature)));
270
+ if (candidates.length === 0)
271
+ return [];
272
+ const wantProject = canonicalizePath(cwd);
273
+ const out = [];
274
+ for (const c of candidates) {
275
+ // Project firewall: a fix learned in another repo never surfaces here.
276
+ if (canonicalizePath(c.cwd) !== wantProject)
277
+ continue;
278
+ // Verified Recall: classify against the LIVE repo at `cwd`. Stale (deleted
279
+ // or content-changed referenced file) and unsourced fixes are dropped.
280
+ const verdict = classifyProvenance(c.provenance, cwd);
281
+ if (verdict.status === "stale" || verdict.status === "unsourced")
282
+ continue;
283
+ const fix = {
284
+ signature: c.signature,
285
+ observationId: c.observationId,
286
+ fix: c.fix,
287
+ cwd: c.cwd,
288
+ timestamp: c.timestamp,
289
+ status: verdict.status,
290
+ badge: verdict.status === "verified" ? "verified current" : "sourced, unverified",
291
+ };
292
+ if (c.rootCause !== undefined)
293
+ fix.rootCause = c.rootCause;
294
+ if (c.tool !== undefined)
295
+ fix.tool = c.tool;
296
+ if (c.sessionId !== undefined)
297
+ fix.sessionId = c.sessionId;
298
+ out.push(fix);
299
+ }
300
+ // Newest first.
301
+ out.sort((a, b) => (a.timestamp < b.timestamp ? 1 : a.timestamp > b.timestamp ? -1 : 0));
302
+ return out;
303
+ }
304
+ // ---------------------------------------------------------------------------
305
+ // Kernel function registration
306
+ // ---------------------------------------------------------------------------
307
+ /**
308
+ * Register the two Déjà Fix kernel functions:
309
+ * mem::dejafix_record — store a fix (input: errorText|signature, fix, …)
310
+ * mem::dejafix_lookup — surface verified fixes for an error (input:
311
+ * errorText, cwd)
312
+ *
313
+ * Both are thin, dependency-free, and go through the same StateKV chokepoint
314
+ * every other mem:: function uses, so they share the one persistence layer.
315
+ */
316
+ export function registerDejaFixFunctions(sdk, kv) {
317
+ sdk.registerFunction("mem::dejafix_record", async (data) => {
318
+ try {
319
+ const rec = await recordFix(kv, data);
320
+ if (!rec)
321
+ return { recorded: false };
322
+ return {
323
+ recorded: true,
324
+ signature: rec.signature,
325
+ observationId: rec.observationId,
326
+ };
327
+ }
328
+ catch (err) {
329
+ logger.warn("mem::dejafix_record failed", {
330
+ error: err instanceof Error ? err.message : String(err),
331
+ });
332
+ return { recorded: false };
333
+ }
334
+ });
335
+ sdk.registerFunction("mem::dejafix_lookup", async (data) => {
336
+ const errorText = typeof data?.errorText === "string"
337
+ ? data.errorText
338
+ : typeof data?.error_text === "string"
339
+ ? data.error_text
340
+ : "";
341
+ const cwd = typeof data?.cwd === "string" ? data.cwd : "";
342
+ const signature = errorText ? errorSignature(errorText) : null;
343
+ if (!signature || !cwd)
344
+ return { signature, fixes: [] };
345
+ try {
346
+ const fixes = await lookupFix(kv, errorText, cwd);
347
+ return { signature, fixes };
348
+ }
349
+ catch (err) {
350
+ logger.warn("mem::dejafix_lookup failed", {
351
+ error: err instanceof Error ? err.message : String(err),
352
+ });
353
+ return { signature, fixes: [] };
354
+ }
355
+ });
356
+ }
@@ -0,0 +1,29 @@
1
+ import type { ISdk } from "../kernel/index.js";
2
+ import type { StateKV } from "../state/kv.js";
3
+ import { type MemoryConflict } from "./conflicts.js";
4
+ export interface DoctorEntry {
5
+ id: string;
6
+ title: string;
7
+ reason: string;
8
+ }
9
+ export interface DoctorFootprint {
10
+ /** Total bytes the brain occupies on disk (whole data dir). */
11
+ bytesOnDisk: number;
12
+ /** Where it lives. */
13
+ dataDir: string;
14
+ /** Append-only oplog length — growth observability. */
15
+ oplogEntries: number;
16
+ }
17
+ export interface DoctorReport {
18
+ total: number;
19
+ safe: number;
20
+ verified: number;
21
+ sourcedUnverified: number;
22
+ stale: DoctorEntry[];
23
+ unsourced: DoctorEntry[];
24
+ conflicts: MemoryConflict[];
25
+ /** Disk/size honesty: memory layers that hide their footprint end up
26
+ * surprising users with gigabytes. memwarden reports it on every audit. */
27
+ footprint: DoctorFootprint;
28
+ }
29
+ export declare function registerDoctorFunction(sdk: ISdk, kv: StateKV): void;
@@ -0,0 +1,137 @@
1
+ //
2
+ // mem::doctor — the memory doctor / firewall. Audits stored memories for
3
+ // trustworthiness against the live repo, not just integrity:
4
+ //
5
+ // VERIFIED code-backed memory still matches its capture-time hashes
6
+ // SOURCED sourced, but not content-verified
7
+ // STALE references files that no longer exist or changed under root
8
+ // UNSOURCED no evidence (no files, no command, not confirmed)
9
+ // CONFLICTS newer sourced memories that contradict older sourced memories
10
+ //
11
+ // File checks run in the daemon (same machine as the repo). Conflict detection
12
+ // is intentionally conservative and explainable: simple subject/value claims,
13
+ // no LLM, no fuzzy black box.
14
+ import { readdirSync, statSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { KV } from "../state/schema.js";
17
+ import { classifyProvenance } from "./verify.js";
18
+ import { memoryToObservation } from "./memory-utils.js";
19
+ import { canonicalizePath } from "./paths.js";
20
+ import { getDataDir } from "./config.js";
21
+ import { logger } from "./logger.js";
22
+ import { detectConflicts } from "./conflicts.js";
23
+ /** Recursive size of a directory in bytes; 0 when it doesn't exist. */
24
+ function dirSizeBytes(dir) {
25
+ let total = 0;
26
+ let entries;
27
+ try {
28
+ entries = readdirSync(dir, { withFileTypes: true });
29
+ }
30
+ catch {
31
+ return 0;
32
+ }
33
+ for (const e of entries) {
34
+ const p = join(dir, e.name);
35
+ try {
36
+ if (e.isDirectory())
37
+ total += dirSizeBytes(p);
38
+ else if (e.isFile())
39
+ total += statSync(p).size;
40
+ }
41
+ catch {
42
+ // racing deletes are fine — best-effort
43
+ }
44
+ }
45
+ return total;
46
+ }
47
+ export function registerDoctorFunction(sdk, kv) {
48
+ sdk.registerFunction("mem::doctor", async (data) => {
49
+ const root = data?.root ?? process.cwd();
50
+ // Project scope is canonicalized the same way search scopes recall, so
51
+ // /tmp vs /private/tmp (and trailing-slash/`..` spellings) of the same
52
+ // directory match. undefined => whole-brain audit across every project.
53
+ const projectFilter = typeof data?.project === "string" && data.project.trim().length > 0
54
+ ? canonicalizePath(data.project)
55
+ : undefined;
56
+ const report = {
57
+ total: 0,
58
+ safe: 0,
59
+ verified: 0,
60
+ sourcedUnverified: 0,
61
+ stale: [],
62
+ unsourced: [],
63
+ conflicts: [],
64
+ footprint: { bytesOnDisk: 0, dataDir: getDataDir(), oplogEntries: 0 },
65
+ };
66
+ const conflictCandidates = [];
67
+ const audit = (obs) => {
68
+ report.total++;
69
+ const verdict = classifyProvenance(obs.provenance, root);
70
+ const entry = { id: obs.id, title: obs.title, reason: verdict.reason };
71
+ switch (verdict.status) {
72
+ case "verified":
73
+ report.verified++;
74
+ report.safe++;
75
+ conflictCandidates.push(obs);
76
+ break;
77
+ case "sourced_unverified":
78
+ report.sourcedUnverified++;
79
+ report.safe++;
80
+ conflictCandidates.push(obs);
81
+ break;
82
+ case "stale":
83
+ report.stale.push(entry);
84
+ break;
85
+ default:
86
+ report.unsourced.push(entry);
87
+ }
88
+ };
89
+ // Memories (mem::remember scope).
90
+ try {
91
+ const memories = await kv.list(KV.memories);
92
+ for (const m of memories) {
93
+ if (m.isLatest === false)
94
+ continue;
95
+ if (projectFilter &&
96
+ m.project &&
97
+ canonicalizePath(m.project) !== projectFilter)
98
+ continue;
99
+ audit(memoryToObservation(m));
100
+ }
101
+ }
102
+ catch (err) {
103
+ logger.warn("doctor: failed to load memories", {
104
+ error: err instanceof Error ? err.message : String(err),
105
+ });
106
+ }
107
+ // Per-session observations, optionally scoped by project/cwd.
108
+ const sessions = await kv.list(KV.sessions).catch(() => []);
109
+ for (const s of sessions) {
110
+ if (projectFilter &&
111
+ s.project &&
112
+ canonicalizePath(s.project) !== projectFilter)
113
+ continue;
114
+ const obs = await kv
115
+ .list(KV.observations(s.id))
116
+ .catch(() => []);
117
+ for (const o of obs)
118
+ audit(o);
119
+ }
120
+ report.conflicts = detectConflicts(conflictCandidates);
121
+ // Footprint: whole-data-dir size + oplog length. Best-effort — a
122
+ // failure here must never sink the audit itself.
123
+ try {
124
+ const dataDir = getDataDir();
125
+ const { count } = await sdk.trigger({ function_id: "state::oplog-count", payload: {} });
126
+ report.footprint = {
127
+ bytesOnDisk: dirSizeBytes(dataDir),
128
+ dataDir,
129
+ oplogEntries: count,
130
+ };
131
+ }
132
+ catch {
133
+ // leave the zero footprint from initialization
134
+ }
135
+ return report;
136
+ });
137
+ }
@@ -0,0 +1,3 @@
1
+ import type { ISdk } from "../kernel/index.js";
2
+ import type { StateKV } from "../state/kv.js";
3
+ export declare function registerForgetFunction(sdk: ISdk, kv: StateKV): void;
@@ -0,0 +1,87 @@
1
+ //
2
+ // mem::auto-forget — retention sweep. Without it the store grows without
3
+ // bound and recall slows as it fills with stale, never-touched entries.
4
+ //
5
+ // An observation is forgotten when ALL hold: it is older than the TTL, has
6
+ // never been accessed, and its importance is below the floor. Forgetting
7
+ // removes it from KV, the BM25 index, the vector index, and its access log
8
+ // in lockstep so the three stay consistent. High-importance or
9
+ // recently-accessed memories are always kept.
10
+ //
11
+ // Tuning (env): MEMWARDEN_FORGET_TTL_DAYS (default 30),
12
+ // MEMWARDEN_FORGET_IMPORTANCE_FLOOR (default 0.3). The sweep cadence and
13
+ // on/off live in the boot timers (AUTO_FORGET_*).
14
+ import { KV } from "../state/schema.js";
15
+ import { getSearchIndex, vectorIndexRemove } from "./search.js";
16
+ import { getAccessLog, deleteAccessLog } from "./access-tracker.js";
17
+ import { logger } from "./logger.js";
18
+ function ttlMs() {
19
+ const days = parseInt(process.env.MEMWARDEN_FORGET_TTL_DAYS ?? "30", 10);
20
+ return (Number.isFinite(days) && days > 0 ? days : 30) * 24 * 60 * 60 * 1000;
21
+ }
22
+ function importanceFloor() {
23
+ const raw = parseFloat(process.env.MEMWARDEN_FORGET_IMPORTANCE_FLOOR ?? "0.3");
24
+ return Number.isFinite(raw) ? raw : 0.3;
25
+ }
26
+ export function registerForgetFunction(sdk, kv) {
27
+ sdk.registerFunction("mem::auto-forget", async (data) => {
28
+ const now = typeof data?.now === "number" ? data.now : Date.now();
29
+ const cutoff = now - ttlMs();
30
+ const floor = importanceFloor();
31
+ let scanned = 0;
32
+ let forgotten = 0;
33
+ let sessions;
34
+ try {
35
+ sessions = await kv.list(KV.sessions);
36
+ }
37
+ catch {
38
+ return { scanned: 0, forgotten: 0 };
39
+ }
40
+ const idx = getSearchIndex();
41
+ for (const session of sessions) {
42
+ let observations;
43
+ try {
44
+ observations = await kv.list(KV.observations(session.id));
45
+ }
46
+ catch {
47
+ continue;
48
+ }
49
+ for (const obs of observations) {
50
+ scanned++;
51
+ const ts = new Date(obs.timestamp).getTime();
52
+ // Keep if newer than the cutoff, or if the timestamp is unparseable
53
+ // (never forget on bad data).
54
+ if (Number.isNaN(ts) || ts > cutoff)
55
+ continue;
56
+ if (obs.importance >= floor)
57
+ continue;
58
+ const access = await getAccessLog(kv, obs.id);
59
+ if (access.count > 0)
60
+ continue;
61
+ // Forget: remove from every index in lockstep.
62
+ try {
63
+ await kv.delete(KV.observations(session.id), obs.id);
64
+ idx.remove(obs.id);
65
+ vectorIndexRemove(obs.id);
66
+ await deleteAccessLog(kv, obs.id);
67
+ forgotten++;
68
+ }
69
+ catch (err) {
70
+ logger.warn("auto-forget: failed to remove observation", {
71
+ obsId: obs.id,
72
+ error: err instanceof Error ? err.message : String(err),
73
+ });
74
+ }
75
+ }
76
+ }
77
+ // cutoff is logged for observability of the retention window.
78
+ if (forgotten > 0) {
79
+ logger.info("auto-forget swept stale memories", {
80
+ scanned,
81
+ forgotten,
82
+ cutoff: new Date(cutoff).toISOString(),
83
+ });
84
+ }
85
+ return { scanned, forgotten };
86
+ });
87
+ }
@@ -0,0 +1,17 @@
1
+ import { SearchIndex } from "./search-index.js";
2
+ import type { EmbeddingProvider, HybridSearchResult, VectorIndexLike } from "./types.js";
3
+ import type { StateKV } from "../state/kv.js";
4
+ export declare class HybridSearch {
5
+ private bm25;
6
+ private vector;
7
+ private embeddingProvider;
8
+ private kv;
9
+ private bm25Weight;
10
+ private vectorWeight;
11
+ private graphWeight;
12
+ constructor(bm25: SearchIndex, vector: VectorIndexLike | null, embeddingProvider: EmbeddingProvider | null, kv: StateKV, bm25Weight?: number, vectorWeight?: number, graphWeight?: number);
13
+ search(query: string, limit?: number): Promise<HybridSearchResult[]>;
14
+ private tripleStreamSearch;
15
+ private diversifyBySession;
16
+ private enrichResults;
17
+ }