@199-bio/engram 0.5.1 → 0.6.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/README.md +205 -46
- package/dist/consolidation/consolidator.d.ts.map +1 -1
- package/dist/index.js +89 -4
- package/dist/retrieval/hybrid.d.ts.map +1 -1
- package/dist/storage/database.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/consolidation/consolidator.ts +245 -5
- package/src/index.ts +96 -3
- package/src/retrieval/hybrid.ts +83 -7
- package/src/storage/database.ts +198 -9
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import Anthropic from "@anthropic-ai/sdk";
|
|
15
|
-
import { EngramDatabase, Memory, Digest } from "../storage/database.js";
|
|
15
|
+
import { EngramDatabase, Memory, Digest, Episode } from "../storage/database.js";
|
|
16
|
+
import { KnowledgeGraph } from "../graph/knowledge-graph.js";
|
|
17
|
+
import { HybridSearch } from "../retrieval/hybrid.js";
|
|
16
18
|
|
|
17
19
|
const CONSOLIDATION_SYSTEM = `You are a high-quality memory consolidation system for a personal AI assistant. Your goal is to create comprehensive, nuanced digests that preserve the richness of human experience and relationships.
|
|
18
20
|
|
|
@@ -49,6 +51,37 @@ const CONSOLIDATION_SYSTEM = `You are a high-quality memory consolidation system
|
|
|
49
51
|
- If memories span different time periods, note the evolution
|
|
50
52
|
- Only flag true contradictions, not incomplete information or natural life changes`;
|
|
51
53
|
|
|
54
|
+
const EPISODE_EXTRACTION_SYSTEM = `You are extracting structured memories from a conversation. Your goal is to identify facts, preferences, events, and relationships worth remembering.
|
|
55
|
+
|
|
56
|
+
## What to Extract
|
|
57
|
+
- Key facts about people, places, organizations
|
|
58
|
+
- User preferences and opinions
|
|
59
|
+
- Important events and their dates
|
|
60
|
+
- Relationships between entities
|
|
61
|
+
- Decisions made or plans discussed
|
|
62
|
+
|
|
63
|
+
## What to Skip
|
|
64
|
+
- Small talk and pleasantries
|
|
65
|
+
- Repetitive information
|
|
66
|
+
- Context that's only relevant to the immediate task
|
|
67
|
+
- Technical details that don't reveal user preferences
|
|
68
|
+
|
|
69
|
+
## Output Format (JSON)
|
|
70
|
+
{
|
|
71
|
+
"memories": [
|
|
72
|
+
{
|
|
73
|
+
"content": "The actual memory to store (clear, standalone statement)",
|
|
74
|
+
"importance": 0.5,
|
|
75
|
+
"emotional_weight": 0.5,
|
|
76
|
+
"event_time": "2024-12-01 or null if not mentioned",
|
|
77
|
+
"entities": [{"name": "Boris", "type": "person"}],
|
|
78
|
+
"relationships": [{"from": "Boris", "to": "Google", "type": "works_at"}]
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
Extract 0-5 memories. Quality over quantity. If nothing worth remembering, return empty memories array.`;
|
|
84
|
+
|
|
52
85
|
interface ConsolidationResult {
|
|
53
86
|
digest: string;
|
|
54
87
|
topic: string;
|
|
@@ -58,6 +91,19 @@ interface ConsolidationResult {
|
|
|
58
91
|
}>;
|
|
59
92
|
}
|
|
60
93
|
|
|
94
|
+
interface ExtractedMemory {
|
|
95
|
+
content: string;
|
|
96
|
+
importance: number;
|
|
97
|
+
emotional_weight: number;
|
|
98
|
+
event_time: string | null;
|
|
99
|
+
entities: Array<{ name: string; type: string }>;
|
|
100
|
+
relationships: Array<{ from: string; to: string; type: string }>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface EpisodeExtractionResult {
|
|
104
|
+
memories: ExtractedMemory[];
|
|
105
|
+
}
|
|
106
|
+
|
|
61
107
|
interface ConsolidateOptions {
|
|
62
108
|
batchSize?: number;
|
|
63
109
|
minMemoriesForConsolidation?: number;
|
|
@@ -66,9 +112,17 @@ interface ConsolidateOptions {
|
|
|
66
112
|
export class Consolidator {
|
|
67
113
|
private client: Anthropic | null = null;
|
|
68
114
|
private db: EngramDatabase;
|
|
69
|
-
|
|
70
|
-
|
|
115
|
+
private graph: KnowledgeGraph | null = null;
|
|
116
|
+
private search: HybridSearch | null = null;
|
|
117
|
+
|
|
118
|
+
constructor(
|
|
119
|
+
db: EngramDatabase,
|
|
120
|
+
graph?: KnowledgeGraph,
|
|
121
|
+
search?: HybridSearch
|
|
122
|
+
) {
|
|
71
123
|
this.db = db;
|
|
124
|
+
this.graph = graph || null;
|
|
125
|
+
this.search = search || null;
|
|
72
126
|
|
|
73
127
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
74
128
|
if (apiKey) {
|
|
@@ -359,18 +413,204 @@ Create a rich, detailed profile. Do not summarize away important nuances. Respon
|
|
|
359
413
|
getStatus(): {
|
|
360
414
|
configured: boolean;
|
|
361
415
|
unconsolidatedMemories: number;
|
|
416
|
+
unconsolidatedEpisodes: number;
|
|
362
417
|
totalDigests: number;
|
|
363
418
|
unresolvedContradictions: number;
|
|
364
419
|
} {
|
|
365
|
-
const
|
|
420
|
+
const unconsolidatedMem = this.db.getUnconsolidatedMemories(undefined, 1000);
|
|
421
|
+
const unconsolidatedEp = this.db.getUnconsolidatedEpisodes(1000);
|
|
366
422
|
const digests = this.db.getDigests(undefined, 1000);
|
|
367
423
|
const contradictions = this.db.getContradictions(false, 1000);
|
|
368
424
|
|
|
369
425
|
return {
|
|
370
426
|
configured: this.isConfigured(),
|
|
371
|
-
unconsolidatedMemories:
|
|
427
|
+
unconsolidatedMemories: unconsolidatedMem.length,
|
|
428
|
+
unconsolidatedEpisodes: unconsolidatedEp.length,
|
|
372
429
|
totalDigests: digests.length,
|
|
373
430
|
unresolvedContradictions: contradictions.length,
|
|
374
431
|
};
|
|
375
432
|
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Process unconsolidated episodes into memories
|
|
436
|
+
* This is the "working memory → long-term memory" transfer
|
|
437
|
+
*/
|
|
438
|
+
async consolidateEpisodes(options: {
|
|
439
|
+
minEpisodes?: number;
|
|
440
|
+
batchSize?: number;
|
|
441
|
+
} = {}): Promise<{
|
|
442
|
+
episodesProcessed: number;
|
|
443
|
+
memoriesCreated: number;
|
|
444
|
+
entitiesCreated: number;
|
|
445
|
+
}> {
|
|
446
|
+
if (!this.client) {
|
|
447
|
+
throw new Error("Consolidator not configured - set ANTHROPIC_API_KEY");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const { minEpisodes = 4, batchSize = 20 } = options;
|
|
451
|
+
|
|
452
|
+
// Get unconsolidated episodes
|
|
453
|
+
const episodes = this.db.getUnconsolidatedEpisodes(batchSize);
|
|
454
|
+
|
|
455
|
+
if (episodes.length < minEpisodes) {
|
|
456
|
+
return { episodesProcessed: 0, memoriesCreated: 0, entitiesCreated: 0 };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Group by session for context
|
|
460
|
+
const sessionGroups = new Map<string, Episode[]>();
|
|
461
|
+
for (const ep of episodes) {
|
|
462
|
+
const existing = sessionGroups.get(ep.session_id) || [];
|
|
463
|
+
existing.push(ep);
|
|
464
|
+
sessionGroups.set(ep.session_id, existing);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
let episodesProcessed = 0;
|
|
468
|
+
let memoriesCreated = 0;
|
|
469
|
+
let entitiesCreated = 0;
|
|
470
|
+
|
|
471
|
+
// Process each session
|
|
472
|
+
for (const [sessionId, sessionEpisodes] of sessionGroups) {
|
|
473
|
+
if (sessionEpisodes.length < 2) continue;
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
const result = await this.extractMemoriesFromEpisodes(sessionEpisodes);
|
|
477
|
+
|
|
478
|
+
if (result && result.memories.length > 0) {
|
|
479
|
+
for (const mem of result.memories) {
|
|
480
|
+
// Create the memory
|
|
481
|
+
const memory = this.db.createMemory(
|
|
482
|
+
mem.content,
|
|
483
|
+
"episode_consolidation",
|
|
484
|
+
mem.importance,
|
|
485
|
+
{
|
|
486
|
+
eventTime: mem.event_time ? new Date(mem.event_time) : undefined,
|
|
487
|
+
emotionalWeight: mem.emotional_weight,
|
|
488
|
+
}
|
|
489
|
+
);
|
|
490
|
+
memoriesCreated++;
|
|
491
|
+
|
|
492
|
+
// Index for search
|
|
493
|
+
if (this.search) {
|
|
494
|
+
await this.search.indexMemory(memory);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Create entities and relationships
|
|
498
|
+
if (this.graph) {
|
|
499
|
+
for (const ent of mem.entities || []) {
|
|
500
|
+
const entity = this.graph.getOrCreateEntity(
|
|
501
|
+
ent.name,
|
|
502
|
+
ent.type as "person" | "place" | "concept" | "event" | "organization"
|
|
503
|
+
);
|
|
504
|
+
this.db.addObservation(entity.id, mem.content, memory.id, 1.0);
|
|
505
|
+
entitiesCreated++;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
for (const rel of mem.relationships || []) {
|
|
509
|
+
try {
|
|
510
|
+
const fromEntity = this.graph.getOrCreateEntity(rel.from, "person");
|
|
511
|
+
const toEntity = this.graph.getOrCreateEntity(rel.to, "person");
|
|
512
|
+
this.graph.relate(fromEntity.name, toEntity.name, rel.type);
|
|
513
|
+
} catch {
|
|
514
|
+
// Skip invalid relationships
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Mark episodes as consolidated
|
|
522
|
+
this.db.markEpisodesConsolidated(sessionEpisodes.map(e => e.id));
|
|
523
|
+
episodesProcessed += sessionEpisodes.length;
|
|
524
|
+
|
|
525
|
+
} catch (error) {
|
|
526
|
+
console.error("[Consolidator] Episode consolidation failed:", error);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return { episodesProcessed, memoriesCreated, entitiesCreated };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Extract memories from conversation episodes using Haiku (fast, cheap)
|
|
535
|
+
*/
|
|
536
|
+
private async extractMemoriesFromEpisodes(
|
|
537
|
+
episodes: Episode[]
|
|
538
|
+
): Promise<EpisodeExtractionResult | null> {
|
|
539
|
+
if (!this.client) return null;
|
|
540
|
+
|
|
541
|
+
// Format conversation
|
|
542
|
+
const conversationText = episodes
|
|
543
|
+
.sort((a, b) => a.turn_index - b.turn_index)
|
|
544
|
+
.map(ep => `${ep.role.toUpperCase()}: ${ep.content}`)
|
|
545
|
+
.join("\n\n");
|
|
546
|
+
|
|
547
|
+
const userPrompt = `Extract memorable facts from this conversation.
|
|
548
|
+
|
|
549
|
+
CONVERSATION:
|
|
550
|
+
${conversationText}
|
|
551
|
+
|
|
552
|
+
Remember: Only extract information worth remembering long-term. Skip transient task details.
|
|
553
|
+
Respond with JSON only.`;
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
// Use Haiku for speed/cost (no extended thinking needed)
|
|
557
|
+
const response = await this.client.messages.create({
|
|
558
|
+
model: "claude-haiku-4-5-20251201",
|
|
559
|
+
max_tokens: 4000,
|
|
560
|
+
messages: [
|
|
561
|
+
{
|
|
562
|
+
role: "user",
|
|
563
|
+
content: userPrompt,
|
|
564
|
+
},
|
|
565
|
+
],
|
|
566
|
+
system: EPISODE_EXTRACTION_SYSTEM,
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
let text = "";
|
|
570
|
+
for (const block of response.content) {
|
|
571
|
+
if (block.type === "text") {
|
|
572
|
+
text = block.text;
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (!text) return null;
|
|
578
|
+
|
|
579
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
580
|
+
if (!jsonMatch) return null;
|
|
581
|
+
|
|
582
|
+
return JSON.parse(jsonMatch[0]) as EpisodeExtractionResult;
|
|
583
|
+
} catch (error) {
|
|
584
|
+
console.error("[Consolidator] Episode extraction failed:", error);
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Run full consolidation cycle (episodes → memories → digests)
|
|
591
|
+
* This is the "sleep cycle" that should run periodically
|
|
592
|
+
*/
|
|
593
|
+
async runSleepCycle(): Promise<{
|
|
594
|
+
episodesProcessed: number;
|
|
595
|
+
memoriesCreated: number;
|
|
596
|
+
digestsCreated: number;
|
|
597
|
+
contradictionsFound: number;
|
|
598
|
+
}> {
|
|
599
|
+
console.error("[Consolidator] Starting sleep cycle...");
|
|
600
|
+
|
|
601
|
+
// Step 1: Process episodes into memories
|
|
602
|
+
const episodeResult = await this.consolidateEpisodes();
|
|
603
|
+
console.error(`[Consolidator] Episodes: ${episodeResult.episodesProcessed} → ${episodeResult.memoriesCreated} memories`);
|
|
604
|
+
|
|
605
|
+
// Step 2: Consolidate memories into digests
|
|
606
|
+
const memoryResult = await this.consolidate();
|
|
607
|
+
console.error(`[Consolidator] Memories: ${memoryResult.memoriesProcessed} → ${memoryResult.digestsCreated} digests`);
|
|
608
|
+
|
|
609
|
+
return {
|
|
610
|
+
episodesProcessed: episodeResult.episodesProcessed,
|
|
611
|
+
memoriesCreated: episodeResult.memoriesCreated,
|
|
612
|
+
digestsCreated: memoryResult.digestsCreated,
|
|
613
|
+
contradictionsFound: memoryResult.contradictionsFound,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
376
616
|
}
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { KnowledgeGraph } from "./graph/knowledge-graph.js";
|
|
|
20
20
|
import { createRetriever } from "./retrieval/colbert.js";
|
|
21
21
|
import { HybridSearch } from "./retrieval/hybrid.js";
|
|
22
22
|
import { EngramWebServer } from "./web/server.js";
|
|
23
|
+
import { Consolidator } from "./consolidation/consolidator.js";
|
|
23
24
|
|
|
24
25
|
// ============ Configuration ============
|
|
25
26
|
|
|
@@ -34,6 +35,7 @@ const DB_FILE = path.join(DB_PATH, "engram.db");
|
|
|
34
35
|
let db: EngramDatabase;
|
|
35
36
|
let graph: KnowledgeGraph;
|
|
36
37
|
let search: HybridSearch;
|
|
38
|
+
let consolidator: Consolidator;
|
|
37
39
|
let webServer: EngramWebServer | null = null;
|
|
38
40
|
|
|
39
41
|
async function initialize(): Promise<void> {
|
|
@@ -44,6 +46,7 @@ async function initialize(): Promise<void> {
|
|
|
44
46
|
|
|
45
47
|
const retriever = await createRetriever(DB_PATH);
|
|
46
48
|
search = new HybridSearch(db, graph, retriever);
|
|
49
|
+
consolidator = new Consolidator(db, graph, search);
|
|
47
50
|
|
|
48
51
|
// Rebuild index with existing memories
|
|
49
52
|
const stats = db.getStats();
|
|
@@ -53,6 +56,9 @@ async function initialize(): Promise<void> {
|
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
console.error(`[Engram] Ready. Stats: ${JSON.stringify(stats)}`);
|
|
59
|
+
if (consolidator.isConfigured()) {
|
|
60
|
+
console.error(`[Engram] Consolidation enabled (ANTHROPIC_API_KEY found)`);
|
|
61
|
+
}
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
// ============ MCP Server ============
|
|
@@ -60,7 +66,7 @@ async function initialize(): Promise<void> {
|
|
|
60
66
|
const server = new Server(
|
|
61
67
|
{
|
|
62
68
|
name: "engram",
|
|
63
|
-
version: "0.
|
|
69
|
+
version: "0.6.0",
|
|
64
70
|
},
|
|
65
71
|
{
|
|
66
72
|
capabilities: {
|
|
@@ -90,6 +96,17 @@ const TOOLS = [
|
|
|
90
96
|
maximum: 1,
|
|
91
97
|
default: 0.5,
|
|
92
98
|
},
|
|
99
|
+
emotional_weight: {
|
|
100
|
+
type: "number",
|
|
101
|
+
description: "0-1 emotional significance. Use 0.8+ for emotionally charged content, celebrations, losses. Affects memory retention.",
|
|
102
|
+
minimum: 0,
|
|
103
|
+
maximum: 1,
|
|
104
|
+
default: 0.5,
|
|
105
|
+
},
|
|
106
|
+
event_time: {
|
|
107
|
+
type: "string",
|
|
108
|
+
description: "When the event actually happened (ISO 8601), if different from now. E.g., 'Last week I went to Paris' → set event_time to that date.",
|
|
109
|
+
},
|
|
93
110
|
entities: {
|
|
94
111
|
type: "array",
|
|
95
112
|
description: "Key entities mentioned (people, organizations, places). Only include clear, specific named entities.",
|
|
@@ -200,6 +217,28 @@ const TOOLS = [
|
|
|
200
217
|
openWorldHint: true,
|
|
201
218
|
},
|
|
202
219
|
},
|
|
220
|
+
{
|
|
221
|
+
name: "consolidate",
|
|
222
|
+
description: "Run memory consolidation to compress episodes into memories and memories into digests. Like sleep for the memory system. Use periodically or when requested.",
|
|
223
|
+
inputSchema: {
|
|
224
|
+
type: "object" as const,
|
|
225
|
+
properties: {
|
|
226
|
+
mode: {
|
|
227
|
+
type: "string",
|
|
228
|
+
enum: ["full", "episodes_only", "memories_only"],
|
|
229
|
+
description: "What to consolidate: 'full' (default) runs everything, 'episodes_only' just processes conversation history, 'memories_only' creates digests",
|
|
230
|
+
default: "full",
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
annotations: {
|
|
235
|
+
title: "Consolidate Memories",
|
|
236
|
+
readOnlyHint: false,
|
|
237
|
+
destructiveHint: false,
|
|
238
|
+
idempotentHint: false,
|
|
239
|
+
openWorldHint: false,
|
|
240
|
+
},
|
|
241
|
+
},
|
|
203
242
|
];
|
|
204
243
|
|
|
205
244
|
// List available tools
|
|
@@ -218,18 +257,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
218
257
|
content,
|
|
219
258
|
source = "conversation",
|
|
220
259
|
importance = 0.5,
|
|
260
|
+
emotional_weight = 0.5,
|
|
261
|
+
event_time,
|
|
221
262
|
entities: providedEntities = [],
|
|
222
263
|
relationships: providedRelationships = [],
|
|
223
264
|
} = args as {
|
|
224
265
|
content: string;
|
|
225
266
|
source?: string;
|
|
226
267
|
importance?: number;
|
|
268
|
+
emotional_weight?: number;
|
|
269
|
+
event_time?: string;
|
|
227
270
|
entities?: Array<{ name: string; type: "person" | "organization" | "place" }>;
|
|
228
271
|
relationships?: Array<{ from: string; to: string; type: string }>;
|
|
229
272
|
};
|
|
230
273
|
|
|
231
|
-
// Create memory
|
|
232
|
-
const memory = db.createMemory(content, source, importance
|
|
274
|
+
// Create memory with new temporal and salience fields
|
|
275
|
+
const memory = db.createMemory(content, source, importance, {
|
|
276
|
+
eventTime: event_time ? new Date(event_time) : undefined,
|
|
277
|
+
emotionalWeight: emotional_weight,
|
|
278
|
+
});
|
|
233
279
|
|
|
234
280
|
// Index for semantic search
|
|
235
281
|
await search.indexMemory(memory);
|
|
@@ -290,6 +336,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
290
336
|
source: r.memory.source,
|
|
291
337
|
timestamp: r.memory.timestamp.toISOString(),
|
|
292
338
|
relevance_score: r.score.toFixed(4),
|
|
339
|
+
retention: r.retention.toFixed(2), // How well-retained (0-1)
|
|
293
340
|
matched_via: Object.entries(r.sources)
|
|
294
341
|
.filter(([, v]) => v !== undefined)
|
|
295
342
|
.map(([k]) => k)
|
|
@@ -365,6 +412,52 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
365
412
|
};
|
|
366
413
|
}
|
|
367
414
|
|
|
415
|
+
case "consolidate": {
|
|
416
|
+
const { mode = "full" } = args as { mode?: "full" | "episodes_only" | "memories_only" };
|
|
417
|
+
|
|
418
|
+
if (!consolidator.isConfigured()) {
|
|
419
|
+
return {
|
|
420
|
+
content: [
|
|
421
|
+
{
|
|
422
|
+
type: "text" as const,
|
|
423
|
+
text: JSON.stringify({
|
|
424
|
+
success: false,
|
|
425
|
+
error: "Consolidation requires ANTHROPIC_API_KEY environment variable",
|
|
426
|
+
}),
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
isError: true,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
let result;
|
|
434
|
+
switch (mode) {
|
|
435
|
+
case "episodes_only":
|
|
436
|
+
result = await consolidator.consolidateEpisodes();
|
|
437
|
+
break;
|
|
438
|
+
case "memories_only":
|
|
439
|
+
result = await consolidator.consolidate();
|
|
440
|
+
break;
|
|
441
|
+
case "full":
|
|
442
|
+
default:
|
|
443
|
+
result = await consolidator.runSleepCycle();
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
content: [
|
|
449
|
+
{
|
|
450
|
+
type: "text" as const,
|
|
451
|
+
text: JSON.stringify({
|
|
452
|
+
success: true,
|
|
453
|
+
mode,
|
|
454
|
+
...result,
|
|
455
|
+
}, null, 2),
|
|
456
|
+
},
|
|
457
|
+
],
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
368
461
|
default:
|
|
369
462
|
throw new Error(`Unknown tool: ${name}`);
|
|
370
463
|
}
|
package/src/retrieval/hybrid.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hybrid Search with Reciprocal Rank Fusion (RRF)
|
|
3
3
|
* Combines BM25 (keyword) and ColBERT (semantic) search
|
|
4
|
+
* Enhanced with temporal decay and salience scoring
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import { EngramDatabase, Memory } from "../storage/database.js";
|
|
@@ -10,6 +11,7 @@ import { ColBERTRetriever, SimpleRetriever, SearchResult, Document } from "./col
|
|
|
10
11
|
export interface HybridSearchResult {
|
|
11
12
|
memory: Memory;
|
|
12
13
|
score: number;
|
|
14
|
+
retention: number; // 0-1 how well-retained this memory is
|
|
13
15
|
sources: {
|
|
14
16
|
bm25?: number;
|
|
15
17
|
semantic?: number;
|
|
@@ -17,6 +19,60 @@ export interface HybridSearchResult {
|
|
|
17
19
|
};
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Calculate Ebbinghaus forgetting curve retention
|
|
24
|
+
* R = e^(-t/S) where t=time since last access, S=stability
|
|
25
|
+
*
|
|
26
|
+
* Higher stability = slower forgetting
|
|
27
|
+
* Recent access = higher retention
|
|
28
|
+
*/
|
|
29
|
+
function calculateRetention(memory: Memory, now: Date): number {
|
|
30
|
+
// Use last_accessed if available, otherwise timestamp
|
|
31
|
+
const lastActive = memory.last_accessed || memory.timestamp;
|
|
32
|
+
const daysSinceAccess = (now.getTime() - lastActive.getTime()) / (1000 * 60 * 60 * 24);
|
|
33
|
+
|
|
34
|
+
// Stability is our memory strength (default 1.0, increases with recalls)
|
|
35
|
+
const stability = memory.stability || 1.0;
|
|
36
|
+
|
|
37
|
+
// Half-life in days = stability * 7 (so stability=1 means 7-day half-life)
|
|
38
|
+
const halfLife = stability * 7;
|
|
39
|
+
|
|
40
|
+
// Exponential decay: R = e^(-0.693 * t / halfLife)
|
|
41
|
+
const retention = Math.exp(-0.693 * daysSinceAccess / halfLife);
|
|
42
|
+
|
|
43
|
+
return Math.max(0, Math.min(1, retention));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Calculate salience score - how important/memorable is this?
|
|
48
|
+
* Combines emotional weight, importance, and access patterns
|
|
49
|
+
*/
|
|
50
|
+
function calculateSalience(memory: Memory): number {
|
|
51
|
+
const importance = memory.importance || 0.5;
|
|
52
|
+
const emotionalWeight = memory.emotional_weight || 0.5;
|
|
53
|
+
const accessBonus = Math.min(1, Math.log(1 + (memory.access_count || 0)) / 5);
|
|
54
|
+
|
|
55
|
+
// Weighted combination
|
|
56
|
+
return (importance * 0.4) + (emotionalWeight * 0.4) + (accessBonus * 0.2);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Apply temporal and salience adjustments to search results
|
|
61
|
+
*/
|
|
62
|
+
function adjustScore(memory: Memory, baseScore: number, now: Date): { adjusted: number; retention: number } {
|
|
63
|
+
const retention = calculateRetention(memory, now);
|
|
64
|
+
const salience = calculateSalience(memory);
|
|
65
|
+
|
|
66
|
+
// Final score = base * (0.5 + 0.3*retention + 0.2*salience)
|
|
67
|
+
// This means: 50% retrieval match, 30% recency/stability, 20% importance
|
|
68
|
+
const multiplier = 0.5 + (0.3 * retention) + (0.2 * salience);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
adjusted: baseScore * multiplier,
|
|
72
|
+
retention,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
20
76
|
export class HybridSearch {
|
|
21
77
|
constructor(
|
|
22
78
|
private db: EngramDatabase,
|
|
@@ -133,18 +189,21 @@ export class HybridSearch {
|
|
|
133
189
|
// Sort by RRF score
|
|
134
190
|
rrfScores.sort((a, b) => b.score - a.score);
|
|
135
191
|
|
|
136
|
-
// Get
|
|
137
|
-
const
|
|
192
|
+
// Get results with full memory data and apply temporal adjustments
|
|
193
|
+
const now = new Date();
|
|
194
|
+
const adjustedResults: Array<HybridSearchResult & { originalScore: number }> = [];
|
|
138
195
|
|
|
139
|
-
for (const { id, score, sources } of rrfScores
|
|
196
|
+
for (const { id, score, sources } of rrfScores) {
|
|
140
197
|
const memory = this.db.getMemory(id);
|
|
141
198
|
if (memory) {
|
|
142
|
-
//
|
|
143
|
-
|
|
199
|
+
// Apply Ebbinghaus decay and salience scoring
|
|
200
|
+
const { adjusted, retention } = adjustScore(memory, score, now);
|
|
144
201
|
|
|
145
|
-
|
|
202
|
+
adjustedResults.push({
|
|
146
203
|
memory,
|
|
147
|
-
score,
|
|
204
|
+
score: adjusted,
|
|
205
|
+
retention,
|
|
206
|
+
originalScore: score,
|
|
148
207
|
sources: {
|
|
149
208
|
bm25: sources.bm25,
|
|
150
209
|
semantic: sources.semantic,
|
|
@@ -154,6 +213,23 @@ export class HybridSearch {
|
|
|
154
213
|
}
|
|
155
214
|
}
|
|
156
215
|
|
|
216
|
+
// Re-sort by adjusted score (accounts for recency/stability)
|
|
217
|
+
adjustedResults.sort((a, b) => b.score - a.score);
|
|
218
|
+
|
|
219
|
+
// Take top results and update access counts
|
|
220
|
+
const results: HybridSearchResult[] = [];
|
|
221
|
+
for (const result of adjustedResults.slice(0, limit)) {
|
|
222
|
+
// Update access count (which also increases stability for future searches)
|
|
223
|
+
this.db.touchMemory(result.memory.id);
|
|
224
|
+
|
|
225
|
+
results.push({
|
|
226
|
+
memory: result.memory,
|
|
227
|
+
score: result.score,
|
|
228
|
+
retention: result.retention,
|
|
229
|
+
sources: result.sources,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
157
233
|
return results;
|
|
158
234
|
}
|
|
159
235
|
|