dsh-tdai-memory 0.1.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/LICENSE +21 -0
- package/README.md +56 -0
- package/THIRD-PARTY-NOTICES.md +20 -0
- package/index.js +328 -0
- package/package.json +53 -0
- package/recall-inject.js +80 -0
- package/vendor/tdai/adapters/index.js +15 -0
- package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -0
- package/vendor/tdai/adapters/openclaw/index.js +5 -0
- package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -0
- package/vendor/tdai/adapters/standalone/host-adapter.js +58 -0
- package/vendor/tdai/adapters/standalone/index.js +5 -0
- package/vendor/tdai/adapters/standalone/llm-runner.js +246 -0
- package/vendor/tdai/config.js +363 -0
- package/vendor/tdai/core/conversation/l0-recorder.js +420 -0
- package/vendor/tdai/core/hooks/auto-capture.js +236 -0
- package/vendor/tdai/core/hooks/auto-recall.js +653 -0
- package/vendor/tdai/core/index.js +8 -0
- package/vendor/tdai/core/persona/persona-generator.js +184 -0
- package/vendor/tdai/core/persona/persona-trigger.js +94 -0
- package/vendor/tdai/core/profile/profile-sync.js +200 -0
- package/vendor/tdai/core/prompts/l1-dedup.js +136 -0
- package/vendor/tdai/core/prompts/l1-extraction.js +132 -0
- package/vendor/tdai/core/prompts/persona-generation.js +160 -0
- package/vendor/tdai/core/prompts/scene-extraction.js +269 -0
- package/vendor/tdai/core/record/l1-dedup.js +318 -0
- package/vendor/tdai/core/record/l1-extractor.js +379 -0
- package/vendor/tdai/core/record/l1-reader.js +178 -0
- package/vendor/tdai/core/record/l1-writer.js +141 -0
- package/vendor/tdai/core/report/reporter.js +86 -0
- package/vendor/tdai/core/scene/filename-normalizer.js +161 -0
- package/vendor/tdai/core/scene/scene-extractor.js +410 -0
- package/vendor/tdai/core/scene/scene-format.js +53 -0
- package/vendor/tdai/core/scene/scene-index.js +82 -0
- package/vendor/tdai/core/scene/scene-navigation.js +60 -0
- package/vendor/tdai/core/seed/input.js +404 -0
- package/vendor/tdai/core/seed/seed-runtime.js +314 -0
- package/vendor/tdai/core/seed/types.js +9 -0
- package/vendor/tdai/core/store/bm25-client.js +117 -0
- package/vendor/tdai/core/store/bm25-local.js +68 -0
- package/vendor/tdai/core/store/embedding.js +632 -0
- package/vendor/tdai/core/store/factory.js +95 -0
- package/vendor/tdai/core/store/search-utils.js +54 -0
- package/vendor/tdai/core/store/sqlite.js +1822 -0
- package/vendor/tdai/core/store/tcvdb-client.js +221 -0
- package/vendor/tdai/core/store/tcvdb.js +1047 -0
- package/vendor/tdai/core/store/types.js +17 -0
- package/vendor/tdai/core/tdai-core.js +446 -0
- package/vendor/tdai/core/tools/conversation-search.js +199 -0
- package/vendor/tdai/core/tools/memory-search.js +207 -0
- package/vendor/tdai/core/types.js +12 -0
- package/vendor/tdai/utils/backup.js +189 -0
- package/vendor/tdai/utils/checkpoint.js +356 -0
- package/vendor/tdai/utils/clean-context-runner.js +472 -0
- package/vendor/tdai/utils/ensure-hook-policy.js +213 -0
- package/vendor/tdai/utils/env.js +13 -0
- package/vendor/tdai/utils/managed-timer.js +127 -0
- package/vendor/tdai/utils/manifest.js +91 -0
- package/vendor/tdai/utils/memory-cleaner.js +300 -0
- package/vendor/tdai/utils/no-think-fetch.js +109 -0
- package/vendor/tdai/utils/openclaw-state-dir.js +26 -0
- package/vendor/tdai/utils/pipeline-factory.js +517 -0
- package/vendor/tdai/utils/pipeline-manager.js +892 -0
- package/vendor/tdai/utils/sanitize.js +360 -0
- package/vendor/tdai/utils/serial-queue.js +103 -0
- package/vendor/tdai/utils/session-filter.js +88 -0
- package/vendor/tdai/utils/text-utils.js +29 -0
- package/vendor/tdai/utils/time.js +252 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1 Memory Conflict Detection (Batch Mode): decides how to handle multiple new
|
|
3
|
+
* memories against existing records in a single LLM call.
|
|
4
|
+
*
|
|
5
|
+
* v4: Removed JSONL-based Jaccard fallback. Candidate recall now relies exclusively
|
|
6
|
+
* on vector search (primary) and FTS5 BM25 (degraded). If neither is available,
|
|
7
|
+
* conflict detection is skipped entirely — all memories go straight to store.
|
|
8
|
+
*
|
|
9
|
+
* Two-phase approach:
|
|
10
|
+
* 1. Candidate search per new memory — vector recall or FTS5 keyword recall (fast, no LLM)
|
|
11
|
+
* 2. Batch LLM judgment on all new memories + their candidate pools (single call)
|
|
12
|
+
*/
|
|
13
|
+
import { CONFLICT_DETECTION_SYSTEM_PROMPT, formatBatchConflictPrompt } from "../prompts/l1-dedup.js";
|
|
14
|
+
import { CleanContextRunner } from "../../utils/clean-context-runner.js";
|
|
15
|
+
import { sanitizeJsonForParse } from "../../utils/sanitize.js";
|
|
16
|
+
import { buildFtsQuery } from "../store/sqlite.js";
|
|
17
|
+
const TAG = "[memory-tdai][l1-dedup]";
|
|
18
|
+
// ============================
|
|
19
|
+
// Core function (batch mode)
|
|
20
|
+
// ============================
|
|
21
|
+
/**
|
|
22
|
+
* Batch conflict detection: compare all new memories against existing records
|
|
23
|
+
* in a single LLM call.
|
|
24
|
+
*
|
|
25
|
+
* Candidate recall strategy (3-tier degradation):
|
|
26
|
+
* 1. Vector recall (vectorStore + embeddingService) — cosine similarity (best)
|
|
27
|
+
* 2. FTS5 keyword recall (vectorStore with FTS available) — BM25 ranking (degraded)
|
|
28
|
+
* 3. Skip conflict detection entirely — all memories go straight to "store"
|
|
29
|
+
*
|
|
30
|
+
* The old JSONL-based Jaccard fallback has been removed. If neither vector search
|
|
31
|
+
* nor FTS is available, we skip dedup rather than paying the O(N) full-file-scan cost.
|
|
32
|
+
*
|
|
33
|
+
* @param memories - Newly extracted memories (with record_id)
|
|
34
|
+
* @param config - OpenClaw config (for LLM access)
|
|
35
|
+
* @param logger - Optional logger
|
|
36
|
+
* @param model - Optional model override
|
|
37
|
+
* @param vectorStore - Optional vector store for cosine similarity search
|
|
38
|
+
* @param embeddingService - Optional embedding service for computing query vectors
|
|
39
|
+
* @param conflictRecallTopK - Top-K candidates to recall per new memory (default: 5)
|
|
40
|
+
* @returns Array of dedup decisions, one per new memory
|
|
41
|
+
*/
|
|
42
|
+
export async function batchDedup(params) {
|
|
43
|
+
const { memories, config, logger, model, vectorStore, embeddingService, llmRunner } = params;
|
|
44
|
+
const topK = params.conflictRecallTopK ?? 5;
|
|
45
|
+
if (memories.length === 0) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const storeAll = () => memories.map((m) => ({
|
|
49
|
+
record_id: m.record_id,
|
|
50
|
+
action: "store",
|
|
51
|
+
target_ids: [],
|
|
52
|
+
}));
|
|
53
|
+
// Determine what recall capabilities are available
|
|
54
|
+
const hasVectorData = vectorStore && (await vectorStore.countL1()) > 0;
|
|
55
|
+
const hasFts = vectorStore?.isFtsAvailable() ?? false;
|
|
56
|
+
// Fast path: no recall capability at all → skip dedup
|
|
57
|
+
if (!hasVectorData && !hasFts) {
|
|
58
|
+
logger?.debug?.(`${TAG} No vector data and no FTS available, skipping conflict detection for ${memories.length} memories`);
|
|
59
|
+
return storeAll();
|
|
60
|
+
}
|
|
61
|
+
// Phase 1: Find candidates
|
|
62
|
+
//
|
|
63
|
+
// Decision tree (after the fast-path guard above, vectorStore is guaranteed non-null):
|
|
64
|
+
// hasVectorData + embeddingService → Tier 1 vector recall (FTS fallback on error)
|
|
65
|
+
// otherwise hasFts → Tier 2 FTS keyword recall
|
|
66
|
+
// otherwise → skip dedup (defensive; shouldn't reach here)
|
|
67
|
+
let matches;
|
|
68
|
+
if (hasVectorData && embeddingService) {
|
|
69
|
+
// === Tier 1: Vector recall mode ===
|
|
70
|
+
logger?.debug?.(`${TAG} Using vector recall mode (topK=${topK})`);
|
|
71
|
+
try {
|
|
72
|
+
matches = await findCandidatesByVector(memories, vectorStore, embeddingService, topK, logger, params.embeddingTimeoutMs);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
logger?.warn?.(`${TAG} Vector recall failed, falling back to FTS keyword: ${err instanceof Error ? err.message : String(err)}`);
|
|
76
|
+
// Degrade to FTS keyword recall
|
|
77
|
+
if (hasFts) {
|
|
78
|
+
matches = await findCandidatesByFts(memories, vectorStore, logger);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
logger?.debug?.(`${TAG} FTS not available either, skipping conflict detection`);
|
|
82
|
+
return storeAll();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else if (hasFts) {
|
|
87
|
+
// === Tier 2: FTS keyword recall ===
|
|
88
|
+
logger?.debug?.(`${TAG} Using FTS keyword recall mode (no embedding service or no vector data)`);
|
|
89
|
+
matches = await findCandidatesByFts(memories, vectorStore, logger);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
// Shouldn't reach here given the fast-path check above, but be defensive
|
|
93
|
+
logger?.debug?.(`${TAG} No usable recall path, skipping conflict detection`);
|
|
94
|
+
return storeAll();
|
|
95
|
+
}
|
|
96
|
+
// Check if any memory has candidates
|
|
97
|
+
const hasAnyCandidates = matches.some((m) => m.candidates.length > 0);
|
|
98
|
+
if (!hasAnyCandidates) {
|
|
99
|
+
logger?.debug?.(`${TAG} No similar records found for any memory, all will be stored`);
|
|
100
|
+
return storeAll();
|
|
101
|
+
}
|
|
102
|
+
// Phase 2: Batch LLM judgment
|
|
103
|
+
return runLlmJudgment(matches, memories, config, logger, model, llmRunner);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Phase 2: Run batch LLM judgment on candidate matches.
|
|
107
|
+
*/
|
|
108
|
+
async function runLlmJudgment(matches, memories, config, logger, model, llmRunner) {
|
|
109
|
+
logger?.debug?.(`${TAG} Running batch conflict detection for ${memories.length} memories`);
|
|
110
|
+
try {
|
|
111
|
+
const userPrompt = formatBatchConflictPrompt(matches);
|
|
112
|
+
let result;
|
|
113
|
+
if (llmRunner) {
|
|
114
|
+
// Use the host-neutral LLMRunner interface
|
|
115
|
+
result = await llmRunner.run({
|
|
116
|
+
prompt: userPrompt,
|
|
117
|
+
systemPrompt: CONFLICT_DETECTION_SYSTEM_PROMPT,
|
|
118
|
+
taskId: "l1-conflict-detection",
|
|
119
|
+
timeoutMs: 180_000,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
// Fallback: create CleanContextRunner (OpenClaw path)
|
|
124
|
+
const runner = new CleanContextRunner({
|
|
125
|
+
config,
|
|
126
|
+
modelRef: model,
|
|
127
|
+
enableTools: false,
|
|
128
|
+
logger,
|
|
129
|
+
});
|
|
130
|
+
result = await runner.run({
|
|
131
|
+
prompt: userPrompt,
|
|
132
|
+
systemPrompt: CONFLICT_DETECTION_SYSTEM_PROMPT,
|
|
133
|
+
taskId: "l1-conflict-detection",
|
|
134
|
+
timeoutMs: 180_000,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const decisions = parseBatchResult(result, memories, logger);
|
|
138
|
+
return decisions;
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
logger?.warn?.(`${TAG} Batch conflict detection failed, defaulting all to store: ${err instanceof Error ? err.message : String(err)}`);
|
|
142
|
+
return memories.map((m) => ({
|
|
143
|
+
record_id: m.record_id,
|
|
144
|
+
action: "store",
|
|
145
|
+
target_ids: [],
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// ============================
|
|
150
|
+
// Candidate recall strategies
|
|
151
|
+
// ============================
|
|
152
|
+
/**
|
|
153
|
+
* Vector-based candidate recall (aligned with prototype):
|
|
154
|
+
* batch-embed new memories → cosine search in VectorStore → exclude self-batch → return candidates.
|
|
155
|
+
*/
|
|
156
|
+
async function findCandidatesByVector(memories, vectorStore, embeddingService, topK, logger, embeddingTimeoutMs) {
|
|
157
|
+
const newRecordIds = new Set(memories.map((m) => m.record_id));
|
|
158
|
+
// Batch-compute embeddings for all new memories
|
|
159
|
+
const texts = memories.map((m) => m.content);
|
|
160
|
+
const embeddings = await embeddingService.embedBatch(texts, embeddingTimeoutMs ? { timeoutMs: embeddingTimeoutMs } : undefined);
|
|
161
|
+
const matches = [];
|
|
162
|
+
for (let i = 0; i < memories.length; i++) {
|
|
163
|
+
const mem = memories[i];
|
|
164
|
+
const queryVec = embeddings[i];
|
|
165
|
+
// Vector search top-K (request extra to account for self-batch filtering)
|
|
166
|
+
const searchResults = await vectorStore.searchL1Vector(queryVec, topK + memories.length, mem.content);
|
|
167
|
+
// Exclude records from current batch, convert to MemoryRecord format
|
|
168
|
+
const candidates = searchResults
|
|
169
|
+
.filter((r) => !newRecordIds.has(r.record_id))
|
|
170
|
+
.slice(0, topK)
|
|
171
|
+
.map((r) => ({
|
|
172
|
+
id: r.record_id,
|
|
173
|
+
content: r.content,
|
|
174
|
+
type: r.type,
|
|
175
|
+
priority: r.priority,
|
|
176
|
+
scene_name: r.scene_name,
|
|
177
|
+
source_message_ids: [],
|
|
178
|
+
metadata: {},
|
|
179
|
+
timestamps: [r.timestamp_str].filter(Boolean),
|
|
180
|
+
createdAt: "",
|
|
181
|
+
updatedAt: "",
|
|
182
|
+
sessionKey: r.session_key,
|
|
183
|
+
sessionId: r.session_id,
|
|
184
|
+
}));
|
|
185
|
+
matches.push({ newMemory: mem, candidates });
|
|
186
|
+
}
|
|
187
|
+
logger?.debug?.(`${TAG} Vector recall: ${matches.map((m) => `${m.newMemory.record_id}→${m.candidates.length}`).join(", ")}`);
|
|
188
|
+
return matches;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* FTS5-based candidate recall:
|
|
192
|
+
* Uses the FTS index for efficient BM25-ranked keyword matching.
|
|
193
|
+
* This replaces the old Jaccard word-overlap fallback entirely.
|
|
194
|
+
*/
|
|
195
|
+
async function findCandidatesByFts(memories, vectorStore, _logger) {
|
|
196
|
+
const newRecordIds = new Set(memories.map((m) => m.record_id));
|
|
197
|
+
const matches = [];
|
|
198
|
+
for (const mem of memories) {
|
|
199
|
+
const ftsQuery = buildFtsQuery(mem.content);
|
|
200
|
+
if (ftsQuery) {
|
|
201
|
+
const ftsResults = await vectorStore.searchL1Fts(ftsQuery, 10);
|
|
202
|
+
// Filter out records from the current batch
|
|
203
|
+
const candidates = ftsResults
|
|
204
|
+
.filter((r) => !newRecordIds.has(r.record_id))
|
|
205
|
+
.slice(0, 5)
|
|
206
|
+
.map((r) => ({
|
|
207
|
+
id: r.record_id,
|
|
208
|
+
content: r.content,
|
|
209
|
+
type: r.type,
|
|
210
|
+
priority: r.priority,
|
|
211
|
+
scene_name: r.scene_name,
|
|
212
|
+
source_message_ids: [],
|
|
213
|
+
metadata: r.metadata_json ? (() => { try {
|
|
214
|
+
return JSON.parse(r.metadata_json);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return {};
|
|
218
|
+
} })() : {},
|
|
219
|
+
timestamps: [r.timestamp_str].filter(Boolean),
|
|
220
|
+
createdAt: "",
|
|
221
|
+
updatedAt: "",
|
|
222
|
+
sessionKey: r.session_key,
|
|
223
|
+
sessionId: r.session_id,
|
|
224
|
+
}));
|
|
225
|
+
matches.push({ newMemory: mem, candidates });
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
matches.push({ newMemory: mem, candidates: [] });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
_logger?.debug?.(`${TAG} FTS keyword recall: ${matches.map((m) => `${m.newMemory.record_id}→${m.candidates.length}`).join(", ")}`);
|
|
232
|
+
return matches;
|
|
233
|
+
}
|
|
234
|
+
// ============================
|
|
235
|
+
// Result parsing
|
|
236
|
+
// ============================
|
|
237
|
+
const VALID_TYPES = ["persona", "episodic", "instruction"];
|
|
238
|
+
/**
|
|
239
|
+
* Parse the LLM's batch conflict detection JSON response.
|
|
240
|
+
*
|
|
241
|
+
* Expected format: [{record_id, action, target_ids, merged_content, merged_type, merged_priority, merged_timestamps}]
|
|
242
|
+
*/
|
|
243
|
+
function parseBatchResult(raw, memories, logger) {
|
|
244
|
+
try {
|
|
245
|
+
// Strip markdown code block wrappers
|
|
246
|
+
let cleaned = raw.trim();
|
|
247
|
+
if (cleaned.startsWith("```")) {
|
|
248
|
+
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "");
|
|
249
|
+
}
|
|
250
|
+
// Extract JSON array
|
|
251
|
+
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
|
|
252
|
+
if (!arrayMatch) {
|
|
253
|
+
logger?.warn?.(`${TAG} No JSON array found in conflict detection response`);
|
|
254
|
+
return fallbackStoreAll(memories);
|
|
255
|
+
}
|
|
256
|
+
// Sanitize control characters inside JSON string literals that LLM may produce
|
|
257
|
+
const sanitized = sanitizeJsonForParse(arrayMatch[0]);
|
|
258
|
+
const parsed = JSON.parse(sanitized);
|
|
259
|
+
if (!Array.isArray(parsed)) {
|
|
260
|
+
logger?.warn?.(`${TAG} Conflict detection response is not an array`);
|
|
261
|
+
return fallbackStoreAll(memories);
|
|
262
|
+
}
|
|
263
|
+
// Build decisions from LLM output
|
|
264
|
+
const decisions = [];
|
|
265
|
+
const validActions = ["store", "update", "merge", "skip"];
|
|
266
|
+
for (const item of parsed) {
|
|
267
|
+
if (!item || typeof item !== "object")
|
|
268
|
+
continue;
|
|
269
|
+
const d = item;
|
|
270
|
+
const recordId = String(d.record_id ?? "");
|
|
271
|
+
// Skip entries with empty/missing record_id — they are LLM hallucinations
|
|
272
|
+
if (!recordId) {
|
|
273
|
+
logger?.debug?.(`${TAG} Skipping decision with empty record_id`);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const action = String(d.action ?? "store");
|
|
277
|
+
if (!validActions.includes(action)) {
|
|
278
|
+
logger?.warn?.(`${TAG} Invalid action "${action}" for record ${recordId}, defaulting to store`);
|
|
279
|
+
}
|
|
280
|
+
decisions.push({
|
|
281
|
+
record_id: recordId,
|
|
282
|
+
action: validActions.includes(action) ? action : "store",
|
|
283
|
+
target_ids: Array.isArray(d.target_ids) ? d.target_ids.map(String) : [],
|
|
284
|
+
merged_content: typeof d.merged_content === "string" ? d.merged_content : undefined,
|
|
285
|
+
merged_type: VALID_TYPES.includes(d.merged_type) ? d.merged_type : undefined,
|
|
286
|
+
merged_priority: typeof d.merged_priority === "number" ? d.merged_priority : undefined,
|
|
287
|
+
merged_timestamps: Array.isArray(d.merged_timestamps) ? d.merged_timestamps.map(String) : undefined,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
// Ensure all memories have a decision (fill missing with "store")
|
|
291
|
+
const decidedIds = new Set(decisions.map((d) => d.record_id));
|
|
292
|
+
for (const mem of memories) {
|
|
293
|
+
if (!decidedIds.has(mem.record_id)) {
|
|
294
|
+
logger?.debug?.(`${TAG} No decision for record ${mem.record_id}, defaulting to store`);
|
|
295
|
+
decisions.push({
|
|
296
|
+
record_id: mem.record_id,
|
|
297
|
+
action: "store",
|
|
298
|
+
target_ids: [],
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return decisions;
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
logger?.warn?.(`${TAG} Failed to parse conflict detection result: ${err instanceof Error ? err.message : String(err)}`);
|
|
306
|
+
return fallbackStoreAll(memories);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Fallback: store all memories when parsing fails.
|
|
311
|
+
*/
|
|
312
|
+
function fallbackStoreAll(memories) {
|
|
313
|
+
return memories.map((m) => ({
|
|
314
|
+
record_id: m.record_id,
|
|
315
|
+
action: "store",
|
|
316
|
+
target_ids: [],
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1 Memory Extractor: extracts structured memories from L0 conversation messages
|
|
3
|
+
* using a single LLM call with JSON-mode structured output.
|
|
4
|
+
*
|
|
5
|
+
* v3: Aligned with Kenty's prompt — scene segmentation + memory extraction in one call,
|
|
6
|
+
* followed by batch conflict detection.
|
|
7
|
+
*
|
|
8
|
+
* Pipeline:
|
|
9
|
+
* 1. Read recent messages from L0 (split into background + new)
|
|
10
|
+
* 2. Call LLM to extract scene-segmented memories
|
|
11
|
+
* 3. Batch conflict detection against existing records
|
|
12
|
+
* 4. Write to L1 JSONL files
|
|
13
|
+
*/
|
|
14
|
+
import { EXTRACT_MEMORIES_SYSTEM_PROMPT, formatExtractionPrompt } from "../prompts/l1-extraction.js";
|
|
15
|
+
import { batchDedup } from "./l1-dedup.js";
|
|
16
|
+
import { writeMemory, generateMemoryId } from "./l1-writer.js";
|
|
17
|
+
import { CleanContextRunner } from "../../utils/clean-context-runner.js";
|
|
18
|
+
import { sanitizeJsonForParse, shouldExtractL1 } from "../../utils/sanitize.js";
|
|
19
|
+
import { report } from "../report/reporter.js";
|
|
20
|
+
const TAG = "[memory-tdai][l1-extractor]";
|
|
21
|
+
// ============================
|
|
22
|
+
// Core function
|
|
23
|
+
// ============================
|
|
24
|
+
/**
|
|
25
|
+
* Run the full L1 extraction pipeline on conversation messages.
|
|
26
|
+
*
|
|
27
|
+
* @param messages - Filtered conversation messages (from L0 or directly from hook)
|
|
28
|
+
* @param sessionKey - The session key
|
|
29
|
+
* @param baseDir - Base data directory (~/.openclaw/memory-tdai/)
|
|
30
|
+
* @param config - OpenClaw config (for LLM access)
|
|
31
|
+
* @param options - Extraction options
|
|
32
|
+
* @param logger - Optional logger
|
|
33
|
+
*/
|
|
34
|
+
export async function extractL1Memories(params) {
|
|
35
|
+
const { messages, sessionKey, sessionId, baseDir, config, logger, instanceId: metricInstanceId } = params;
|
|
36
|
+
const options = params.options ?? {};
|
|
37
|
+
const maxNewMessages = options.maxMessagesPerExtraction ?? 10;
|
|
38
|
+
const maxBgMessages = options.maxBackgroundMessages ?? 5;
|
|
39
|
+
const enableDedup = options.enableDedup ?? true;
|
|
40
|
+
const maxMemoriesPerSession = options.maxMemoriesPerSession ?? 10;
|
|
41
|
+
if (messages.length === 0) {
|
|
42
|
+
logger?.debug?.(`${TAG} No messages to extract from`);
|
|
43
|
+
return { success: true, extractedCount: 0, storedCount: 0, records: [], sceneNames: [] };
|
|
44
|
+
}
|
|
45
|
+
const l1StartMs = Date.now();
|
|
46
|
+
// Quality gate: filter messages through L1 extraction rules (length, symbols,
|
|
47
|
+
// prompt injection, etc.) before sending to the LLM. L0 deliberately captures
|
|
48
|
+
// everything; the strict filtering happens here at L1 stage.
|
|
49
|
+
const qualifiedMessages = messages.filter((m) => shouldExtractL1(m.content));
|
|
50
|
+
if (qualifiedMessages.length < messages.length) {
|
|
51
|
+
logger?.debug?.(`${TAG} L1 quality filter: ${messages.length} → ${qualifiedMessages.length} messages ` +
|
|
52
|
+
`(${messages.length - qualifiedMessages.length} filtered out)`);
|
|
53
|
+
}
|
|
54
|
+
if (qualifiedMessages.length === 0) {
|
|
55
|
+
logger?.debug?.(`${TAG} All messages filtered out by L1 quality gate`);
|
|
56
|
+
return { success: true, extractedCount: 0, storedCount: 0, records: [], sceneNames: [] };
|
|
57
|
+
}
|
|
58
|
+
// Split messages into background (older) + new (recent)
|
|
59
|
+
const newMessages = qualifiedMessages.slice(-maxNewMessages);
|
|
60
|
+
const bgEndIdx = qualifiedMessages.length - newMessages.length;
|
|
61
|
+
const backgroundMessages = bgEndIdx > 0
|
|
62
|
+
? qualifiedMessages.slice(Math.max(0, bgEndIdx - maxBgMessages), bgEndIdx)
|
|
63
|
+
: [];
|
|
64
|
+
logger?.debug?.(`${TAG} Extracting from ${newMessages.length} new messages (+ ${backgroundMessages.length} background) [${qualifiedMessages.length} qualified from ${messages.length} input]`);
|
|
65
|
+
// Step 1: LLM extraction (scene segmentation + memory extraction)
|
|
66
|
+
let scenes;
|
|
67
|
+
try {
|
|
68
|
+
scenes = await callLlmExtraction({
|
|
69
|
+
newMessages,
|
|
70
|
+
backgroundMessages,
|
|
71
|
+
previousSceneName: options.previousSceneName,
|
|
72
|
+
config,
|
|
73
|
+
logger,
|
|
74
|
+
model: options.model,
|
|
75
|
+
llmRunner: options.llmRunner,
|
|
76
|
+
});
|
|
77
|
+
logger?.debug?.(`${TAG} LLM detected ${scenes.length} scene(s)`);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
logger?.error(`${TAG} LLM extraction failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
81
|
+
return { success: false, extractedCount: 0, storedCount: 0, records: [], sceneNames: [] };
|
|
82
|
+
}
|
|
83
|
+
// Flatten all memories across scenes
|
|
84
|
+
const allExtracted = [];
|
|
85
|
+
const sceneNames = [];
|
|
86
|
+
for (const scene of scenes) {
|
|
87
|
+
sceneNames.push(scene.scene_name);
|
|
88
|
+
for (const mem of scene.memories) {
|
|
89
|
+
const memType = normalizeType(mem.type);
|
|
90
|
+
if (!memType) {
|
|
91
|
+
logger?.warn?.(`${TAG} Skipping memory with invalid type "${mem.type}"`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
allExtracted.push({
|
|
95
|
+
content: mem.content,
|
|
96
|
+
type: memType,
|
|
97
|
+
priority: typeof mem.priority === "number" ? mem.priority : 50,
|
|
98
|
+
source_message_ids: Array.isArray(mem.source_message_ids) ? mem.source_message_ids : [],
|
|
99
|
+
metadata: mem.metadata ?? {},
|
|
100
|
+
scene_name: scene.scene_name,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
logger?.debug?.(`${TAG} Total extracted memories: ${allExtracted.length} across ${scenes.length} scene(s)`);
|
|
105
|
+
if (allExtracted.length === 0) {
|
|
106
|
+
return {
|
|
107
|
+
success: true,
|
|
108
|
+
extractedCount: 0,
|
|
109
|
+
storedCount: 0,
|
|
110
|
+
records: [],
|
|
111
|
+
sceneNames,
|
|
112
|
+
lastSceneName: sceneNames[sceneNames.length - 1],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// Limit per session
|
|
116
|
+
let extracted = allExtracted;
|
|
117
|
+
if (extracted.length > maxMemoriesPerSession) {
|
|
118
|
+
logger?.debug?.(`${TAG} Limiting from ${extracted.length} to ${maxMemoriesPerSession} memories per session`);
|
|
119
|
+
extracted = extracted.slice(0, maxMemoriesPerSession);
|
|
120
|
+
}
|
|
121
|
+
// Assign temporary IDs to extracted memories (needed for batch dedup)
|
|
122
|
+
const memoriesWithIds = extracted.map((m) => ({
|
|
123
|
+
...m,
|
|
124
|
+
record_id: generateMemoryId(),
|
|
125
|
+
}));
|
|
126
|
+
// Step 2: Batch Conflict Detection + Write
|
|
127
|
+
let storedRecords;
|
|
128
|
+
if (enableDedup) {
|
|
129
|
+
try {
|
|
130
|
+
const decisions = await batchDedup({
|
|
131
|
+
memories: memoriesWithIds,
|
|
132
|
+
config,
|
|
133
|
+
logger,
|
|
134
|
+
model: options.model,
|
|
135
|
+
vectorStore: options.vectorStore,
|
|
136
|
+
embeddingService: options.embeddingService,
|
|
137
|
+
conflictRecallTopK: options.conflictRecallTopK,
|
|
138
|
+
embeddingTimeoutMs: options.embeddingTimeoutMs,
|
|
139
|
+
llmRunner: options.llmRunner,
|
|
140
|
+
});
|
|
141
|
+
storedRecords = await applyDecisions({
|
|
142
|
+
memoriesWithIds,
|
|
143
|
+
decisions,
|
|
144
|
+
baseDir,
|
|
145
|
+
sessionKey,
|
|
146
|
+
sessionId,
|
|
147
|
+
logger,
|
|
148
|
+
vectorStore: options.vectorStore,
|
|
149
|
+
embeddingService: options.embeddingService,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
logger?.warn?.(`${TAG} Batch dedup failed, storing all as new: ${err instanceof Error ? err.message : String(err)}`);
|
|
154
|
+
storedRecords = await storeAllDirectly(memoriesWithIds, baseDir, sessionKey, sessionId, logger, options.vectorStore, options.embeddingService);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
storedRecords = await storeAllDirectly(memoriesWithIds, baseDir, sessionKey, sessionId, logger, options.vectorStore, options.embeddingService);
|
|
159
|
+
}
|
|
160
|
+
logger?.info(`${TAG} Extraction complete: extracted=${extracted.length}, stored=${storedRecords.length}`);
|
|
161
|
+
// ── l1_extraction metric ──
|
|
162
|
+
if (metricInstanceId && logger) {
|
|
163
|
+
// Build type distribution of stored memories
|
|
164
|
+
const memoriesByType = {};
|
|
165
|
+
for (const r of storedRecords) {
|
|
166
|
+
memoriesByType[r.type] = (memoriesByType[r.type] ?? 0) + 1;
|
|
167
|
+
}
|
|
168
|
+
report("l1_extraction", {
|
|
169
|
+
sessionKey,
|
|
170
|
+
inputMessageCount: messages.length,
|
|
171
|
+
memoriesExtracted: extracted.length,
|
|
172
|
+
memoriesStored: storedRecords.length,
|
|
173
|
+
memoriesStoredContent: storedRecords.map((r) => ({
|
|
174
|
+
content: r.content,
|
|
175
|
+
type: r.type,
|
|
176
|
+
scene: r.scene_name ?? null,
|
|
177
|
+
})),
|
|
178
|
+
memoriesByType,
|
|
179
|
+
totalDurationMs: Date.now() - l1StartMs,
|
|
180
|
+
success: true,
|
|
181
|
+
error: null,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
success: true,
|
|
186
|
+
extractedCount: extracted.length,
|
|
187
|
+
storedCount: storedRecords.length,
|
|
188
|
+
records: storedRecords,
|
|
189
|
+
sceneNames,
|
|
190
|
+
lastSceneName: sceneNames[sceneNames.length - 1],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
// ============================
|
|
194
|
+
// LLM call
|
|
195
|
+
// ============================
|
|
196
|
+
/**
|
|
197
|
+
* Call LLM to extract scene-segmented memories from conversation messages.
|
|
198
|
+
*/
|
|
199
|
+
async function callLlmExtraction(params) {
|
|
200
|
+
const { newMessages, backgroundMessages, previousSceneName, config, logger, model, llmRunner } = params;
|
|
201
|
+
const userPrompt = formatExtractionPrompt({
|
|
202
|
+
newMessages,
|
|
203
|
+
backgroundMessages,
|
|
204
|
+
previousSceneName,
|
|
205
|
+
});
|
|
206
|
+
// [l1-debug] ENTRY — what are we about to ask the LLM to extract?
|
|
207
|
+
logger?.debug?.(`${TAG} [l1-debug] ENTRY taskId=l1-extraction, newMsgs=${newMessages.length}, bgMsgs=${backgroundMessages.length}, userPromptLen=${userPrompt.length}, sysPromptLen=${EXTRACT_MEMORIES_SYSTEM_PROMPT.length}, model=${model ?? "(default)"}, previousSceneName=${previousSceneName ? JSON.stringify(previousSceneName) : "(none)"}, runnerKind=${llmRunner ? "llmRunner" : "CleanContextRunner"}`);
|
|
208
|
+
let result;
|
|
209
|
+
if (llmRunner) {
|
|
210
|
+
// Use the host-neutral LLMRunner interface
|
|
211
|
+
result = await llmRunner.run({
|
|
212
|
+
prompt: userPrompt,
|
|
213
|
+
systemPrompt: EXTRACT_MEMORIES_SYSTEM_PROMPT,
|
|
214
|
+
taskId: "l1-extraction",
|
|
215
|
+
timeoutMs: 180_000,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
// Fallback: create CleanContextRunner (OpenClaw path)
|
|
220
|
+
const runner = new CleanContextRunner({
|
|
221
|
+
config,
|
|
222
|
+
modelRef: model,
|
|
223
|
+
enableTools: false,
|
|
224
|
+
logger,
|
|
225
|
+
});
|
|
226
|
+
result = await runner.run({
|
|
227
|
+
prompt: userPrompt,
|
|
228
|
+
systemPrompt: EXTRACT_MEMORIES_SYSTEM_PROMPT,
|
|
229
|
+
taskId: "l1-extraction",
|
|
230
|
+
timeoutMs: 180_000,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return parseExtractionResult(result, logger);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Parse the LLM's JSON response into SceneSegment array.
|
|
237
|
+
* Expected format: [{scene_name, message_ids, memories: [...]}]
|
|
238
|
+
*/
|
|
239
|
+
function parseExtractionResult(raw, logger) {
|
|
240
|
+
try {
|
|
241
|
+
// Strip markdown code block wrappers if present
|
|
242
|
+
let cleaned = raw.trim();
|
|
243
|
+
if (cleaned.startsWith("```")) {
|
|
244
|
+
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "");
|
|
245
|
+
}
|
|
246
|
+
// Try to extract JSON array
|
|
247
|
+
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
|
|
248
|
+
if (!arrayMatch) {
|
|
249
|
+
logger?.warn?.(`${TAG} No JSON array found in extraction response`);
|
|
250
|
+
// [l1-debug] NO_JSON — dump the full raw so we can see what the LLM actually said
|
|
251
|
+
const rawPreview = raw.slice(0, 2048);
|
|
252
|
+
logger?.warn?.(`${TAG} [l1-debug] NO_JSON taskId=l1-extraction, rawLen=${raw.length}, cleanedLen=${cleaned.length}, rawFull=${JSON.stringify(rawPreview)}${raw.length > 2048 ? `…(+${raw.length - 2048})` : ""}`);
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
// Sanitize control characters inside JSON string literals that LLM may produce
|
|
256
|
+
const sanitized = sanitizeJsonForParse(arrayMatch[0]);
|
|
257
|
+
const parsed = JSON.parse(sanitized);
|
|
258
|
+
if (!Array.isArray(parsed)) {
|
|
259
|
+
logger?.warn?.(`${TAG} Extraction response is not an array`);
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
const scenes = [];
|
|
263
|
+
for (const item of parsed) {
|
|
264
|
+
if (!item || typeof item !== "object")
|
|
265
|
+
continue;
|
|
266
|
+
const s = item;
|
|
267
|
+
scenes.push({
|
|
268
|
+
scene_name: typeof s.scene_name === "string" ? s.scene_name : "未知情境",
|
|
269
|
+
message_ids: Array.isArray(s.message_ids) ? s.message_ids.map(String) : [],
|
|
270
|
+
memories: Array.isArray(s.memories)
|
|
271
|
+
? s.memories
|
|
272
|
+
.filter((m) => m && typeof m === "object" && typeof m.content === "string" && m.content.length > 0)
|
|
273
|
+
.map((m) => ({
|
|
274
|
+
content: String(m.content),
|
|
275
|
+
type: String(m.type ?? "episodic"),
|
|
276
|
+
priority: typeof m.priority === "number" ? m.priority : 50,
|
|
277
|
+
source_message_ids: Array.isArray(m.source_message_ids) ? m.source_message_ids.map(String) : [],
|
|
278
|
+
metadata: (m.metadata && typeof m.metadata === "object" ? m.metadata : {}),
|
|
279
|
+
}))
|
|
280
|
+
: [],
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return scenes;
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
logger?.warn?.(`${TAG} Failed to parse extraction result: ${err instanceof Error ? err.message : String(err)}`);
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// ============================
|
|
291
|
+
// Write helpers
|
|
292
|
+
// ============================
|
|
293
|
+
/**
|
|
294
|
+
* Apply batch dedup decisions — write memories according to their decisions.
|
|
295
|
+
*/
|
|
296
|
+
async function applyDecisions(params) {
|
|
297
|
+
const { memoriesWithIds, decisions, baseDir, sessionKey, sessionId, logger, vectorStore, embeddingService } = params;
|
|
298
|
+
const storedRecords = [];
|
|
299
|
+
// Build a map from record_id → decision
|
|
300
|
+
const decisionMap = new Map();
|
|
301
|
+
for (const d of decisions) {
|
|
302
|
+
decisionMap.set(d.record_id, d);
|
|
303
|
+
}
|
|
304
|
+
for (const memoryWithId of memoriesWithIds) {
|
|
305
|
+
const decision = decisionMap.get(memoryWithId.record_id) ?? {
|
|
306
|
+
record_id: memoryWithId.record_id,
|
|
307
|
+
action: "store",
|
|
308
|
+
target_ids: [],
|
|
309
|
+
};
|
|
310
|
+
try {
|
|
311
|
+
const record = await writeMemory({
|
|
312
|
+
memory: memoryWithId,
|
|
313
|
+
decision,
|
|
314
|
+
baseDir,
|
|
315
|
+
sessionKey,
|
|
316
|
+
sessionId,
|
|
317
|
+
logger,
|
|
318
|
+
vectorStore,
|
|
319
|
+
embeddingService,
|
|
320
|
+
});
|
|
321
|
+
if (record) {
|
|
322
|
+
storedRecords.push(record);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
logger?.warn?.(`${TAG} Write failed for memory "${memoryWithId.content.slice(0, 50)}...": ${err instanceof Error ? err.message : String(err)}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return storedRecords;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Store all memories directly (no dedup).
|
|
333
|
+
*/
|
|
334
|
+
async function storeAllDirectly(memoriesWithIds, baseDir, sessionKey, sessionId, logger, vectorStore, embeddingService) {
|
|
335
|
+
const storedRecords = [];
|
|
336
|
+
for (const memoryWithId of memoriesWithIds) {
|
|
337
|
+
try {
|
|
338
|
+
const record = await writeMemory({
|
|
339
|
+
memory: memoryWithId,
|
|
340
|
+
decision: {
|
|
341
|
+
record_id: memoryWithId.record_id,
|
|
342
|
+
action: "store",
|
|
343
|
+
target_ids: [],
|
|
344
|
+
},
|
|
345
|
+
baseDir,
|
|
346
|
+
sessionKey,
|
|
347
|
+
sessionId,
|
|
348
|
+
logger,
|
|
349
|
+
vectorStore,
|
|
350
|
+
embeddingService,
|
|
351
|
+
});
|
|
352
|
+
if (record) {
|
|
353
|
+
storedRecords.push(record);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
logger?.warn?.(`${TAG} Write failed for memory "${memoryWithId.content.slice(0, 50)}...": ${err instanceof Error ? err.message : String(err)}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return storedRecords;
|
|
361
|
+
}
|
|
362
|
+
// ============================
|
|
363
|
+
// Helpers
|
|
364
|
+
// ============================
|
|
365
|
+
const VALID_TYPES = ["persona", "episodic", "instruction"];
|
|
366
|
+
function normalizeType(raw) {
|
|
367
|
+
const lower = raw.toLowerCase().trim();
|
|
368
|
+
if (VALID_TYPES.includes(lower)) {
|
|
369
|
+
return lower;
|
|
370
|
+
}
|
|
371
|
+
// Handle legacy type names
|
|
372
|
+
if (lower === "episode")
|
|
373
|
+
return "episodic";
|
|
374
|
+
if (lower === "instruct")
|
|
375
|
+
return "instruction";
|
|
376
|
+
if (lower === "preference")
|
|
377
|
+
return "persona"; // fold preference into persona
|
|
378
|
+
return null;
|
|
379
|
+
}
|