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.
- package/LICENSE +202 -0
- package/README.md +402 -0
- package/dist/bundle/bundle.d.ts +28 -0
- package/dist/bundle/bundle.js +85 -0
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +593 -0
- package/dist/cli/connect.d.ts +63 -0
- package/dist/cli/connect.js +121 -0
- package/dist/cli/hook.d.ts +24 -0
- package/dist/cli/hook.js +186 -0
- package/dist/cli/tools.d.ts +47 -0
- package/dist/cli/tools.js +246 -0
- package/dist/daemon/ensure.d.ts +12 -0
- package/dist/daemon/ensure.js +54 -0
- package/dist/daemon/service.d.ts +15 -0
- package/dist/daemon/service.js +210 -0
- package/dist/embedding/index.d.ts +10 -0
- package/dist/embedding/index.js +33 -0
- package/dist/embedding/local-embedding.d.ts +14 -0
- package/dist/embedding/local-embedding.js +80 -0
- package/dist/functions/access-tracker.d.ts +13 -0
- package/dist/functions/access-tracker.js +92 -0
- package/dist/functions/audit.d.ts +46 -0
- package/dist/functions/audit.js +0 -0
- package/dist/functions/cjk-segmenter.d.ts +6 -0
- package/dist/functions/cjk-segmenter.js +120 -0
- package/dist/functions/compress-synthetic.d.ts +2 -0
- package/dist/functions/compress-synthetic.js +104 -0
- package/dist/functions/config.d.ts +68 -0
- package/dist/functions/config.js +231 -0
- package/dist/functions/conflicts.d.ts +19 -0
- package/dist/functions/conflicts.js +328 -0
- package/dist/functions/context.d.ts +3 -0
- package/dist/functions/context.js +155 -0
- package/dist/functions/dedup.d.ts +11 -0
- package/dist/functions/dedup.js +51 -0
- package/dist/functions/dejafix.d.ts +96 -0
- package/dist/functions/dejafix.js +356 -0
- package/dist/functions/doctor.d.ts +29 -0
- package/dist/functions/doctor.js +137 -0
- package/dist/functions/forget.d.ts +3 -0
- package/dist/functions/forget.js +87 -0
- package/dist/functions/hybrid-search.d.ts +17 -0
- package/dist/functions/hybrid-search.js +205 -0
- package/dist/functions/index.d.ts +32 -0
- package/dist/functions/index.js +44 -0
- package/dist/functions/keyed-mutex.d.ts +1 -0
- package/dist/functions/keyed-mutex.js +21 -0
- package/dist/functions/logger.d.ts +6 -0
- package/dist/functions/logger.js +37 -0
- package/dist/functions/memory-utils.d.ts +2 -0
- package/dist/functions/memory-utils.js +29 -0
- package/dist/functions/observe.d.ts +5 -0
- package/dist/functions/observe.js +326 -0
- package/dist/functions/paths.d.ts +1 -0
- package/dist/functions/paths.js +38 -0
- package/dist/functions/privacy.d.ts +1 -0
- package/dist/functions/privacy.js +30 -0
- package/dist/functions/provenance.d.ts +9 -0
- package/dist/functions/provenance.js +57 -0
- package/dist/functions/quantized-vector-index.d.ts +60 -0
- package/dist/functions/quantized-vector-index.js +275 -0
- package/dist/functions/receipt.d.ts +31 -0
- package/dist/functions/receipt.js +95 -0
- package/dist/functions/search-index.d.ts +27 -0
- package/dist/functions/search-index.js +217 -0
- package/dist/functions/search.d.ts +25 -0
- package/dist/functions/search.js +523 -0
- package/dist/functions/stemmer.d.ts +1 -0
- package/dist/functions/stemmer.js +110 -0
- package/dist/functions/synonyms.d.ts +1 -0
- package/dist/functions/synonyms.js +69 -0
- package/dist/functions/turboquant.d.ts +53 -0
- package/dist/functions/turboquant.js +278 -0
- package/dist/functions/types.d.ts +217 -0
- package/dist/functions/types.js +8 -0
- package/dist/functions/vector-index.d.ts +25 -0
- package/dist/functions/vector-index.js +125 -0
- package/dist/functions/vector-persistence.d.ts +14 -0
- package/dist/functions/vector-persistence.js +75 -0
- package/dist/functions/verify.d.ts +13 -0
- package/dist/functions/verify.js +104 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +219 -0
- package/dist/kernel/http.d.ts +24 -0
- package/dist/kernel/http.js +261 -0
- package/dist/kernel/index.d.ts +19 -0
- package/dist/kernel/index.js +21 -0
- package/dist/kernel/kernel.d.ts +80 -0
- package/dist/kernel/kernel.js +297 -0
- package/dist/kernel/pubsub.d.ts +21 -0
- package/dist/kernel/pubsub.js +38 -0
- package/dist/kernel/types.d.ts +139 -0
- package/dist/kernel/types.js +20 -0
- package/dist/mcp/bin.d.ts +2 -0
- package/dist/mcp/bin.js +27 -0
- package/dist/mcp/server.d.ts +34 -0
- package/dist/mcp/server.js +377 -0
- package/dist/observability/metrics.d.ts +26 -0
- package/dist/observability/metrics.js +104 -0
- package/dist/proxy/server.d.ts +30 -0
- package/dist/proxy/server.js +331 -0
- package/dist/state/kv.d.ts +41 -0
- package/dist/state/kv.js +50 -0
- package/dist/state/oplog.d.ts +25 -0
- package/dist/state/oplog.js +57 -0
- package/dist/state/schema.d.ts +60 -0
- package/dist/state/schema.js +88 -0
- package/dist/state/store-libsql.d.ts +46 -0
- package/dist/state/store-libsql.js +263 -0
- package/dist/state/store-memory.d.ts +23 -0
- package/dist/state/store-memory.js +121 -0
- package/dist/state/store.d.ts +87 -0
- package/dist/state/store.js +58 -0
- package/dist/triggers/api.d.ts +14 -0
- package/dist/triggers/api.js +510 -0
- package/dist/triggers/auth.d.ts +1 -0
- package/dist/triggers/auth.js +13 -0
- 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,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, "&")
|
|
19
|
+
.replace(/"/g, """)
|
|
20
|
+
.replace(/</g, "<")
|
|
21
|
+
.replace(/>/g, ">");
|
|
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;
|