Repository navigation
Expand file tree
/
Copy pathmemory-invalidation.js
More file actions
449 lines (398 loc) · 17.3 KB
/
Copy pathmemory-invalidation.js
File metadata and controls
449 lines (398 loc) · 17.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
/**
* @module memory-invalidation
* @description Knowledge Invalidation & Temporal Fact Superseding Engine based on:
* "MobileMem: Learning from a Year of Mobile Experiences" (arXiv: 2608.13606).
*
* Provides first-class support for:
* 1. Tracking memory lifecycle states (ACTIVE, SUPERSEDED, INVALIDATED).
* 2. Fact superseding and lineage traversal (handling changed hardware specs, updated credentials, revised policies).
* 3. Temporal recency and contradiction filtering so agents never retrieve stale, invalidated knowledge.
* 4. Semantic contradiction detection before recording new memories.
*/
import crypto from 'crypto';
import { chatJson } from './llm.js';
/**
* Filter an arbitrary list of memory records to return only valid, non-superseded, non-invalidated records.
*
* @param {Array<object>} memories - List of memory objects from pgvector, SQLite, or in-memory arrays.
* @param {object} [options={}] - Filtering options.
* @param {boolean} [options.strictLineage=true] - If true, discards any record whose ID is marked as superseded by a newer record.
* @param {boolean} [options.dedupByEntity=false] - If true, keeps only the most recent active memory for each entity/key.
* @returns {Array<object>} Filtered list of active, authoritative memories.
*/
export function filterActiveMemories(memories = [], options = {}) {
if (!Array.isArray(memories)) return [];
const strictLineage = options.strictLineage !== false;
const dedupByEntity = !!options.dedupByEntity;
// 1. Identify all IDs that have been explicitly superseded or invalidated
const supersededIds = new Set();
const invalidatedIds = new Set();
for (const mem of memories) {
if (mem.supersedesId != null) {
supersededIds.add(mem.supersedesId);
}
if (mem.supersedes_id != null) {
supersededIds.add(mem.supersedes_id);
}
if (Array.isArray(mem.supersedesIds)) {
mem.supersedesIds.forEach(id => supersededIds.add(id));
}
if (Array.isArray(mem.supersedes_ids)) {
mem.supersedes_ids.forEach(id => supersededIds.add(id));
}
if (mem.status === 'SUPERSEDED') {
supersededIds.add(mem.id);
}
if (mem.status === 'INVALIDATED') {
invalidatedIds.add(mem.id);
}
}
// 2. Filter candidate pool
let activeCandidates = memories.filter(mem => {
if (mem.status === 'INVALIDATED' || mem.status === 'SUPERSEDED') return false;
if (strictLineage && supersededIds.has(mem.id)) return false;
if (invalidatedIds.has(mem.id)) return false;
return true;
});
// 3. Optional entity-level deduplication (keeping newest timestamp)
if (dedupByEntity) {
const entityMap = new Map();
for (const mem of activeCandidates) {
const key = mem.entity || mem.key || mem.id;
const existing = entityMap.get(key);
if (!existing) {
entityMap.set(key, mem);
} else {
const existingTime = new Date(existing.timestamp || existing.created_at || 0).getTime();
const memTime = new Date(mem.timestamp || mem.created_at || 0).getTime();
if (memTime > existingTime) {
entityMap.set(key, mem);
}
}
}
activeCandidates = Array.from(entityMap.values());
}
return activeCandidates;
}
/**
* Marks an old memory record as superseded by a new memory record, creating a verifiable lineage link.
*
* @param {object} oldRecord - The original memory record.
* @param {object} newRecordData - Data for the replacement record.
* @param {string} [reason="Updated with newer facts"] - Rationale for superseding.
* @returns {{ updatedOld: object, createdNew: object }}
*/
export function supersedeMemoryRecord(oldRecord, newRecordData, reason = "Updated with newer facts") {
const timestamp = new Date().toISOString();
const updatedOld = {
...oldRecord,
status: 'SUPERSEDED',
supersededBy: newRecordData.id || `mem_${Date.now()}`,
invalidationReason: reason,
updated_at: timestamp
};
const createdNew = {
...newRecordData,
id: newRecordData.id || updatedOld.supersededBy,
supersedesId: oldRecord.id,
status: 'ACTIVE',
timestamp: newRecordData.timestamp || timestamp,
created_at: newRecordData.created_at || timestamp
};
return { updatedOld, createdNew };
}
/**
* Builds the complete lineage history for a given memory ID across ancestral and child records.
*
* @param {Array<object>} memories - Pool of all memories.
* @param {string|number} memoryId - The memory ID to trace.
* @returns {{ target: object|null, ancestors: Array<object>, descendants: Array<object> }}
*/
export function buildMemoryLineage(memories = [], memoryId) {
const idMap = new Map(memories.map(m => [String(m.id), m]));
const target = idMap.get(String(memoryId)) || null;
if (!target) {
return { target: null, ancestors: [], descendants: [] };
}
// Traverse ancestors (who did this memory supersede?)
const ancestors = [];
let currentAncestorId = target.supersedesId || target.supersedes_id;
while (currentAncestorId && idMap.has(String(currentAncestorId))) {
const anc = idMap.get(String(currentAncestorId));
ancestors.push(anc);
currentAncestorId = anc.supersedesId || anc.supersedes_id;
}
// Traverse descendants (who superseded this memory?)
const descendants = [];
let currentDescendantId = target.supersededBy || target.superseded_by;
while (currentDescendantId && idMap.has(String(currentDescendantId))) {
const desc = idMap.get(String(currentDescendantId));
descendants.push(desc);
currentDescendantId = desc.supersededBy || desc.superseded_by;
}
return { target, ancestors, descendants };
}
/**
* Stateful Invalidation Engine managing local memory states with temporal and semantic validation.
*/
export class MemoryInvalidationEngine {
constructor(initialMemories = []) {
this.memories = new Map();
for (const mem of initialMemories) {
this.memories.set(String(mem.id), {
status: 'ACTIVE',
created_at: new Date().toISOString(),
...mem
});
}
}
/**
* Store or register a memory. If supersedesId is provided, the previous memory is automatically marked SUPERSEDED.
*/
addMemory(memory) {
const id = String(memory.id || `mem_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`);
const timestamp = memory.timestamp || new Date().toISOString();
const record = {
id,
entity: memory.entity || null,
key: memory.key || null,
content: memory.content || "",
category: memory.category || "general",
tags: Array.isArray(memory.tags) ? memory.tags : [],
status: memory.status || 'ACTIVE',
supersedesId: memory.supersedesId ? String(memory.supersedesId) : null,
supersededBy: null,
invalidationReason: null,
timestamp,
created_at: memory.created_at || timestamp,
metadata: memory.metadata || {}
};
// If this supersedes an existing record, update the old one
if (record.supersedesId && this.memories.has(record.supersedesId)) {
const old = this.memories.get(record.supersedesId);
old.status = 'SUPERSEDED';
old.supersededBy = id;
old.invalidationReason = memory.invalidationReason || `Superseded by memory ${id}`;
old.updated_at = timestamp;
}
this.memories.set(id, record);
return record;
}
/**
* Explicitly invalidate a memory (e.g. decommissioned host, retracted policy).
*/
invalidateMemory(memoryId, reason = "Explicitly invalidated") {
const id = String(memoryId);
if (!this.memories.has(id)) {
throw new Error(`Memory with ID ${memoryId} not found.`);
}
const mem = this.memories.get(id);
mem.status = 'INVALIDATED';
mem.invalidationReason = reason;
mem.updated_at = new Date().toISOString();
return mem;
}
/**
* Returns all active, authoritative memories filtered by optional criteria.
*/
getActiveMemories(filter = {}) {
const all = Array.from(this.memories.values());
let filtered = filterActiveMemories(all, { dedupByEntity: filter.dedupByEntity });
if (filter.category) {
filtered = filtered.filter(m => m.category === filter.category);
}
if (filter.entity) {
filtered = filtered.filter(m => m.entity === filter.entity || m.key === filter.entity);
}
if (filter.tag) {
filtered = filtered.filter(m => m.tags.includes(filter.tag));
}
return filtered;
}
/**
* Retrieve lineage for an ID.
*/
getLineage(memoryId) {
return buildMemoryLineage(Array.from(this.memories.values()), memoryId);
}
/**
* Uses an LLM to evaluate whether a new incoming fact contradicts or supersedes any existing active memories.
*
* @param {string} newFactText - The new observation or fact text.
* @param {object} [llmConfig={}] - LLM configuration for analysis.
* @returns {Promise<{ hasContradiction: boolean, conflictingMemoryIds: Array<string>, suggestedAction: 'SUPERSEDE'|'APPEND'|'REJECT', rationale: string }>}
*/
async detectContradictions(newFactText, llmConfig = {}) {
const activeMemories = this.getActiveMemories();
if (activeMemories.length === 0) {
return {
hasContradiction: false,
conflictingMemoryIds: [],
suggestedAction: 'APPEND',
rationale: "No existing memories to conflict with."
};
}
const systemPrompt = `You are an AI Memory Auditor (MobileMem arXiv: 2608.13606).
Your job is to compare a NEW INCOMING FACT against EXISTING ACTIVE MEMORIES.
Determine whether the new fact directly contradicts, updates, or supersedes any of the existing memories.
Rules:
1. If the new fact updates a state (e.g. node hardware upgraded, service migrated to new port, password rotated), identify the conflicting memory IDs and specify "SUPERSEDE".
2. If the new fact is complementary/additive, specify "APPEND" with empty conflicting IDs.
3. If the new fact is invalid or hallucinated, specify "REJECT".
Respond in JSON format:
{
"hasContradiction": boolean,
"conflictingMemoryIds": ["id1", "id2"],
"suggestedAction": "SUPERSEDE" | "APPEND" | "REJECT",
"rationale": "Brief 1-2 sentence explanation"
}`;
const memoriesContext = activeMemories.map(m => `[ID: ${m.id}] (Entity: ${m.entity || 'N/A'}, Category: ${m.category}): ${m.content}`).join('\n');
const userMessage = `## EXISTING ACTIVE MEMORIES
${memoriesContext}
## NEW INCOMING FACT
${newFactText}`;
const result = await chatJson(systemPrompt, userMessage, { ...llmConfig, temperature: 0.1 });
return {
hasContradiction: !!result?.hasContradiction,
conflictingMemoryIds: Array.isArray(result?.conflictingMemoryIds) ? result.conflictingMemoryIds : [],
suggestedAction: result?.suggestedAction || 'APPEND',
rationale: result?.rationale || "Analysis completed."
};
}
}
/**
* Computes a deterministic SHA-256 hash of a file or code artifact string.
*
* @param {string} content - Raw file or code content.
* @returns {string} Hex hash string.
*/
export function computeArtifactHash(content = "") {
return crypto.createHash('sha256').update(typeof content === 'string' ? content : JSON.stringify(content)).digest('hex');
}
/**
* Artifact-Anchored Verification Memory (EA-Graph) based on:
* "EA-Graph: Artifact-Anchored Verification Memory for Coding Agents under Upstream Drift" (arXiv: 2608.04278).
*
* Anchors verification assertions, test results, and spec compliance claims directly to source file content hashes.
* Automatically invalidates dependent verification claims when upstream files experience drift.
*/
export class ArtifactVerificationGraph {
constructor() {
this.claims = new Map(); // claimId -> { claimId, statement, status, fileAnchors: [{ path, hash, symbol }], timestamp, metadata }
}
/**
* Anchors a verification claim to one or more source code artifacts.
*
* @param {object} claim
* @param {string} claim.claimId - Unique claim identifier.
* @param {string} claim.statement - Verification claim statement (e.g. "auth middleware satisfies JWT spec").
* @param {Array<{path: string, content?: string, hash?: string, symbol?: string}>} claim.fileAnchors - Dependent file artifacts.
* @param {object} [claim.metadata={}] - Optional metadata (e.g. testPassNumber, toolName).
* @returns {object} The registered claim record.
*/
anchorClaim(claim) {
const claimId = String(claim.claimId || `claim_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`);
const anchors = (claim.fileAnchors || []).map(a => ({
path: a.path,
hash: a.hash || (a.content ? computeArtifactHash(a.content) : ''),
symbol: a.symbol || null
}));
const record = {
claimId,
statement: claim.statement || "",
status: 'VALID', // 'VALID' | 'STALE_DRIFT' | 'INVALIDATED'
fileAnchors: anchors,
driftReason: null,
timestamp: new Date().toISOString(),
metadata: claim.metadata || {}
};
this.claims.set(claimId, record);
return record;
}
/**
* Checks current files against anchored claims to identify upstream drift.
*
* @param {Array<{path: string, content: string}>} currentFiles - List of current active files.
* @returns {{ hasDrift: boolean, driftedClaimIds: Array<string>, details: Array<{claimId: string, driftedFile: string, expectedHash: string, currentHash: string}> }}
*/
checkArtifactDrift(currentFiles = []) {
const currentHashMap = new Map();
for (const file of currentFiles) {
currentHashMap.set(file.path, computeArtifactHash(file.content));
}
const driftedClaims = [];
const details = [];
for (const [claimId, claim] of this.claims.entries()) {
if (claim.status === 'INVALIDATED') continue;
for (const anchor of claim.fileAnchors) {
if (currentHashMap.has(anchor.path)) {
const currentHash = currentHashMap.get(anchor.path);
if (anchor.hash && currentHash !== anchor.hash) {
driftedClaims.push(claimId);
details.push({
claimId,
driftedFile: anchor.path,
expectedHash: anchor.hash.slice(0, 10),
currentHash: currentHash.slice(0, 10)
});
break; // Claim is already drifted
}
}
}
}
return {
hasDrift: driftedClaims.length > 0,
driftedClaimIds: [...new Set(driftedClaims)],
details
};
}
/**
* Automatically invalidates all claims affected by upstream file drift.
*
* @param {Array<{path: string, content: string}>} currentFiles - Current active files.
* @returns {{ invalidatedCount: number, invalidatedClaims: Array<object> }}
*/
invalidateDriftedClaims(currentFiles = []) {
const drift = this.checkArtifactDrift(currentFiles);
const invalidatedClaims = [];
for (const claimId of drift.driftedClaimIds) {
const claim = this.claims.get(claimId);
if (claim && claim.status === 'VALID') {
claim.status = 'STALE_DRIFT';
const fileDetail = drift.details.find(d => d.claimId === claimId);
claim.driftReason = fileDetail
? `Upstream source file "${fileDetail.driftedFile}" mutated (hash mismatch)`
: "Upstream code artifacts modified";
claim.invalidatedAt = new Date().toISOString();
invalidatedClaims.push(claim);
}
}
return {
invalidatedCount: invalidatedClaims.length,
invalidatedClaims
};
}
/**
* Returns all currently valid claims whose code dependencies have not drifted.
*/
getValidClaims() {
return Array.from(this.claims.values()).filter(c => c.status === 'VALID');
}
}
/**
* Functional helper: Anchors a single verification claim into an EA-Graph.
*/
export function anchorVerificationClaim(graph, claim) {
if (!graph || typeof graph.anchorClaim !== 'function') {
throw new Error("A valid ArtifactVerificationGraph instance is required.");
}
return graph.anchorClaim(claim);
}
/**
* Functional helper: Checks artifact drift for a list of claims.
*/
export function checkArtifactDrift(claims = [], currentFiles = []) {
const graph = new ArtifactVerificationGraph();
for (const c of claims) graph.anchorClaim(c);
return graph.checkArtifactDrift(currentFiles);
}