memory-lancedb-pro 1.0.26 → 1.1.0-beta.2

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.
@@ -0,0 +1,524 @@
1
+ /**
2
+ * Smart Memory Extractor — LLM-powered extraction pipeline
3
+ * Replaces regex-triggered capture with intelligent 6-category extraction.
4
+ *
5
+ * Pipeline: conversation → LLM extract → candidates → dedup → persist
6
+ *
7
+ * Ported from epro-memory/extractor.ts + deduplicator.ts
8
+ */
9
+
10
+ import type { MemoryStore, MemorySearchResult } from "./store.js";
11
+ import type { Embedder } from "./embedder.js";
12
+ import type { LlmClient } from "./llm-client.js";
13
+ import {
14
+ buildExtractionPrompt,
15
+ buildDedupPrompt,
16
+ buildMergePrompt,
17
+ } from "./extraction-prompts.js";
18
+ import {
19
+ type CandidateMemory,
20
+ type DedupDecision,
21
+ type DedupResult,
22
+ type ExtractionStats,
23
+ type MemoryCategory,
24
+ ALWAYS_MERGE_CATEGORIES,
25
+ MERGE_SUPPORTED_CATEGORIES,
26
+ MEMORY_CATEGORIES,
27
+ normalizeCategory,
28
+ } from "./memory-categories.js";
29
+ import { isNoise } from "./noise-filter.js";
30
+
31
+ // ============================================================================
32
+ // Constants
33
+ // ============================================================================
34
+
35
+ const SIMILARITY_THRESHOLD = 0.7;
36
+ const MAX_SIMILAR_FOR_PROMPT = 3;
37
+ const MAX_MEMORIES_PER_EXTRACTION = 5;
38
+ const VALID_DECISIONS = new Set<string>(["create", "merge", "skip"]);
39
+
40
+ // ============================================================================
41
+ // Smart Extractor
42
+ // ============================================================================
43
+
44
+ export interface SmartExtractorConfig {
45
+ /** User identifier for extraction prompt. */
46
+ user?: string;
47
+ /** Minimum conversation messages before extraction triggers. */
48
+ extractMinMessages?: number;
49
+ /** Maximum characters of conversation text to process. */
50
+ extractMaxChars?: number;
51
+ /** Default scope for new memories. */
52
+ defaultScope?: string;
53
+ /** Logger function. */
54
+ log?: (msg: string) => void;
55
+ }
56
+
57
+ export class SmartExtractor {
58
+ private log: (msg: string) => void;
59
+
60
+ constructor(
61
+ private store: MemoryStore,
62
+ private embedder: Embedder,
63
+ private llm: LlmClient,
64
+ private config: SmartExtractorConfig = {},
65
+ ) {
66
+ this.log = config.log ?? ((msg: string) => console.log(msg));
67
+ }
68
+
69
+ // --------------------------------------------------------------------------
70
+ // Main entry point
71
+ // --------------------------------------------------------------------------
72
+
73
+ /**
74
+ * Extract memories from a conversation text and persist them.
75
+ * Returns extraction statistics.
76
+ */
77
+ async extractAndPersist(
78
+ conversationText: string,
79
+ sessionKey: string = "unknown",
80
+ ): Promise<ExtractionStats> {
81
+ const stats: ExtractionStats = { created: 0, merged: 0, skipped: 0 };
82
+
83
+ // Step 1: LLM extraction
84
+ const candidates = await this.extractCandidates(conversationText);
85
+
86
+ if (candidates.length === 0) {
87
+ this.log("memory-pro: smart-extractor: no memories extracted");
88
+ return stats;
89
+ }
90
+
91
+ this.log(
92
+ `memory-pro: smart-extractor: extracted ${candidates.length} candidate(s)`,
93
+ );
94
+
95
+ // Step 2: Process each candidate through dedup pipeline
96
+ for (const candidate of candidates.slice(0, MAX_MEMORIES_PER_EXTRACTION)) {
97
+ try {
98
+ await this.processCandidate(candidate, sessionKey, stats);
99
+ } catch (err) {
100
+ this.log(
101
+ `memory-pro: smart-extractor: failed to process candidate [${candidate.category}]: ${String(err)}`,
102
+ );
103
+ }
104
+ }
105
+
106
+ return stats;
107
+ }
108
+
109
+ // --------------------------------------------------------------------------
110
+ // Step 1: LLM Extraction
111
+ // --------------------------------------------------------------------------
112
+
113
+ /**
114
+ * Call LLM to extract candidate memories from conversation text.
115
+ */
116
+ private async extractCandidates(
117
+ conversationText: string,
118
+ ): Promise<CandidateMemory[]> {
119
+ const maxChars = this.config.extractMaxChars ?? 8000;
120
+ const truncated =
121
+ conversationText.length > maxChars
122
+ ? conversationText.slice(-maxChars)
123
+ : conversationText;
124
+
125
+ const user = this.config.user ?? "User";
126
+ const prompt = buildExtractionPrompt(truncated, user);
127
+
128
+ const result = await this.llm.completeJson<{
129
+ memories: Array<{
130
+ category: string;
131
+ abstract: string;
132
+ overview: string;
133
+ content: string;
134
+ }>;
135
+ }>(prompt);
136
+
137
+ if (!result?.memories || !Array.isArray(result.memories)) {
138
+ return [];
139
+ }
140
+
141
+ // Validate and normalize candidates
142
+ const candidates: CandidateMemory[] = [];
143
+ for (const raw of result.memories) {
144
+ const category = normalizeCategory(raw.category ?? "");
145
+ if (!category) continue;
146
+
147
+ const abstract = (raw.abstract ?? "").trim();
148
+ const overview = (raw.overview ?? "").trim();
149
+ const content = (raw.content ?? "").trim();
150
+
151
+ // Skip empty or noise
152
+ if (!abstract || abstract.length < 5) continue;
153
+ if (isNoise(abstract)) continue;
154
+
155
+ candidates.push({ category, abstract, overview, content });
156
+ }
157
+
158
+ return candidates;
159
+ }
160
+
161
+ // --------------------------------------------------------------------------
162
+ // Step 2: Dedup + Persist
163
+ // --------------------------------------------------------------------------
164
+
165
+ /**
166
+ * Process a single candidate memory: dedup → merge/create → store
167
+ */
168
+ private async processCandidate(
169
+ candidate: CandidateMemory,
170
+ sessionKey: string,
171
+ stats: ExtractionStats,
172
+ ): Promise<void> {
173
+ // Profile always merges (skip dedup)
174
+ if (ALWAYS_MERGE_CATEGORIES.has(candidate.category)) {
175
+ await this.handleProfileMerge(candidate, sessionKey);
176
+ stats.merged++;
177
+ return;
178
+ }
179
+
180
+ // Embed the candidate for vector dedup
181
+ const embeddingText = `${candidate.abstract} ${candidate.content}`;
182
+ const vector = await this.embedder.embed(embeddingText);
183
+ if (!vector || vector.length === 0) {
184
+ this.log("memory-pro: smart-extractor: embedding failed, storing as-is");
185
+ await this.storeCandidate(candidate, vector || [], sessionKey);
186
+ stats.created++;
187
+ return;
188
+ }
189
+
190
+ // Dedup pipeline
191
+ const dedupResult = await this.deduplicate(candidate, vector);
192
+
193
+ switch (dedupResult.decision) {
194
+ case "create":
195
+ await this.storeCandidate(candidate, vector, sessionKey);
196
+ stats.created++;
197
+ break;
198
+
199
+ case "merge":
200
+ if (
201
+ dedupResult.matchId &&
202
+ MERGE_SUPPORTED_CATEGORIES.has(candidate.category)
203
+ ) {
204
+ await this.handleMerge(candidate, dedupResult.matchId);
205
+ stats.merged++;
206
+ } else {
207
+ // Category doesn't support merge → create instead
208
+ await this.storeCandidate(candidate, vector, sessionKey);
209
+ stats.created++;
210
+ }
211
+ break;
212
+
213
+ case "skip":
214
+ this.log(
215
+ `memory-pro: smart-extractor: skipped [${candidate.category}] ${candidate.abstract.slice(0, 60)}`,
216
+ );
217
+ stats.skipped++;
218
+ break;
219
+ }
220
+ }
221
+
222
+ // --------------------------------------------------------------------------
223
+ // Dedup Pipeline (vector pre-filter + LLM decision)
224
+ // --------------------------------------------------------------------------
225
+
226
+ /**
227
+ * Two-stage dedup: vector similarity search → LLM decision.
228
+ * Ported from epro-memory/deduplicator.ts
229
+ */
230
+ private async deduplicate(
231
+ candidate: CandidateMemory,
232
+ candidateVector: number[],
233
+ ): Promise<DedupResult> {
234
+ // Stage 1: Vector pre-filter — find similar memories
235
+ const similar = await this.store.vectorSearch(
236
+ candidateVector,
237
+ 5,
238
+ SIMILARITY_THRESHOLD,
239
+ );
240
+
241
+ if (similar.length === 0) {
242
+ return { decision: "create", reason: "No similar memories found" };
243
+ }
244
+
245
+ // Stage 2: LLM decision
246
+ return this.llmDedupDecision(candidate, similar);
247
+ }
248
+
249
+ private async llmDedupDecision(
250
+ candidate: CandidateMemory,
251
+ similar: MemorySearchResult[],
252
+ ): Promise<DedupResult> {
253
+ const topSimilar = similar.slice(0, MAX_SIMILAR_FOR_PROMPT);
254
+ const existingFormatted = topSimilar
255
+ .map((r, i) => {
256
+ // Extract L0 abstract from metadata if available, fallback to text
257
+ let metaObj: Record<string, unknown> = {};
258
+ try {
259
+ metaObj = JSON.parse(r.entry.metadata || "{}");
260
+ } catch {}
261
+ const abstract = (metaObj.l0_abstract as string) || r.entry.text;
262
+ const overview = (metaObj.l1_overview as string) || "";
263
+ return `${i + 1}. [${(metaObj.memory_category as string) || r.entry.category}] ${abstract}\n Overview: ${overview}\n Score: ${r.score.toFixed(3)}`;
264
+ })
265
+ .join("\n");
266
+
267
+ const prompt = buildDedupPrompt(
268
+ candidate.abstract,
269
+ candidate.overview,
270
+ candidate.content,
271
+ existingFormatted,
272
+ );
273
+
274
+ try {
275
+ const data = await this.llm.completeJson<{
276
+ decision: string;
277
+ reason: string;
278
+ match_index?: number;
279
+ }>(prompt);
280
+
281
+ if (!data) {
282
+ this.log(
283
+ "memory-pro: smart-extractor: dedup LLM returned unparseable response, defaulting to CREATE",
284
+ );
285
+ return { decision: "create", reason: "LLM response unparseable" };
286
+ }
287
+
288
+ const decision = (data.decision?.toLowerCase() ??
289
+ "create") as DedupDecision;
290
+ if (!VALID_DECISIONS.has(decision)) {
291
+ return {
292
+ decision: "create",
293
+ reason: `Unknown decision: ${data.decision}`,
294
+ };
295
+ }
296
+
297
+ // Resolve merge target from LLM's match_index (1-based)
298
+ const idx = data.match_index;
299
+ const matchEntry =
300
+ typeof idx === "number" && idx >= 1 && idx <= topSimilar.length
301
+ ? topSimilar[idx - 1]
302
+ : topSimilar[0];
303
+
304
+ return {
305
+ decision,
306
+ reason: data.reason ?? "",
307
+ matchId: decision === "merge" ? matchEntry?.entry.id : undefined,
308
+ };
309
+ } catch (err) {
310
+ this.log(
311
+ `memory-pro: smart-extractor: dedup LLM failed: ${String(err)}`,
312
+ );
313
+ return { decision: "create", reason: `LLM failed: ${String(err)}` };
314
+ }
315
+ }
316
+
317
+ // --------------------------------------------------------------------------
318
+ // Merge Logic
319
+ // --------------------------------------------------------------------------
320
+
321
+ /**
322
+ * Profile always-merge: read existing profile, merge with LLM, upsert.
323
+ */
324
+ private async handleProfileMerge(
325
+ candidate: CandidateMemory,
326
+ sessionKey: string,
327
+ ): Promise<void> {
328
+ // Find existing profile memory by category
329
+ const embeddingText = `${candidate.abstract} ${candidate.content}`;
330
+ const vector = await this.embedder.embed(embeddingText);
331
+
332
+ // Search for existing profile memories
333
+ const existing = await this.store.vectorSearch(vector || [], 1, 0.3);
334
+ const profileMatch = existing.find((r) => {
335
+ try {
336
+ const meta = JSON.parse(r.entry.metadata || "{}");
337
+ return meta.memory_category === "profile";
338
+ } catch {
339
+ return false;
340
+ }
341
+ });
342
+
343
+ if (profileMatch) {
344
+ await this.handleMerge(candidate, profileMatch.entry.id);
345
+ } else {
346
+ // No existing profile — create new
347
+ await this.storeCandidate(candidate, vector || [], sessionKey);
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Merge a candidate into an existing memory using LLM.
353
+ */
354
+ private async handleMerge(
355
+ candidate: CandidateMemory,
356
+ matchId: string,
357
+ ): Promise<void> {
358
+ // Read existing memory
359
+ const results = await this.store.vectorSearch(
360
+ [],
361
+ 1,
362
+ 0,
363
+ );
364
+ // We need to get the existing entry by ID — use list then find
365
+ // Since LanceDB doesn't have a direct getById, use the store's list approach
366
+ let existingAbstract = "";
367
+ let existingOverview = "";
368
+ let existingContent = "";
369
+
370
+ try {
371
+ const allEntries = await this.store.list(undefined, undefined, 1000);
372
+ const existing = allEntries.find((e) => e.id === matchId);
373
+ if (existing) {
374
+ const meta = JSON.parse(existing.metadata || "{}");
375
+ existingAbstract = (meta.l0_abstract as string) || existing.text;
376
+ existingOverview = (meta.l1_overview as string) || "";
377
+ existingContent = (meta.l2_content as string) || existing.text;
378
+ }
379
+ } catch {
380
+ // Fallback: store as new
381
+ this.log(
382
+ `memory-pro: smart-extractor: could not read existing memory ${matchId}, storing as new`,
383
+ );
384
+ const vector = await this.embedder.embed(
385
+ `${candidate.abstract} ${candidate.content}`,
386
+ );
387
+ await this.storeCandidate(candidate, vector || [], "merge-fallback");
388
+ return;
389
+ }
390
+
391
+ // Call LLM to merge
392
+ const prompt = buildMergePrompt(
393
+ existingAbstract,
394
+ existingOverview,
395
+ existingContent,
396
+ candidate.abstract,
397
+ candidate.overview,
398
+ candidate.content,
399
+ candidate.category,
400
+ );
401
+
402
+ const merged = await this.llm.completeJson<{
403
+ abstract: string;
404
+ overview: string;
405
+ content: string;
406
+ }>(prompt);
407
+
408
+ if (!merged) {
409
+ this.log("memory-pro: smart-extractor: merge LLM failed, skipping merge");
410
+ return;
411
+ }
412
+
413
+ // Re-embed the merged content
414
+ const mergedText = `${merged.abstract} ${merged.content}`;
415
+ const newVector = await this.embedder.embed(mergedText);
416
+
417
+ // Update existing memory via store.update()
418
+ const metadata = JSON.stringify({
419
+ l0_abstract: merged.abstract,
420
+ l1_overview: merged.overview,
421
+ l2_content: merged.content,
422
+ memory_category: candidate.category,
423
+ tier: "working",
424
+ access_count: 1,
425
+ confidence: 0.8,
426
+ });
427
+
428
+ await this.store.update(matchId, {
429
+ text: merged.abstract,
430
+ vector: newVector,
431
+ metadata,
432
+ });
433
+
434
+ this.log(
435
+ `memory-pro: smart-extractor: merged [${candidate.category}] into ${matchId.slice(0, 8)}`,
436
+ );
437
+ }
438
+
439
+ // --------------------------------------------------------------------------
440
+ // Store Helper
441
+ // --------------------------------------------------------------------------
442
+
443
+ /**
444
+ * Store a candidate memory as a new entry with L0/L1/L2 metadata.
445
+ */
446
+ private async storeCandidate(
447
+ candidate: CandidateMemory,
448
+ vector: number[],
449
+ sessionKey: string,
450
+ ): Promise<void> {
451
+ // Map 6-category to existing store categories for backward compatibility
452
+ const storeCategory = this.mapToStoreCategory(candidate.category);
453
+
454
+ const metadata = JSON.stringify({
455
+ l0_abstract: candidate.abstract,
456
+ l1_overview: candidate.overview,
457
+ l2_content: candidate.content,
458
+ memory_category: candidate.category,
459
+ tier: "working",
460
+ access_count: 0,
461
+ confidence: 0.7,
462
+ source_session: sessionKey,
463
+ });
464
+
465
+ await this.store.store({
466
+ text: candidate.abstract, // L0 used as the searchable text
467
+ vector,
468
+ category: storeCategory,
469
+ scope: this.config.defaultScope ?? "global",
470
+ importance: this.getDefaultImportance(candidate.category),
471
+ metadata,
472
+ });
473
+
474
+ this.log(
475
+ `memory-pro: smart-extractor: created [${candidate.category}] ${candidate.abstract.slice(0, 60)}`,
476
+ );
477
+ }
478
+
479
+ /**
480
+ * Map 6-category to existing 5-category store type for backward compatibility.
481
+ */
482
+ private mapToStoreCategory(
483
+ category: MemoryCategory,
484
+ ): "preference" | "fact" | "decision" | "entity" | "other" {
485
+ switch (category) {
486
+ case "profile":
487
+ return "fact";
488
+ case "preferences":
489
+ return "preference";
490
+ case "entities":
491
+ return "entity";
492
+ case "events":
493
+ return "decision";
494
+ case "cases":
495
+ return "fact";
496
+ case "patterns":
497
+ return "other";
498
+ default:
499
+ return "other";
500
+ }
501
+ }
502
+
503
+ /**
504
+ * Get default importance score by category.
505
+ */
506
+ private getDefaultImportance(category: MemoryCategory): number {
507
+ switch (category) {
508
+ case "profile":
509
+ return 0.9; // Identity is very important
510
+ case "preferences":
511
+ return 0.8;
512
+ case "entities":
513
+ return 0.7;
514
+ case "events":
515
+ return 0.6;
516
+ case "cases":
517
+ return 0.8; // Problem-solution pairs are high value
518
+ case "patterns":
519
+ return 0.85; // Reusable processes are high value
520
+ default:
521
+ return 0.5;
522
+ }
523
+ }
524
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Tier Manager — Three-tier memory promotion/demotion system
3
+ * Ported from memx-memory's tier lifecycle model.
4
+ *
5
+ * Tiers:
6
+ * - Core (decay floor 0.9): Identity-level facts, almost never forgotten
7
+ * - Working (decay floor 0.7): Active context, ages out without reinforcement
8
+ * - Peripheral (decay floor 0.5): Low-priority or aging memories
9
+ *
10
+ * Promotion: Peripheral → Working → Core (based on access, composite score, importance)
11
+ * Demotion: Core → Working → Peripheral (based on decay, age)
12
+ */
13
+
14
+ import type { MemoryTier } from "./memory-categories.js";
15
+ import type { DecayScore } from "./decay-engine.js";
16
+
17
+ // ============================================================================
18
+ // Types
19
+ // ============================================================================
20
+
21
+ export interface TierConfig {
22
+ /** Minimum access count for Core promotion (default: 10) */
23
+ coreAccessThreshold: number;
24
+ /** Minimum composite decay score for Core promotion (default: 0.7) */
25
+ coreCompositeThreshold: number;
26
+ /** Minimum importance for Core promotion (default: 0.8) */
27
+ coreImportanceThreshold: number;
28
+ /** Composite threshold below which to demote to Peripheral (default: 0.15) */
29
+ peripheralCompositeThreshold: number;
30
+ /** Age in days after which infrequent memories demote to Peripheral (default: 60) */
31
+ peripheralAgeDays: number;
32
+ /** Minimum access count for Working promotion from Peripheral (default: 3) */
33
+ workingAccessThreshold: number;
34
+ /** Minimum composite for Working promotion from Peripheral (default: 0.4) */
35
+ workingCompositeThreshold: number;
36
+ }
37
+
38
+ export const DEFAULT_TIER_CONFIG: TierConfig = {
39
+ coreAccessThreshold: 10,
40
+ coreCompositeThreshold: 0.7,
41
+ coreImportanceThreshold: 0.8,
42
+ peripheralCompositeThreshold: 0.15,
43
+ peripheralAgeDays: 60,
44
+ workingAccessThreshold: 3,
45
+ workingCompositeThreshold: 0.4,
46
+ };
47
+
48
+ export interface TierTransition {
49
+ memoryId: string;
50
+ fromTier: MemoryTier;
51
+ toTier: MemoryTier;
52
+ reason: string;
53
+ }
54
+
55
+ /** Minimal memory fields needed for tier evaluation. */
56
+ export interface TierableMemory {
57
+ id: string;
58
+ tier: MemoryTier;
59
+ importance: number;
60
+ accessCount: number;
61
+ createdAt: number;
62
+ }
63
+
64
+ export interface TierManager {
65
+ /**
66
+ * Evaluate whether a memory should change tiers.
67
+ * Returns the transition if a change is needed, null otherwise.
68
+ */
69
+ evaluate(
70
+ memory: TierableMemory,
71
+ decayScore: DecayScore,
72
+ now?: number,
73
+ ): TierTransition | null;
74
+
75
+ /**
76
+ * Evaluate multiple memories and return all transitions.
77
+ */
78
+ evaluateAll(
79
+ memories: TierableMemory[],
80
+ decayScores: DecayScore[],
81
+ now?: number,
82
+ ): TierTransition[];
83
+ }
84
+
85
+ // ============================================================================
86
+ // Factory
87
+ // ============================================================================
88
+
89
+ const MS_PER_DAY = 86_400_000;
90
+
91
+ export function createTierManager(
92
+ config: TierConfig = DEFAULT_TIER_CONFIG,
93
+ ): TierManager {
94
+ function evaluate(
95
+ memory: TierableMemory,
96
+ decayScore: DecayScore,
97
+ now: number = Date.now(),
98
+ ): TierTransition | null {
99
+ const ageDays = (now - memory.createdAt) / MS_PER_DAY;
100
+
101
+ switch (memory.tier) {
102
+ case "peripheral": {
103
+ // Promote to Working?
104
+ if (
105
+ memory.accessCount >= config.workingAccessThreshold &&
106
+ decayScore.composite >= config.workingCompositeThreshold
107
+ ) {
108
+ return {
109
+ memoryId: memory.id,
110
+ fromTier: "peripheral",
111
+ toTier: "working",
112
+ reason: `Access count (${memory.accessCount}) >= ${config.workingAccessThreshold} and composite (${decayScore.composite.toFixed(2)}) >= ${config.workingCompositeThreshold}`,
113
+ };
114
+ }
115
+ break;
116
+ }
117
+
118
+ case "working": {
119
+ // Promote to Core?
120
+ if (
121
+ memory.accessCount >= config.coreAccessThreshold &&
122
+ decayScore.composite >= config.coreCompositeThreshold &&
123
+ memory.importance >= config.coreImportanceThreshold
124
+ ) {
125
+ return {
126
+ memoryId: memory.id,
127
+ fromTier: "working",
128
+ toTier: "core",
129
+ reason: `High access (${memory.accessCount}), composite (${decayScore.composite.toFixed(2)}), importance (${memory.importance})`,
130
+ };
131
+ }
132
+
133
+ // Demote to Peripheral?
134
+ if (
135
+ decayScore.composite < config.peripheralCompositeThreshold ||
136
+ (ageDays > config.peripheralAgeDays &&
137
+ memory.accessCount < config.workingAccessThreshold)
138
+ ) {
139
+ return {
140
+ memoryId: memory.id,
141
+ fromTier: "working",
142
+ toTier: "peripheral",
143
+ reason: `Low composite (${decayScore.composite.toFixed(2)}) or aged ${ageDays.toFixed(0)} days with low access (${memory.accessCount})`,
144
+ };
145
+ }
146
+ break;
147
+ }
148
+
149
+ case "core": {
150
+ // Demote to Working? (Core rarely demotes, but it can)
151
+ if (
152
+ decayScore.composite < config.peripheralCompositeThreshold &&
153
+ memory.accessCount < config.workingAccessThreshold
154
+ ) {
155
+ return {
156
+ memoryId: memory.id,
157
+ fromTier: "core",
158
+ toTier: "working",
159
+ reason: `Severely low composite (${decayScore.composite.toFixed(2)}) and access (${memory.accessCount})`,
160
+ };
161
+ }
162
+ break;
163
+ }
164
+ }
165
+
166
+ return null;
167
+ }
168
+
169
+ return {
170
+ evaluate,
171
+
172
+ evaluateAll(memories, decayScores, now = Date.now()) {
173
+ const scoreMap = new Map(decayScores.map((s) => [s.memoryId, s]));
174
+ const transitions: TierTransition[] = [];
175
+
176
+ for (const memory of memories) {
177
+ const score = scoreMap.get(memory.id);
178
+ if (!score) continue;
179
+
180
+ const transition = evaluate(memory, score, now);
181
+ if (transition) {
182
+ transitions.push(transition);
183
+ }
184
+ }
185
+
186
+ return transitions;
187
+ },
188
+ };
189
+ }