engram-sdk 0.1.10 → 0.3.0
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/dist/auto-ingest.d.ts +14 -1
- package/dist/auto-ingest.d.ts.map +1 -1
- package/dist/auto-ingest.js +49 -38
- package/dist/auto-ingest.js.map +1 -1
- package/dist/cli.js +24 -10
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +76 -4
- package/dist/mcp.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +59 -1
- package/dist/server.js.map +1 -1
- package/dist/temporal.d.ts.map +1 -1
- package/dist/temporal.js +12 -3
- package/dist/temporal.js.map +1 -1
- package/dist/types.d.ts +5 -5
- package/dist/types.js +2 -2
- package/dist/types.js.map +1 -1
- package/dist/vault.d.ts +69 -0
- package/dist/vault.d.ts.map +1 -1
- package/dist/vault.js +482 -28
- package/dist/vault.js.map +1 -1
- package/package.json +1 -1
- package/rescore-codebase.ts +13 -8
package/dist/vault.js
CHANGED
|
@@ -2,7 +2,7 @@ import path from 'path';
|
|
|
2
2
|
import { MemoryStore } from './store.js';
|
|
3
3
|
import { RememberInputSchema, RecallInputSchema } from './types.js';
|
|
4
4
|
import { extract } from './extract.js';
|
|
5
|
-
import { calculateRecencyBoost, DEFAULT_TEMPORAL_CONFIG, findContradictionCandidates, verifyContradiction } from './temporal.js';
|
|
5
|
+
import { calculateRecencyBoost, DEFAULT_TEMPORAL_CONFIG, findContradictionCandidates, verifyContradiction, temporalEdgeWeight } from './temporal.js';
|
|
6
6
|
// ============================================================
|
|
7
7
|
// Vault — The public API for Engram
|
|
8
8
|
// ============================================================
|
|
@@ -66,6 +66,11 @@ export class Vault {
|
|
|
66
66
|
// Contradiction detection: if this memory updates a previous fact,
|
|
67
67
|
// mark the old one as superseded. Only runs when LLM is configured.
|
|
68
68
|
return this.detectContradictions(memory);
|
|
69
|
+
})
|
|
70
|
+
.then(() => {
|
|
71
|
+
// Post-remember inference: extract implicit insights that a human
|
|
72
|
+
// would obviously infer (e.g., "builds hunting platform" → "likes hunting").
|
|
73
|
+
return this.inferInsights(memory);
|
|
69
74
|
})
|
|
70
75
|
.catch(err => {
|
|
71
76
|
console.warn(`Failed to process embedding/contradictions for ${memory.id}:`, err);
|
|
@@ -197,6 +202,103 @@ export class Vault {
|
|
|
197
202
|
// Reinforcement is best-effort
|
|
198
203
|
}
|
|
199
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Post-remember inference: after storing a memory, use LLM to extract
|
|
207
|
+
* 0-2 implicit insights that a human would obviously infer.
|
|
208
|
+
*
|
|
209
|
+
* Example: "Ian is building a hunting land acquisition platform"
|
|
210
|
+
* → infers "Ian is interested in hunting"
|
|
211
|
+
*
|
|
212
|
+
* These are stored as low-confidence (0.3) semantic memories that
|
|
213
|
+
* accumulate via reinforcement over time. If someone is building
|
|
214
|
+
* a hunting platform AND talks about hunting trips AND buys hunting
|
|
215
|
+
* gear, the confidence on "Ian likes hunting" climbs naturally.
|
|
216
|
+
*
|
|
217
|
+
* Only runs when LLM is configured. Async, fire-and-forget.
|
|
218
|
+
* Skips memories that are already implicit or from consolidation.
|
|
219
|
+
*/
|
|
220
|
+
async inferInsights(memory) {
|
|
221
|
+
if (!this.config.llm)
|
|
222
|
+
return;
|
|
223
|
+
// Don't infer from system/consolidation memories or already-implicit ones
|
|
224
|
+
if (memory.source?.type === 'consolidation')
|
|
225
|
+
return;
|
|
226
|
+
if (memory.topics?.includes('implicit'))
|
|
227
|
+
return;
|
|
228
|
+
if (memory.topics?.includes('meta'))
|
|
229
|
+
return;
|
|
230
|
+
// Skip low-salience memories (not worth the LLM call)
|
|
231
|
+
if (memory.salience < 0.4)
|
|
232
|
+
return;
|
|
233
|
+
// Skip very short memories (not enough signal)
|
|
234
|
+
if (memory.content.length < 40)
|
|
235
|
+
return;
|
|
236
|
+
const llmConfig = this.config.llm;
|
|
237
|
+
const model = llmConfig.provider === 'gemini' ? 'gemini-2.5-flash'
|
|
238
|
+
: llmConfig.provider === 'openai' ? 'gpt-4o-mini'
|
|
239
|
+
: 'claude-3-5-haiku-20241022';
|
|
240
|
+
const prompt = `Given this memory about a person, extract 0-2 basic personal insights that any human would obviously infer. Focus on interests, personality traits, preferences, and relationships.
|
|
241
|
+
|
|
242
|
+
Memory: "${memory.content}"
|
|
243
|
+
Entities: ${memory.entities?.join(', ') || 'none'}
|
|
244
|
+
|
|
245
|
+
Rules:
|
|
246
|
+
- Only include inferences that are clearly supported by the memory
|
|
247
|
+
- Keep each insight to one short sentence
|
|
248
|
+
- Do NOT restate the original memory — only new inferences
|
|
249
|
+
- If nothing interesting can be inferred, return empty array
|
|
250
|
+
- These should be things like "X is interested in Y", "X values Z", "X and Y are close"
|
|
251
|
+
|
|
252
|
+
JSON: {"insights": [{"content": "...", "entities": ["..."], "topics": ["..."]}]}
|
|
253
|
+
If nothing: {"insights": []}`;
|
|
254
|
+
try {
|
|
255
|
+
const response = await this.callLLM(model, prompt, llmConfig);
|
|
256
|
+
const parsed = JSON.parse(response);
|
|
257
|
+
for (const insight of parsed.insights ?? []) {
|
|
258
|
+
if (!insight.content || insight.content.length < 10)
|
|
259
|
+
continue;
|
|
260
|
+
// Check if this insight already exists (don't duplicate)
|
|
261
|
+
if (this.embedder && this.store.hasVectorSearch()) {
|
|
262
|
+
try {
|
|
263
|
+
const embedding = await this.embedder.embed(insight.content);
|
|
264
|
+
const similar = this.store.findSimilar(embedding, 0.15, 3);
|
|
265
|
+
if (similar.length > 0) {
|
|
266
|
+
// Similar insight exists — reinforce it instead of creating new
|
|
267
|
+
const existing = this.store.getMemoryDirect(similar[0].memoryId);
|
|
268
|
+
if (existing && existing.status === 'active') {
|
|
269
|
+
const newConf = Math.min(1.0, existing.confidence + 0.05);
|
|
270
|
+
this.store.updateMemory(existing.id, { confidence: newConf });
|
|
271
|
+
this.store.createEdge(memory.id, existing.id, 'supports', 0.6);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// Embedding check failed — create anyway
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Store as implicit memory with low confidence
|
|
281
|
+
const inferred = this.remember({
|
|
282
|
+
content: insight.content,
|
|
283
|
+
type: 'semantic',
|
|
284
|
+
entities: insight.entities ?? memory.entities ?? [],
|
|
285
|
+
topics: [...(insight.topics ?? []), 'implicit', 'inferred'],
|
|
286
|
+
salience: 0.4,
|
|
287
|
+
confidence: 0.3,
|
|
288
|
+
source: {
|
|
289
|
+
type: 'inference',
|
|
290
|
+
evidence: [memory.id],
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
// Link the insight to the source memory
|
|
294
|
+
this.store.createEdge(memory.id, inferred.id, 'derived_from', 0.7);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
// Inference is best-effort — never break the remember flow
|
|
299
|
+
console.error('Insight inference failed:', err);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
200
302
|
/**
|
|
201
303
|
* Detect contradictions: when a new memory is stored, check if it
|
|
202
304
|
* updates or replaces an existing fact about the same entity.
|
|
@@ -226,19 +328,35 @@ export class Vault {
|
|
|
226
328
|
if (!memory.entities || memory.entities.length === 0)
|
|
227
329
|
return;
|
|
228
330
|
try {
|
|
229
|
-
// Phase 1: Find candidates via vector similarity
|
|
331
|
+
// Phase 1: Find candidates via BOTH vector similarity AND entity overlap
|
|
332
|
+
// Vector similarity alone misses cases like "X is 79%" vs "X is 72%" where
|
|
333
|
+
// the surrounding text differs but the factual claim conflicts.
|
|
334
|
+
const candidateSet = new Map();
|
|
335
|
+
// 1a. Vector similarity search
|
|
230
336
|
const embedding = this.store.getEmbedding(memory.id);
|
|
231
|
-
if (
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
337
|
+
if (embedding) {
|
|
338
|
+
const similar = this.store.findSimilar(embedding, 0.5, 20);
|
|
339
|
+
const vectorIds = similar
|
|
340
|
+
.filter(s => s.memoryId !== memory.id)
|
|
341
|
+
.map(s => s.memoryId);
|
|
342
|
+
for (const mem of this.store.getMemoriesDirect(vectorIds)) {
|
|
343
|
+
if (mem.status === 'active')
|
|
344
|
+
candidateSet.set(mem.id, mem);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// 1b. Entity-based search — find ALL memories sharing entities with this one
|
|
348
|
+
// This catches contradictions that vector search misses
|
|
349
|
+
for (const entity of memory.entities) {
|
|
350
|
+
const entityMemories = this.store.getByEntity(entity, 30);
|
|
351
|
+
for (const mem of entityMemories) {
|
|
352
|
+
if (mem.id !== memory.id && mem.status === 'active') {
|
|
353
|
+
candidateSet.set(mem.id, mem);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (candidateSet.size === 0)
|
|
239
358
|
return;
|
|
240
|
-
const candidateMemories =
|
|
241
|
-
.filter(m => m.status === 'active');
|
|
359
|
+
const candidateMemories = [...candidateSet.values()];
|
|
242
360
|
// Phase 1b: Heuristic filter — must share entities
|
|
243
361
|
const threshold = this.config.temporal?.contradictionSimilarityThreshold ?? 0.75;
|
|
244
362
|
const minOverlap = this.config.temporal?.minEntityOverlap ?? 1;
|
|
@@ -251,7 +369,7 @@ export class Vault {
|
|
|
251
369
|
if (filtered.length === 0)
|
|
252
370
|
return;
|
|
253
371
|
// Phase 2: LLM verification — check top 3 candidates max
|
|
254
|
-
const llmCall = (prompt) => this.callLLM(this.config.llm.model ?? 'gemini-2.
|
|
372
|
+
const llmCall = (prompt) => this.callLLM(this.config.llm.model ?? 'gemini-2.5-flash', prompt, this.config.llm);
|
|
255
373
|
// Only check older memories — newer ones can't be superseded by this memory
|
|
256
374
|
const olderCandidates = filtered
|
|
257
375
|
.filter(c => new Date(c.createdAt) < new Date(memory.createdAt))
|
|
@@ -491,6 +609,34 @@ export class Vault {
|
|
|
491
609
|
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
492
610
|
results = results.filter(r => r.memory.createdAt >= oneWeekAgo);
|
|
493
611
|
}
|
|
612
|
+
else if (parsed.temporalFocus === 'latest') {
|
|
613
|
+
// Deduplicate by entity+topic overlap: when multiple memories share
|
|
614
|
+
// the same primary entity AND topic, keep only the newest one.
|
|
615
|
+
// This prevents stale facts from polluting results.
|
|
616
|
+
const seen = new Map();
|
|
617
|
+
const deduped = [];
|
|
618
|
+
for (const r of results) {
|
|
619
|
+
if (r.memory.entities.length === 0) {
|
|
620
|
+
// No entities — can't dedup, keep it
|
|
621
|
+
deduped.push(r);
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
// Build a dedup key from primary entity + topics
|
|
625
|
+
const primaryEntity = r.memory.entities[0].toLowerCase();
|
|
626
|
+
const topicKey = (r.memory.topics ?? []).sort().join(',').toLowerCase();
|
|
627
|
+
const dedupKey = `${primaryEntity}:${topicKey}`;
|
|
628
|
+
const createdAt = new Date(r.memory.createdAt).getTime();
|
|
629
|
+
const existing = seen.get(dedupKey);
|
|
630
|
+
if (!existing || createdAt > existing.createdAt) {
|
|
631
|
+
seen.set(dedupKey, { memory: r.memory, score: r.score, createdAt });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
// Collect deduped results: non-entity memories + latest per entity+topic
|
|
635
|
+
for (const entry of seen.values()) {
|
|
636
|
+
deduped.push({ memory: entry.memory, score: entry.score });
|
|
637
|
+
}
|
|
638
|
+
results = deduped;
|
|
639
|
+
}
|
|
494
640
|
// 8. Score with salience, stability, and type weighting
|
|
495
641
|
// Semantic memories with high stability are the "core knowledge" —
|
|
496
642
|
// they should outrank noisy episodic results when the vector
|
|
@@ -557,9 +703,15 @@ export class Vault {
|
|
|
557
703
|
const parentId = frontier.has(edge.sourceId) ? edge.sourceId : edge.targetId;
|
|
558
704
|
const neighborId = edge.sourceId === parentId ? edge.targetId : edge.sourceId;
|
|
559
705
|
const parentActivation = frontier.get(parentId) ?? 0;
|
|
560
|
-
// Activation = parent ×
|
|
706
|
+
// Activation = parent × temporalEdgeWeight(strength, recency) × decay × edge_type_weight
|
|
561
707
|
const typeWeight = this.edgeTypeWeight(edge.type);
|
|
562
|
-
|
|
708
|
+
// Apply temporal weighting: edges to newer memories carry more activation energy
|
|
709
|
+
// Use getMemoryDirect to avoid bumping access count during traversal
|
|
710
|
+
const neighborMem = this.store.getMemoryDirect(neighborId);
|
|
711
|
+
const effectiveStrength = neighborMem
|
|
712
|
+
? temporalEdgeWeight(edge.strength, neighborMem)
|
|
713
|
+
: edge.strength;
|
|
714
|
+
const activation = parentActivation * effectiveStrength * opts.decay * typeWeight;
|
|
563
715
|
if (activation < opts.minActivation)
|
|
564
716
|
continue;
|
|
565
717
|
// Accumulate activation (multiple paths can reinforce)
|
|
@@ -684,13 +836,6 @@ export class Vault {
|
|
|
684
836
|
entitiesDiscovered = result.entitiesDiscovered;
|
|
685
837
|
connectionsFormed = result.connectionsFormed;
|
|
686
838
|
}
|
|
687
|
-
// Apply decay
|
|
688
|
-
const decayed = this.store.applyDecay(this.config.decay?.halfLifeHours ?? 168);
|
|
689
|
-
// Archive deeply decayed memories
|
|
690
|
-
const archived = this.store.getDecayedMemories(this.config.decay?.archiveThreshold ?? 0.05);
|
|
691
|
-
for (const mem of archived) {
|
|
692
|
-
this.store.deleteMemory(mem.id); // TODO: move to cold storage instead of deleting
|
|
693
|
-
}
|
|
694
839
|
const report = {
|
|
695
840
|
startedAt,
|
|
696
841
|
completedAt: new Date().toISOString(),
|
|
@@ -700,12 +845,12 @@ export class Vault {
|
|
|
700
845
|
entitiesDiscovered,
|
|
701
846
|
connectionsFormed,
|
|
702
847
|
contradictionsFound,
|
|
703
|
-
memoriesDecayed:
|
|
704
|
-
memoriesArchived:
|
|
848
|
+
memoriesDecayed: 0,
|
|
849
|
+
memoriesArchived: 0,
|
|
705
850
|
};
|
|
706
851
|
// Store the consolidation report as a memory itself
|
|
707
852
|
this.remember({
|
|
708
|
-
content: `Consolidation completed: processed ${episodes.length} episodes, created ${semanticCreated} semantic memories, discovered ${entitiesDiscovered} entities, formed ${connectionsFormed} connections
|
|
853
|
+
content: `Consolidation completed: processed ${episodes.length} episodes, created ${semanticCreated} semantic memories, discovered ${entitiesDiscovered} entities, formed ${connectionsFormed} connections.`,
|
|
709
854
|
type: 'procedural',
|
|
710
855
|
topics: ['meta', 'consolidation'],
|
|
711
856
|
salience: 0.3,
|
|
@@ -867,6 +1012,315 @@ export class Vault {
|
|
|
867
1012
|
return results.slice(0, limit);
|
|
868
1013
|
}
|
|
869
1014
|
// --------------------------------------------------------
|
|
1015
|
+
// ask() — Answer a question using memories as evidence.
|
|
1016
|
+
//
|
|
1017
|
+
// This is the feature that makes Engram useful for agents:
|
|
1018
|
+
// instead of returning 30 raw memories and making the agent
|
|
1019
|
+
// do the synthesis, ask() runs recall internally, then uses
|
|
1020
|
+
// the LLM to produce a coherent answer with confidence signal.
|
|
1021
|
+
//
|
|
1022
|
+
// The agent gets: one answer, the evidence behind it, and a
|
|
1023
|
+
// confidence level. No memory parsing, no synthesis burden.
|
|
1024
|
+
// --------------------------------------------------------
|
|
1025
|
+
async ask(question, opts) {
|
|
1026
|
+
if (!this.config.llm) {
|
|
1027
|
+
throw new Error('ask() requires LLM configuration (set llm in vault config)');
|
|
1028
|
+
}
|
|
1029
|
+
const limit = opts?.limit ?? 20;
|
|
1030
|
+
const spread = opts?.spread ?? true;
|
|
1031
|
+
// Step 1: Recall relevant memories
|
|
1032
|
+
const memories = await this.recall({
|
|
1033
|
+
context: question,
|
|
1034
|
+
limit,
|
|
1035
|
+
spread,
|
|
1036
|
+
temporalFocus: 'latest', // Deduplicate by entity+topic, keep newest
|
|
1037
|
+
});
|
|
1038
|
+
if (memories.length === 0) {
|
|
1039
|
+
return {
|
|
1040
|
+
answer: 'I have no memories related to this question.',
|
|
1041
|
+
confidence: 'low',
|
|
1042
|
+
memories: [],
|
|
1043
|
+
tokenEstimate: 0,
|
|
1044
|
+
evidenceQuality: {
|
|
1045
|
+
memoryCount: 0,
|
|
1046
|
+
avgConfidence: 0,
|
|
1047
|
+
totalAccesses: 0,
|
|
1048
|
+
newestMemoryAgeDays: -1,
|
|
1049
|
+
sourceBreakdown: { directInput: 0, autoIngested: 0 },
|
|
1050
|
+
},
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
// Step 2: Build evidence block with metadata
|
|
1054
|
+
const evidenceLines = memories.map((m, i) => {
|
|
1055
|
+
const age = Math.floor((Date.now() - new Date(m.createdAt).getTime()) / (1000 * 60 * 60 * 24));
|
|
1056
|
+
const accessCount = m.accessCount ?? 0;
|
|
1057
|
+
const confidenceLabel = m.confidence >= 0.8 ? 'high' : m.confidence >= 0.5 ? 'medium' : 'low';
|
|
1058
|
+
const status = m.status !== 'active' ? ` [${m.status}]` : '';
|
|
1059
|
+
return `[${i + 1}] (${m.type}, confidence: ${confidenceLabel}, ${age}d ago, accessed ${accessCount}x${status}) ${m.content}`;
|
|
1060
|
+
});
|
|
1061
|
+
const prompt = `You are answering a question using memories from a knowledge vault.
|
|
1062
|
+
|
|
1063
|
+
RULES:
|
|
1064
|
+
- Answer the question directly and concisely based ONLY on the provided memories.
|
|
1065
|
+
- When multiple memories contain different values for the same fact, ALWAYS prefer the most recent one (lower "d ago" number).
|
|
1066
|
+
- If memories conflict, state the most recent fact and note it was updated.
|
|
1067
|
+
- If the memories don't contain enough information, say so honestly.
|
|
1068
|
+
- Do NOT make up information not supported by the memories.
|
|
1069
|
+
|
|
1070
|
+
MEMORIES:
|
|
1071
|
+
${evidenceLines.join('\n')}
|
|
1072
|
+
|
|
1073
|
+
QUESTION: ${question}
|
|
1074
|
+
|
|
1075
|
+
Respond in JSON:
|
|
1076
|
+
{
|
|
1077
|
+
"answer": "Your concise, synthesized answer",
|
|
1078
|
+
"confidence": "high|medium|low",
|
|
1079
|
+
"reasoning": "Brief note on evidence quality"
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
Confidence guide:
|
|
1083
|
+
- "high": Multiple memories support the answer, recent, frequently accessed
|
|
1084
|
+
- "medium": Answer is supported but by few memories or older data
|
|
1085
|
+
- "low": Sparse evidence, conflicting data, or mostly inference`;
|
|
1086
|
+
// Step 3: Call LLM for synthesis
|
|
1087
|
+
const llmConfig = this.config.llm;
|
|
1088
|
+
const model = llmConfig.model ?? 'gemini-2.5-flash';
|
|
1089
|
+
const response = await this.callLLM(model, prompt, llmConfig);
|
|
1090
|
+
// Step 4: Parse response
|
|
1091
|
+
let answer = 'Unable to synthesize an answer.';
|
|
1092
|
+
let confidence = 'low';
|
|
1093
|
+
try {
|
|
1094
|
+
const parsed = JSON.parse(response);
|
|
1095
|
+
answer = parsed.answer ?? answer;
|
|
1096
|
+
confidence = ['high', 'medium', 'low'].includes(parsed.confidence) ? parsed.confidence : 'low';
|
|
1097
|
+
}
|
|
1098
|
+
catch {
|
|
1099
|
+
// If JSON parsing fails, use raw text as answer
|
|
1100
|
+
answer = response.trim();
|
|
1101
|
+
}
|
|
1102
|
+
// Estimate tokens used (rough: 4 chars per token)
|
|
1103
|
+
const tokenEstimate = Math.ceil((prompt.length + answer.length) / 4);
|
|
1104
|
+
// Step 5: Build confidence metadata from evidence
|
|
1105
|
+
const avgConfidence = memories.reduce((sum, m) => sum + m.confidence, 0) / memories.length;
|
|
1106
|
+
const totalAccesses = memories.reduce((sum, m) => sum + m.accessCount, 0);
|
|
1107
|
+
const newestAge = Math.min(...memories.map(m => Math.floor((Date.now() - new Date(m.createdAt).getTime()) / (1000 * 60 * 60 * 24))));
|
|
1108
|
+
const autoIngestedCount = memories.filter(m => (m.topics ?? []).includes('auto-ingested')).length;
|
|
1109
|
+
return {
|
|
1110
|
+
answer,
|
|
1111
|
+
confidence,
|
|
1112
|
+
memories,
|
|
1113
|
+
tokenEstimate,
|
|
1114
|
+
evidenceQuality: {
|
|
1115
|
+
memoryCount: memories.length,
|
|
1116
|
+
avgConfidence: Math.round(avgConfidence * 100) / 100,
|
|
1117
|
+
totalAccesses,
|
|
1118
|
+
newestMemoryAgeDays: newestAge,
|
|
1119
|
+
sourceBreakdown: {
|
|
1120
|
+
directInput: memories.length - autoIngestedCount,
|
|
1121
|
+
autoIngested: autoIngestedCount,
|
|
1122
|
+
},
|
|
1123
|
+
},
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
// --------------------------------------------------------
|
|
1127
|
+
// alerts() — What should the agent know RIGHT NOW?
|
|
1128
|
+
//
|
|
1129
|
+
// Unlike surface() (which needs context input) or briefing()
|
|
1130
|
+
// (which is a full session dump), alerts() returns only the
|
|
1131
|
+
// things that need attention. No context required.
|
|
1132
|
+
//
|
|
1133
|
+
// Three categories:
|
|
1134
|
+
// 1. Pending commitments — things promised but not fulfilled
|
|
1135
|
+
// 2. Stale follow-ups — things that haven't been touched in a while
|
|
1136
|
+
// 3. Contradictions — conflicting facts that need resolution
|
|
1137
|
+
//
|
|
1138
|
+
// Returns empty array when nothing needs attention.
|
|
1139
|
+
// Designed to be called on heartbeat or session start.
|
|
1140
|
+
// --------------------------------------------------------
|
|
1141
|
+
alerts(opts) {
|
|
1142
|
+
const staleDays = opts?.staleDays ?? 3;
|
|
1143
|
+
const limit = opts?.limit ?? 10;
|
|
1144
|
+
const includeContradictions = opts?.includeContradictions ?? true;
|
|
1145
|
+
const now = Date.now();
|
|
1146
|
+
const alerts = [];
|
|
1147
|
+
// 1. Pending commitments
|
|
1148
|
+
const pending = this.store.getByStatus('pending', 50);
|
|
1149
|
+
for (const mem of pending) {
|
|
1150
|
+
const ageDays = Math.floor((now - new Date(mem.createdAt).getTime()) / (1000 * 60 * 60 * 24));
|
|
1151
|
+
const priority = ageDays >= 7 ? 'high' :
|
|
1152
|
+
ageDays >= 3 ? 'medium' : 'low';
|
|
1153
|
+
// High-salience pending items are always worth surfacing
|
|
1154
|
+
// Low-salience ones only if they're getting stale
|
|
1155
|
+
if (mem.salience < 0.4 && ageDays < staleDays)
|
|
1156
|
+
continue;
|
|
1157
|
+
alerts.push({
|
|
1158
|
+
type: 'pending',
|
|
1159
|
+
priority,
|
|
1160
|
+
message: `Pending (${ageDays}d): ${mem.content}`,
|
|
1161
|
+
memoryId: mem.id,
|
|
1162
|
+
entities: mem.entities,
|
|
1163
|
+
ageDays,
|
|
1164
|
+
sortScore: (priority === 'high' ? 3 : priority === 'medium' ? 2 : 1) + mem.salience,
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
// 2. Stale follow-ups — high-salience memories that haven't been accessed recently
|
|
1168
|
+
const allMemories = this.store.getByType('semantic', 100);
|
|
1169
|
+
const staleThreshold = now - staleDays * 24 * 60 * 60 * 1000;
|
|
1170
|
+
for (const mem of allMemories) {
|
|
1171
|
+
if (mem.status !== 'active')
|
|
1172
|
+
continue;
|
|
1173
|
+
if (mem.salience < 0.7)
|
|
1174
|
+
continue; // Only flag important stuff
|
|
1175
|
+
const lastAccessed = new Date(mem.lastAccessedAt).getTime();
|
|
1176
|
+
const ageDays = Math.floor((now - lastAccessed) / (1000 * 60 * 60 * 24));
|
|
1177
|
+
// Only flag if it hasn't been accessed in staleDays AND has topics suggesting follow-up
|
|
1178
|
+
if (lastAccessed > staleThreshold)
|
|
1179
|
+
continue;
|
|
1180
|
+
if (ageDays < staleDays)
|
|
1181
|
+
continue;
|
|
1182
|
+
// Look for action-oriented content
|
|
1183
|
+
const actionPatterns = /\b(should|need|must|todo|follow.?up|check|review|update|schedule|plan|deadline|due|remind)\b/i;
|
|
1184
|
+
if (!actionPatterns.test(mem.content))
|
|
1185
|
+
continue;
|
|
1186
|
+
alerts.push({
|
|
1187
|
+
type: 'stale',
|
|
1188
|
+
priority: ageDays >= 7 ? 'medium' : 'low',
|
|
1189
|
+
message: `Stale (${ageDays}d since accessed): ${mem.content}`,
|
|
1190
|
+
memoryId: mem.id,
|
|
1191
|
+
entities: mem.entities,
|
|
1192
|
+
ageDays,
|
|
1193
|
+
sortScore: (ageDays >= 7 ? 2 : 1) + mem.salience * 0.5,
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
// 3. Contradictions
|
|
1197
|
+
if (includeContradictions) {
|
|
1198
|
+
const contradictions = this.contradictions(5);
|
|
1199
|
+
for (const c of contradictions) {
|
|
1200
|
+
const ageA = Math.floor((now - new Date(c.memoryA.createdAt).getTime()) / (1000 * 60 * 60 * 24));
|
|
1201
|
+
const ageB = Math.floor((now - new Date(c.memoryB.createdAt).getTime()) / (1000 * 60 * 60 * 24));
|
|
1202
|
+
alerts.push({
|
|
1203
|
+
type: 'contradiction',
|
|
1204
|
+
priority: 'medium',
|
|
1205
|
+
message: `Contradiction: "${c.memoryA.content.slice(0, 80)}" vs "${c.memoryB.content.slice(0, 80)}"`,
|
|
1206
|
+
entities: [...new Set([...c.memoryA.entities, ...c.memoryB.entities])],
|
|
1207
|
+
ageDays: Math.min(ageA, ageB),
|
|
1208
|
+
sortScore: 2.5, // Contradictions are always medium-high priority
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
// Sort by priority score descending, then by age descending
|
|
1213
|
+
alerts.sort((a, b) => b.sortScore - a.sortScore || b.ageDays - a.ageDays);
|
|
1214
|
+
// Return without the internal sortScore
|
|
1215
|
+
return alerts.slice(0, limit).map(({ sortScore, ...rest }) => rest);
|
|
1216
|
+
}
|
|
1217
|
+
// --------------------------------------------------------
|
|
1218
|
+
// audit() — Cross-reference external memory against vault.
|
|
1219
|
+
//
|
|
1220
|
+
// Takes content from an external source (e.g., MEMORY.md)
|
|
1221
|
+
// and checks for discrepancies with what's in the vault.
|
|
1222
|
+
// Returns claims that are outdated, missing, or contradicted.
|
|
1223
|
+
//
|
|
1224
|
+
// This is how Engram earns trust: instead of silently
|
|
1225
|
+
// disagreeing with the agent's other memory sources, it
|
|
1226
|
+
// speaks up.
|
|
1227
|
+
// --------------------------------------------------------
|
|
1228
|
+
async audit(externalContent, opts) {
|
|
1229
|
+
if (!this.config.llm) {
|
|
1230
|
+
throw new Error('audit() requires LLM configuration');
|
|
1231
|
+
}
|
|
1232
|
+
const maxClaims = opts?.maxClaims ?? 20;
|
|
1233
|
+
const relevanceThreshold = opts?.relevanceThreshold ?? 0.5;
|
|
1234
|
+
// Step 1: Extract factual claims from external content
|
|
1235
|
+
const extractPrompt = `Extract factual claims from this text. Each claim should be a single, verifiable statement.
|
|
1236
|
+
|
|
1237
|
+
TEXT:
|
|
1238
|
+
${externalContent.slice(0, 8000)}
|
|
1239
|
+
|
|
1240
|
+
Respond as JSON:
|
|
1241
|
+
{"claims": ["claim 1", "claim 2", ...]}
|
|
1242
|
+
|
|
1243
|
+
Extract up to ${maxClaims} claims. Focus on specific facts (names, numbers, dates, statuses, relationships) not opinions or vague statements.`;
|
|
1244
|
+
const llmConfig = this.config.llm;
|
|
1245
|
+
const model = llmConfig.model ?? 'gemini-2.5-flash';
|
|
1246
|
+
const extractResponse = await this.callLLM(model, extractPrompt, llmConfig);
|
|
1247
|
+
let claims = [];
|
|
1248
|
+
try {
|
|
1249
|
+
const parsed = JSON.parse(extractResponse);
|
|
1250
|
+
claims = (parsed.claims ?? []).slice(0, maxClaims);
|
|
1251
|
+
}
|
|
1252
|
+
catch {
|
|
1253
|
+
return { discrepancies: [], verified: 0, total: 0 };
|
|
1254
|
+
}
|
|
1255
|
+
if (claims.length === 0) {
|
|
1256
|
+
return { discrepancies: [], verified: 0, total: 0 };
|
|
1257
|
+
}
|
|
1258
|
+
// Step 2: For each claim, check against vault
|
|
1259
|
+
const discrepancies = [];
|
|
1260
|
+
let verified = 0;
|
|
1261
|
+
for (const claim of claims) {
|
|
1262
|
+
// Recall memories relevant to this claim
|
|
1263
|
+
const memories = await this.recall({
|
|
1264
|
+
context: claim,
|
|
1265
|
+
limit: 5,
|
|
1266
|
+
spread: true,
|
|
1267
|
+
temporalFocus: 'latest',
|
|
1268
|
+
});
|
|
1269
|
+
if (memories.length === 0) {
|
|
1270
|
+
// No relevant memories — can't verify or contradict
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
// Ask LLM to compare claim against vault memories
|
|
1274
|
+
const memoryContext = memories.map((m, i) => {
|
|
1275
|
+
const age = Math.floor((Date.now() - new Date(m.createdAt).getTime()) / (1000 * 60 * 60 * 24));
|
|
1276
|
+
return `[${i + 1}] (${age}d ago) ${m.content}`;
|
|
1277
|
+
}).join('\n');
|
|
1278
|
+
const comparePrompt = `Compare this claim against the vault memories below. Determine if the claim is:
|
|
1279
|
+
- "verified": Vault memories support this claim
|
|
1280
|
+
- "outdated": Vault has a MORE RECENT version of this fact
|
|
1281
|
+
- "contradicted": Vault directly contradicts this claim
|
|
1282
|
+
- "unrelated": Vault memories aren't relevant to this claim
|
|
1283
|
+
|
|
1284
|
+
CLAIM: ${claim}
|
|
1285
|
+
|
|
1286
|
+
VAULT MEMORIES:
|
|
1287
|
+
${memoryContext}
|
|
1288
|
+
|
|
1289
|
+
Respond as JSON:
|
|
1290
|
+
{"status": "verified|outdated|contradicted|unrelated", "explanation": "brief reason", "relevantMemoryIndex": 1}`;
|
|
1291
|
+
try {
|
|
1292
|
+
const compareResponse = await this.callLLM(model, comparePrompt, llmConfig);
|
|
1293
|
+
const result = JSON.parse(compareResponse);
|
|
1294
|
+
if (result.status === 'verified') {
|
|
1295
|
+
verified++;
|
|
1296
|
+
}
|
|
1297
|
+
else if (result.status === 'outdated' || result.status === 'contradicted') {
|
|
1298
|
+
const memIdx = (result.relevantMemoryIndex ?? 1) - 1;
|
|
1299
|
+
const relevantMem = memories[memIdx] ?? memories[0];
|
|
1300
|
+
discrepancies.push({
|
|
1301
|
+
claim,
|
|
1302
|
+
source: 'external',
|
|
1303
|
+
vaultMemory: relevantMem.content,
|
|
1304
|
+
vaultCreatedAt: relevantMem.createdAt,
|
|
1305
|
+
type: result.status,
|
|
1306
|
+
explanation: result.explanation ?? '',
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
// 'unrelated' and parse failures are silently skipped
|
|
1310
|
+
}
|
|
1311
|
+
catch {
|
|
1312
|
+
// LLM comparison failed for this claim, skip
|
|
1313
|
+
}
|
|
1314
|
+
// Rate limit between claims
|
|
1315
|
+
await new Promise(r => setTimeout(r, 500));
|
|
1316
|
+
}
|
|
1317
|
+
return {
|
|
1318
|
+
discrepancies,
|
|
1319
|
+
verified,
|
|
1320
|
+
total: claims.length,
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
// --------------------------------------------------------
|
|
870
1324
|
// surface() — Proactive memory surfacing.
|
|
871
1325
|
//
|
|
872
1326
|
// The key insight from the manifesto: memories should be
|
|
@@ -1097,7 +1551,7 @@ Respond in this exact JSON format:
|
|
|
1097
1551
|
|
|
1098
1552
|
Keep entities specific and topics general. Limit to 10 entities and 8 topics max.`;
|
|
1099
1553
|
try {
|
|
1100
|
-
const response = await this.callLLM('gemini-2.
|
|
1554
|
+
const response = await this.callLLM('gemini-2.5-flash', prompt, this.config.llm);
|
|
1101
1555
|
const result = JSON.parse(response);
|
|
1102
1556
|
return {
|
|
1103
1557
|
entities: (result.entities || []).slice(0, 10),
|
|
@@ -1160,7 +1614,7 @@ Keep entities specific and topics general. Limit to 10 entities and 8 topics max
|
|
|
1160
1614
|
return { semanticCreated: 0, semanticUpdated: 0, entitiesDiscovered: 0, connectionsFormed: 0, contradictionsFound: 0 };
|
|
1161
1615
|
}
|
|
1162
1616
|
const llmConfig = this.config.llm;
|
|
1163
|
-
const defaultModel = llmConfig.provider === 'gemini' ? 'gemini-2.
|
|
1617
|
+
const defaultModel = llmConfig.provider === 'gemini' ? 'gemini-2.5-flash'
|
|
1164
1618
|
: llmConfig.provider === 'openai' ? 'gpt-4o-mini'
|
|
1165
1619
|
: 'claude-3-5-haiku-20241022';
|
|
1166
1620
|
const model = llmConfig.model ?? defaultModel;
|
|
@@ -1313,7 +1767,7 @@ Be conservative with explicit memories. Be observant with implicit ones — look
|
|
|
1313
1767
|
return jsonMatch ? (jsonMatch[1] ?? jsonMatch[0]) : text;
|
|
1314
1768
|
}
|
|
1315
1769
|
if (config.provider === 'gemini') {
|
|
1316
|
-
const geminiModel = model.startsWith('gemini') ? model : 'gemini-2.
|
|
1770
|
+
const geminiModel = model.startsWith('gemini') ? model : 'gemini-2.5-flash';
|
|
1317
1771
|
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${config.apiKey}`, {
|
|
1318
1772
|
method: 'POST',
|
|
1319
1773
|
headers: { 'Content-Type': 'application/json' },
|