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,328 @@
1
+ const STOP_WORDS = new Set([
2
+ "a",
3
+ "an",
4
+ "and",
5
+ "are",
6
+ "as",
7
+ "at",
8
+ "be",
9
+ "by",
10
+ "for",
11
+ "from",
12
+ "in",
13
+ "is",
14
+ "it",
15
+ "of",
16
+ "on",
17
+ "or",
18
+ "the",
19
+ "this",
20
+ "to",
21
+ "with",
22
+ ]);
23
+ // Tokens that negate the claim they sit in. Clause-local: a negation only
24
+ // flips polarity when it appears in the SAME clause as the matched
25
+ // subject/relation (see extractClaims), so a subordinate "…which is not
26
+ // deprecated" can't flip the polarity of the main claim.
27
+ const NEGATION_RE = /\b(no longer|not|never|without|doesn't|does not|do not|isn't|is not|aren't|are not|disabled)\b/i;
28
+ // Generic "container" subjects that legitimately hold many independent facts
29
+ // ("the project uses zod" AND "the project uses vitest" are both true). For
30
+ // these, two different positive values are NOT treated as a contradiction
31
+ // unless they share a qualifier (the same attribute, e.g. "…for passwords").
32
+ // A SPECIFIC subject (auth, cache, runtime, …) is single-valued, so two
33
+ // different values for it DO contradict.
34
+ const CONTAINER_SUBJECTS = new Set([
35
+ "project",
36
+ "repo",
37
+ "repository",
38
+ "codebase",
39
+ "code base",
40
+ "app",
41
+ "application",
42
+ "system",
43
+ "service",
44
+ "server",
45
+ "stack",
46
+ "we",
47
+ "they",
48
+ "it",
49
+ ]);
50
+ const CLAIM_PATTERNS = [
51
+ {
52
+ relation: "uses",
53
+ re: /\b(.{2,80}?)\b(?:does not use|doesn't use|do not use|no longer uses|no longer use|never uses|never use|uses|use)\b\s+(.{2,100})/i,
54
+ },
55
+ {
56
+ relation: "is",
57
+ re: /\b(.{2,80}?)\b(?:is not|isn't|are not|aren't|is|are|was|were|becomes|became)\b\s+(.{2,100})/i,
58
+ },
59
+ {
60
+ relation: "configured",
61
+ re: /\b(.{2,80}?)\b(?:defaults to|default is|configured to|set to|runs on|stores in|writes to)\b\s+(.{2,100})/i,
62
+ },
63
+ ];
64
+ function normalize(raw) {
65
+ return raw
66
+ .toLowerCase()
67
+ .replace(/["'`{}[\](),;:]/g, " ")
68
+ .replace(/[_/\\|=]+/g, " ")
69
+ .replace(/\s+/g, " ")
70
+ .trim();
71
+ }
72
+ function words(raw) {
73
+ return normalize(raw)
74
+ .split(" ")
75
+ .map((w) => w.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ""))
76
+ .filter((w) => w.length > 1 && !STOP_WORDS.has(w));
77
+ }
78
+ function subjectKey(raw) {
79
+ const ws = words(raw);
80
+ return ws.slice(-5).join(" ");
81
+ }
82
+ function valueWords(raw) {
83
+ const firstClause = raw.split(/[.!?\n|]/)[0] ?? raw;
84
+ return words(firstClause)
85
+ .filter((w) => !["now", "currently", "instead", "rather", "than"].includes(w))
86
+ .slice(0, 8);
87
+ }
88
+ function valueKey(raw) {
89
+ return valueWords(raw).join(" ");
90
+ }
91
+ // Split into clauses so negation can be scoped clause-locally. Clause
92
+ // boundaries are sentence terminators AND coordinating/subordinating breaks
93
+ // (commas, "which", "but", "although", "while", "however") so that a "not"
94
+ // living in a subordinate clause does not reach the main claim's clause.
95
+ function clausesOf(sentence) {
96
+ return sentence
97
+ .split(/,|\bwhich\b|\bwho\b|\bwhere\b|\bbut\b|\balthough\b|\bwhile\b|\bhowever\b|\bwhereas\b|\bthough\b/i)
98
+ .map((c) => c.trim())
99
+ .filter((c) => c.length > 0);
100
+ }
101
+ function splitClaimsText(obs) {
102
+ const text = [obs.title, obs.subtitle, ...obs.facts, obs.narrative]
103
+ .filter((s) => typeof s === "string" && s.trim().length > 0)
104
+ .join(" | ");
105
+ return text
106
+ .split(/[.!?\n|]+/)
107
+ .map((s) => s.trim())
108
+ .filter((s) => s.length >= 8 && s.length <= 240);
109
+ }
110
+ function extractClaims(obs) {
111
+ const claims = [];
112
+ for (const sentence of splitClaimsText(obs)) {
113
+ for (const pattern of CLAIM_PATTERNS) {
114
+ const match = pattern.re.exec(sentence);
115
+ if (!match)
116
+ continue;
117
+ const subject = subjectKey(match[1] ?? "");
118
+ const value = valueKey(match[2] ?? "");
119
+ if (!subject || !value)
120
+ continue;
121
+ // Clause-local negation: only the clause that actually contains the
122
+ // matched subject+value can flip polarity. A "not"/"never"/"disabled"
123
+ // anywhere else in the sentence (e.g. a subordinate clause) is ignored.
124
+ const matchedText = normalize(match[0] ?? sentence);
125
+ const owningClause = clausesOf(sentence).find((c) => {
126
+ const n = normalize(c);
127
+ return n.length > 0 && (matchedText.includes(n) || n.includes(value));
128
+ }) ?? sentence;
129
+ claims.push({
130
+ subject,
131
+ relation: pattern.relation,
132
+ value,
133
+ valueTokens: valueWords(match[2] ?? ""),
134
+ polarity: NEGATION_RE.test(owningClause) ? "negative" : "positive",
135
+ text: normalize(sentence),
136
+ obs,
137
+ });
138
+ break;
139
+ }
140
+ }
141
+ return claims;
142
+ }
143
+ function jaccard(a, b) {
144
+ const left = new Set(a);
145
+ const right = new Set(b);
146
+ if (left.size === 0 || right.size === 0)
147
+ return 0;
148
+ let shared = 0;
149
+ for (const w of left)
150
+ if (right.has(w))
151
+ shared++;
152
+ return shared / (left.size + right.size - shared);
153
+ }
154
+ // True when the two phrases are an abbreviation/acronym pair, e.g.
155
+ // "jwts" <-> "json web tokens", "k8s"-style initialisms aside. We compare the
156
+ // initials of the multi-word phrase against the (de-pluralized) short token.
157
+ function acronymMatch(a, b) {
158
+ const tryPair = (acr, phrase) => {
159
+ if (acr.length !== 1 || phrase.length < 2)
160
+ return false;
161
+ const token = acr[0].replace(/s$/, "");
162
+ if (token.length < 2)
163
+ return false;
164
+ const initials = phrase.map((w) => w[0] ?? "").join("");
165
+ return token === initials;
166
+ };
167
+ return tryPair(a, b) || tryPair(b, a);
168
+ }
169
+ // "Same fact, reworded" — the two values are NOT in genuine value-conflict.
170
+ // Equal, one contains the other, an acronym/abbreviation pair, or high
171
+ // token-overlap (>= 0.5) all count as the same fact.
172
+ function valuesCompatible(a, b) {
173
+ if (a.value === b.value)
174
+ return true;
175
+ if (a.value.includes(b.value) || b.value.includes(a.value))
176
+ return true;
177
+ if (acronymMatch(a.valueTokens, b.valueTokens))
178
+ return true;
179
+ return jaccard(a.valueTokens, b.valueTokens) >= 0.5;
180
+ }
181
+ // On/off state words that carry the polarity themselves rather than naming a
182
+ // distinct value: "cache is enabled" vs "cache is disabled" is the SAME
183
+ // attribute toggled, not two different values. Stripped before the
184
+ // polarity-conflict comparison so the residual values line up.
185
+ const STATE_WORDS = new Set([
186
+ "enabled",
187
+ "disabled",
188
+ "on",
189
+ "off",
190
+ "active",
191
+ "inactive",
192
+ "present",
193
+ "absent",
194
+ ]);
195
+ function stripStateTokens(tokens) {
196
+ return tokens.filter((t) => !STATE_WORDS.has(t));
197
+ }
198
+ // Are the two claims about the SAME thing such that opposite polarity is a
199
+ // real contradiction? True when the residual values (state words removed)
200
+ // line up — including the common "enabled"/"disabled" case where both reduce
201
+ // to nothing and the subject IS the toggled thing.
202
+ function samePolarityTarget(a, b) {
203
+ if (valuesCompatible(a, b))
204
+ return true;
205
+ const ra = stripStateTokens(a.valueTokens);
206
+ const rb = stripStateTokens(b.valueTokens);
207
+ if (ra.length === 0 && rb.length === 0)
208
+ return true; // pure state toggle
209
+ if (ra.length === 0 || rb.length === 0) {
210
+ // One side is a pure state word ("disabled"); the other carries the same
211
+ // residual the toggle applies to.
212
+ return jaccard(ra, rb) > 0 || ra.join(" ") === rb.join(" ");
213
+ }
214
+ return jaccard(ra, rb) >= 0.5;
215
+ }
216
+ // Do the two values share a qualifier token (the same attribute)? e.g.
217
+ // "bcrypt for passwords" vs "md5 for passwords" share "passwords". Used to
218
+ // turn a generic-container subject's differing values into a real conflict.
219
+ function shareQualifier(a, b) {
220
+ const right = new Set(b.valueTokens);
221
+ return a.valueTokens.some((t) => right.has(t));
222
+ }
223
+ function isContainerSubject(subject) {
224
+ if (CONTAINER_SUBJECTS.has(subject))
225
+ return true;
226
+ // Multi-word subjects ending in a container head ("the api server") still
227
+ // count as a container.
228
+ const last = subject.split(" ").pop() ?? subject;
229
+ return CONTAINER_SUBJECTS.has(last);
230
+ }
231
+ function compareByTime(a, b) {
232
+ const left = Date.parse(a.timestamp);
233
+ const right = Date.parse(b.timestamp);
234
+ if (Number.isFinite(left) && Number.isFinite(right) && left !== right) {
235
+ return left - right;
236
+ }
237
+ return a.id.localeCompare(b.id);
238
+ }
239
+ function conflictBetween(a, b) {
240
+ if (a.obs.id === b.obs.id)
241
+ return null;
242
+ if (a.subject !== b.subject || a.relation !== b.relation)
243
+ return null;
244
+ const compatible = valuesCompatible(a, b);
245
+ // Polarity conflict: the SAME thing asserted both ways (positive vs negative)
246
+ // — e.g. "cache is enabled" vs "cache is disabled", or "uses X" vs "does not
247
+ // use X". State words ("enabled"/"disabled") carry the polarity themselves,
248
+ // so we compare the residual target rather than the raw value strings.
249
+ const polarityConflict = a.polarity !== b.polarity && samePolarityTarget(a, b);
250
+ // Value conflict: two POSITIVE claims with genuinely different values for the
251
+ // same subject+relation. Only a real contradiction when:
252
+ // - the values aren't the same fact reworded (compatible == false), AND
253
+ // - they aren't two different attributes of one subject. For the
254
+ // "configured" relation (runs on / set to / defaults to …) values with
255
+ // no shared token are different attributes (port vs host), not a clash.
256
+ // - for a generic CONTAINER subject (project/repo/app/…) two unrelated
257
+ // values are independent facts unless they share a qualifier (the same
258
+ // attribute). A SPECIFIC subject is single-valued, so any differing
259
+ // value contradicts.
260
+ let valueConflict = false;
261
+ if (!compatible && a.polarity === "positive" && b.polarity === "positive") {
262
+ const shared = shareQualifier(a, b);
263
+ if (a.relation === "configured" && !shared) {
264
+ valueConflict = false; // different attributes of the same subject
265
+ }
266
+ else if (isContainerSubject(a.subject) && !shared) {
267
+ valueConflict = false; // independent facts about a container
268
+ }
269
+ else {
270
+ valueConflict = true;
271
+ }
272
+ }
273
+ if (!polarityConflict && !valueConflict)
274
+ return null;
275
+ const [older, newer] = compareByTime(a.obs, b.obs) <= 0 ? [a, b] : [b, a];
276
+ return {
277
+ olderId: older.obs.id,
278
+ olderTitle: older.obs.title,
279
+ newerId: newer.obs.id,
280
+ newerTitle: newer.obs.title,
281
+ subject: older.subject,
282
+ olderClaim: older.text,
283
+ newerClaim: newer.text,
284
+ reason: polarityConflict
285
+ ? `same subject "${older.subject}" changed polarity`
286
+ : `same subject "${older.subject}" has incompatible values`,
287
+ };
288
+ }
289
+ /**
290
+ * Advisory contradiction report for mem::doctor. Deliberately conservative:
291
+ * simple subject/relation/value claims, clause-local negation, and a high bar
292
+ * for value conflicts so reworded facts, abbreviations, and different
293
+ * attributes of one subject don't fire. NEVER used to drop memory from recall
294
+ * — recall only firewalls STALE memory.
295
+ */
296
+ export function detectConflicts(observations, limit = 20) {
297
+ const groups = new Map();
298
+ for (const obs of observations) {
299
+ for (const claim of extractClaims(obs)) {
300
+ const key = `${claim.relation}:${claim.subject}`;
301
+ const group = groups.get(key);
302
+ if (group)
303
+ group.push(claim);
304
+ else
305
+ groups.set(key, [claim]);
306
+ }
307
+ }
308
+ const conflicts = [];
309
+ const seen = new Set();
310
+ for (const group of groups.values()) {
311
+ const sorted = group.sort((a, b) => compareByTime(a.obs, b.obs));
312
+ for (let i = 0; i < sorted.length; i++) {
313
+ for (let j = i + 1; j < sorted.length; j++) {
314
+ const conflict = conflictBetween(sorted[i], sorted[j]);
315
+ if (!conflict)
316
+ continue;
317
+ const key = `${conflict.olderId}:${conflict.newerId}:${conflict.subject}`;
318
+ if (seen.has(key))
319
+ continue;
320
+ seen.add(key);
321
+ conflicts.push(conflict);
322
+ if (conflicts.length >= limit)
323
+ return conflicts;
324
+ }
325
+ }
326
+ }
327
+ return conflicts;
328
+ }
@@ -0,0 +1,3 @@
1
+ import type { ISdk } from "../kernel/index.js";
2
+ import type { StateKV } from "../state/kv.js";
3
+ export declare function registerContextFunction(sdk: ISdk, kv: StateKV, tokenBudget: number): void;
@@ -0,0 +1,155 @@
1
+ //
2
+ // Recency-packed context assembly (mem::context). Gathers pinned slots, the
3
+ // project profile, ranked lessons, and recent same-project sessions (rendered
4
+ // from their summary, or from important observations when there is none),
5
+ // sorts the blocks newest-first, and greedily packs them under a token budget
6
+ // inside a <memwarden-context project="..."> wrapper. Returns
7
+ // {context, blocks, tokens}.
8
+ //
9
+ // Memory slots are an optional, off-by-default feature; when disabled the
10
+ // pinned-slots block is empty.
11
+ import { KV } from "../state/schema.js";
12
+ import { recordAccessBatch } from "./access-tracker.js";
13
+ import { isSlotsEnabled } from "./config.js";
14
+ import { logger } from "./logger.js";
15
+ import { metrics, estimateTokens } from "../observability/metrics.js";
16
+ function escapeXmlAttr(s) {
17
+ return s
18
+ .replace(/&/g, "&amp;")
19
+ .replace(/"/g, "&quot;")
20
+ .replace(/</g, "&lt;")
21
+ .replace(/>/g, "&gt;");
22
+ }
23
+ function block(type, content, recency, sourceIds) {
24
+ const b = { type, content, tokens: estimateTokens(content), recency };
25
+ if (sourceIds && sourceIds.length > 0)
26
+ b.sourceIds = sourceIds;
27
+ return b;
28
+ }
29
+ // Slots feature is not part of the core path; no pinned content when disabled.
30
+ async function renderPinnedSlots(_kv) {
31
+ return isSlotsEnabled() ? "" : "";
32
+ }
33
+ function profileBlock(profile) {
34
+ if (!profile)
35
+ return null;
36
+ const parts = [];
37
+ if (profile.topConcepts.length > 0) {
38
+ parts.push(`Concepts: ${profile.topConcepts.slice(0, 8).map((c) => c.concept).join(", ")}`);
39
+ }
40
+ if (profile.topFiles.length > 0) {
41
+ parts.push(`Key files: ${profile.topFiles.slice(0, 5).map((f) => f.file).join(", ")}`);
42
+ }
43
+ if (profile.conventions.length > 0) {
44
+ parts.push(`Conventions: ${profile.conventions.join("; ")}`);
45
+ }
46
+ if (profile.commonErrors.length > 0) {
47
+ parts.push(`Common errors: ${profile.commonErrors.slice(0, 3).join("; ")}`);
48
+ }
49
+ if (parts.length === 0)
50
+ return null;
51
+ return block("memory", `## Project Profile\n${parts.join("\n")}`, new Date(profile.updatedAt).getTime());
52
+ }
53
+ function lessonsBlock(lessons, project) {
54
+ const relevant = lessons
55
+ .filter((l) => !l.deleted && (!l.project || l.project === project))
56
+ .sort((a, b) => {
57
+ const sa = (a.project === project ? 1.5 : 1) * a.confidence;
58
+ const sb = (b.project === project ? 1.5 : 1) * b.confidence;
59
+ return sb - sa;
60
+ })
61
+ .slice(0, 10);
62
+ if (relevant.length === 0)
63
+ return null;
64
+ const items = relevant
65
+ .map((l) => `- (${l.confidence.toFixed(2)}) ${l.content}${l.context ? ` — ${l.context}` : ""}`)
66
+ .join("\n");
67
+ const recency = relevant.reduce((acc, l) => {
68
+ const t = new Date(l.lastReinforcedAt || l.updatedAt).getTime();
69
+ return t > acc ? t : acc;
70
+ }, 0);
71
+ return block("memory", `## Lessons Learned\n${items}`, recency, relevant.map((l) => l.id));
72
+ }
73
+ export function registerContextFunction(sdk, kv, tokenBudget) {
74
+ sdk.registerFunction("mem::context", async (data) => {
75
+ const startedAt = performance.now();
76
+ const budget = data.budget || tokenBudget;
77
+ const blocks = [];
78
+ const [slotContent, profile, lessons] = await Promise.all([
79
+ renderPinnedSlots(kv).catch(() => ""),
80
+ kv.get(KV.profiles, data.project).catch(() => null),
81
+ kv.list(KV.lessons).catch(() => []),
82
+ ]);
83
+ if (slotContent)
84
+ blocks.push(block("memory", slotContent, Date.now()));
85
+ const pb = profileBlock(profile);
86
+ if (pb)
87
+ blocks.push(pb);
88
+ const lb = lessonsBlock(lessons, data.project);
89
+ if (lb)
90
+ blocks.push(lb);
91
+ // Recent sessions in the same project (excluding the current one).
92
+ const sessions = (await kv.list(KV.sessions))
93
+ .filter((s) => s.project === data.project && s.id !== data.sessionId)
94
+ .sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime())
95
+ .slice(0, 10);
96
+ const summaries = await Promise.all(sessions.map((s) => kv.get(KV.summaries, s.id).catch(() => null)));
97
+ // A session renders from its summary, or falls back to its important
98
+ // observations when no summary exists.
99
+ const needObs = [];
100
+ sessions.forEach((_, i) => {
101
+ const summary = summaries[i];
102
+ if (summary) {
103
+ const content = `## ${summary.title}\n${summary.narrative}\nDecisions: ${summary.keyDecisions.join("; ")}\nFiles: ${summary.filesModified.join(", ")}`;
104
+ blocks.push(block("summary", content, new Date(summary.createdAt).getTime()));
105
+ }
106
+ else {
107
+ needObs.push(i);
108
+ }
109
+ });
110
+ const obsLists = await Promise.all(needObs.map((i) => kv
111
+ .list(KV.observations(sessions[i].id))
112
+ .catch(() => [])));
113
+ needObs.forEach((sessionIdx, j) => {
114
+ const session = sessions[sessionIdx];
115
+ const important = (obsLists[j] ?? []).filter((o) => o.title && o.importance >= 5);
116
+ if (important.length === 0)
117
+ return;
118
+ const top = important.sort((a, b) => b.importance - a.importance).slice(0, 5);
119
+ const items = top.map((o) => `- [${o.type}] ${o.title}: ${o.narrative}`).join("\n");
120
+ const content = `## Session ${session.id.slice(0, 8)} (${session.startedAt})\n${items}`;
121
+ blocks.push(block("observation", content, new Date(session.startedAt).getTime(), top.map((o) => o.id)));
122
+ });
123
+ // Newest first, then greedily pack under the budget.
124
+ blocks.sort((a, b) => b.recency - a.recency);
125
+ const candidateTokens = blocks.reduce((sum, b) => sum + b.tokens, 0);
126
+ const header = `<memwarden-context project="${escapeXmlAttr(data.project)}">`;
127
+ const footer = `</memwarden-context>`;
128
+ let usedTokens = estimateTokens(header) + estimateTokens(footer);
129
+ const selected = [];
130
+ const accessedIds = [];
131
+ for (const b of blocks) {
132
+ if (usedTokens + b.tokens > budget)
133
+ continue;
134
+ selected.push(b.content);
135
+ usedTokens += b.tokens;
136
+ if (b.sourceIds)
137
+ accessedIds.push(...b.sourceIds);
138
+ }
139
+ if (accessedIds.length > 0)
140
+ void recordAccessBatch(kv, accessedIds);
141
+ const elapsed = performance.now() - startedAt;
142
+ if (selected.length === 0) {
143
+ metrics.recordContext(candidateTokens, 0, elapsed);
144
+ logger.info("No context available", { project: data.project });
145
+ return { context: "", blocks: 0, tokens: 0 };
146
+ }
147
+ metrics.recordContext(candidateTokens, usedTokens, elapsed);
148
+ logger.info("Context generated", { blocks: selected.length, tokens: usedTokens });
149
+ return {
150
+ context: `${header}\n${selected.join("\n\n")}\n${footer}`,
151
+ blocks: selected.length,
152
+ tokens: usedTokens,
153
+ };
154
+ });
155
+ }
@@ -0,0 +1,11 @@
1
+ export declare class DedupMap {
2
+ private seen;
3
+ private sweep;
4
+ constructor();
5
+ computeHash(sessionId: string, toolName: string, toolInput: unknown): string;
6
+ isDuplicate(hash: string): boolean;
7
+ record(hash: string): void;
8
+ stop(): void;
9
+ get size(): number;
10
+ private evictExpired;
11
+ }
@@ -0,0 +1,51 @@
1
+ //
2
+ // A short-lived dedup set for the observe write path. Identical tool calls
3
+ // (same session + tool + input) seen again within a 5-minute window are
4
+ // suppressed, so a retried or replayed event is not stored twice. A periodic
5
+ // sweep clears expired keys; its timer is unref'd so it never holds the
6
+ // process open.
7
+ import { createHash } from "node:crypto";
8
+ const WINDOW_MS = 5 * 60 * 1000;
9
+ const SWEEP_MS = 60_000;
10
+ const MAX_INPUT = 500;
11
+ export class DedupMap {
12
+ // hash -> expiry (epoch ms)
13
+ seen = new Map();
14
+ sweep;
15
+ constructor() {
16
+ this.sweep = setInterval(() => this.evictExpired(), SWEEP_MS);
17
+ this.sweep.unref();
18
+ }
19
+ computeHash(sessionId, toolName, toolInput) {
20
+ const raw = typeof toolInput === "string" ? toolInput : JSON.stringify(toolInput ?? "");
21
+ return createHash("sha256")
22
+ .update(`${sessionId}:${toolName}:${raw.slice(0, MAX_INPUT)}`)
23
+ .digest("hex");
24
+ }
25
+ isDuplicate(hash) {
26
+ const expiry = this.seen.get(hash);
27
+ if (expiry === undefined)
28
+ return false;
29
+ if (Date.now() > expiry) {
30
+ this.seen.delete(hash);
31
+ return false;
32
+ }
33
+ return true;
34
+ }
35
+ record(hash) {
36
+ this.seen.set(hash, Date.now() + WINDOW_MS);
37
+ }
38
+ stop() {
39
+ clearInterval(this.sweep);
40
+ }
41
+ get size() {
42
+ return this.seen.size;
43
+ }
44
+ evictExpired() {
45
+ const now = Date.now();
46
+ for (const [hash, expiry] of this.seen) {
47
+ if (now > expiry)
48
+ this.seen.delete(hash);
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,96 @@
1
+ import type { ISdk } from "../kernel/index.js";
2
+ import type { StateKV } from "../state/kv.js";
3
+ import type { Provenance } from "./types.js";
4
+ export declare const DEJAFIX_SCOPE = "mem:dejafix";
5
+ /** A captured {error signature -> root cause + fix} record. */
6
+ export interface FixMemory {
7
+ /** Stable signature of the error this fix resolves. */
8
+ signature: string;
9
+ /** Id of the observation/record this fix was captured from. */
10
+ observationId: string;
11
+ /** Optional one-line root cause. */
12
+ rootCause?: string;
13
+ /** Short narrative of the fix that resolved the error. */
14
+ fix: string;
15
+ /** Evidence trail (files + fileHashes) reused by Verified Recall. */
16
+ provenance: Provenance;
17
+ /** Which agent recorded it (claude, codex, cursor, …). */
18
+ tool?: string;
19
+ /** Session it was recorded in. */
20
+ sessionId?: string;
21
+ /** Capture-time working directory (project scope). */
22
+ cwd: string;
23
+ /** ISO timestamp the fix was recorded. */
24
+ timestamp: string;
25
+ }
26
+ /** A FixMemory surfaced by lookup, annotated with a freshness badge. */
27
+ export interface VerifiedFix {
28
+ signature: string;
29
+ observationId: string;
30
+ rootCause?: string;
31
+ fix: string;
32
+ tool?: string;
33
+ sessionId?: string;
34
+ cwd: string;
35
+ timestamp: string;
36
+ /** "verified current" when all referenced files still hash-match; else
37
+ * "sourced, unverified". Stale fixes are never returned at all. */
38
+ badge: "verified current" | "sourced, unverified";
39
+ /** The underlying classifier status (verified | sourced_unverified). */
40
+ status: "verified" | "sourced_unverified";
41
+ }
42
+ /**
43
+ * Extract a STABLE signature from an error message, stack trace, or failing
44
+ * test output. Returns null when nothing recognizable as an error is present.
45
+ *
46
+ * The signature normalizes away volatile parts (absolute paths -> basename,
47
+ * line/column numbers, hex addresses, timestamps, UUIDs, ports, durations) and
48
+ * keeps the stable core: the error/exception class, the failing test name, and
49
+ * the key message tokens. Deterministic: same logical error -> same signature.
50
+ */
51
+ export declare function errorSignature(text: string): string | null;
52
+ /** True when `text` contains both an error AND resolution language — the
53
+ * shape that observe.ts treats as a recorded fix worth signature-tagging. */
54
+ export declare function looksLikeResolvedFix(text: string): boolean;
55
+ export interface RecordFixInput {
56
+ /** Error text (or its signature) the fix resolves. */
57
+ errorText?: string;
58
+ /** Precomputed signature (overrides errorText if both given). */
59
+ signature?: string;
60
+ observationId?: string;
61
+ rootCause?: string;
62
+ fix: string;
63
+ /** Files the fix touched/relied on; hashed under cwd for drift checks. */
64
+ files?: string[];
65
+ /** A fully-formed provenance (overrides files-based one if given). */
66
+ provenance?: Provenance;
67
+ tool?: string;
68
+ sessionId?: string;
69
+ cwd: string;
70
+ timestamp?: string;
71
+ }
72
+ /**
73
+ * Store a FixMemory under its signature. Returns the stored record, or null
74
+ * when no signature can be derived (nothing to key on). Hashes the referenced
75
+ * files at capture time so later recall can detect drift — exactly like
76
+ * observe.ts does for synthetic observations.
77
+ */
78
+ export declare function recordFix(kv: StateKV, input: RecordFixInput): Promise<FixMemory | null>;
79
+ /**
80
+ * Look up verified fixes for an error. Computes the signature, fetches the
81
+ * candidate FixMemories scoped to the caller's project (canonicalized cwd),
82
+ * runs each through classifyProvenance, and returns ONLY verified /
83
+ * sourced_unverified ones — never stale, never if referenced files vanished.
84
+ * Each is annotated with a freshness badge. Newest first.
85
+ */
86
+ export declare function lookupFix(kv: StateKV, errorText: string, cwd: string): Promise<VerifiedFix[]>;
87
+ /**
88
+ * Register the two Déjà Fix kernel functions:
89
+ * mem::dejafix_record — store a fix (input: errorText|signature, fix, …)
90
+ * mem::dejafix_lookup — surface verified fixes for an error (input:
91
+ * errorText, cwd)
92
+ *
93
+ * Both are thin, dependency-free, and go through the same StateKV chokepoint
94
+ * every other mem:: function uses, so they share the one persistence layer.
95
+ */
96
+ export declare function registerDejaFixFunctions(sdk: ISdk, kv: StateKV): void;