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.
- package/CHANGELOG-v1.1.0.md +227 -0
- package/CHANGELOG.md +23 -0
- package/README.md +82 -0
- package/README_CN.md +82 -0
- package/index.ts +106 -11
- package/openclaw.plugin.json +69 -1
- package/package.json +1 -1
- package/src/access-tracker.ts +13 -3
- package/src/decay-engine.ts +227 -0
- package/src/extraction-prompts.ts +205 -0
- package/src/llm-client.ts +92 -0
- package/src/memory-categories.ts +69 -0
- package/src/retriever.ts +152 -4
- package/src/smart-extractor.ts +524 -0
- package/src/tier-manager.ts +189 -0
|
@@ -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
|
+
}
|