dsh-tdai-memory 0.4.2 → 0.4.3

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.
Files changed (64) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +4 -0
  3. package/README.zh.md +4 -0
  4. package/THIRD-PARTY-NOTICES.md +20 -20
  5. package/cordis.patch.yml +8 -8
  6. package/package.json +102 -100
  7. package/vendor/tdai/adapters/index.js +15 -15
  8. package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -83
  9. package/vendor/tdai/adapters/openclaw/index.js +5 -5
  10. package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -69
  11. package/vendor/tdai/adapters/standalone/host-adapter.js +58 -58
  12. package/vendor/tdai/adapters/standalone/index.js +5 -5
  13. package/vendor/tdai/core/conversation/l0-recorder.js +420 -420
  14. package/vendor/tdai/core/hooks/auto-capture.js +236 -236
  15. package/vendor/tdai/core/hooks/auto-recall.js +640 -640
  16. package/vendor/tdai/core/index.js +8 -8
  17. package/vendor/tdai/core/persona/persona-generator.js +184 -184
  18. package/vendor/tdai/core/persona/persona-trigger.js +94 -94
  19. package/vendor/tdai/core/profile/profile-sync.js +200 -200
  20. package/vendor/tdai/core/prompts/l1-dedup.js +72 -72
  21. package/vendor/tdai/core/prompts/l1-extraction.js +34 -34
  22. package/vendor/tdai/core/prompts/persona-generation.js +35 -35
  23. package/vendor/tdai/core/prompts/scene-extraction.js +45 -45
  24. package/vendor/tdai/core/record/l1-dedup.js +318 -318
  25. package/vendor/tdai/core/record/l1-extractor.js +379 -379
  26. package/vendor/tdai/core/record/l1-reader.js +178 -178
  27. package/vendor/tdai/core/record/l1-writer.js +141 -141
  28. package/vendor/tdai/core/report/reporter.js +86 -86
  29. package/vendor/tdai/core/scene/filename-normalizer.js +161 -161
  30. package/vendor/tdai/core/scene/scene-extractor.js +410 -410
  31. package/vendor/tdai/core/scene/scene-format.js +53 -53
  32. package/vendor/tdai/core/scene/scene-index.js +82 -82
  33. package/vendor/tdai/core/scene/scene-navigation.js +57 -57
  34. package/vendor/tdai/core/seed/input.js +404 -404
  35. package/vendor/tdai/core/seed/types.js +9 -9
  36. package/vendor/tdai/core/store/bm25-client.js +117 -117
  37. package/vendor/tdai/core/store/bm25-local.js +68 -68
  38. package/vendor/tdai/core/store/embedding.js +632 -632
  39. package/vendor/tdai/core/store/factory.js +95 -95
  40. package/vendor/tdai/core/store/search-utils.js +54 -54
  41. package/vendor/tdai/core/store/sqlite.js +1632 -1632
  42. package/vendor/tdai/core/store/tcvdb-client.js +221 -221
  43. package/vendor/tdai/core/store/tcvdb.js +1047 -1047
  44. package/vendor/tdai/core/store/types.js +17 -17
  45. package/vendor/tdai/core/tools/conversation-search.js +199 -199
  46. package/vendor/tdai/core/tools/memory-search.js +207 -207
  47. package/vendor/tdai/core/types.js +12 -12
  48. package/vendor/tdai/utils/backup.js +189 -189
  49. package/vendor/tdai/utils/checkpoint.js +360 -360
  50. package/vendor/tdai/utils/clean-context-runner.js +472 -472
  51. package/vendor/tdai/utils/ensure-hook-policy.js +213 -213
  52. package/vendor/tdai/utils/env.js +13 -13
  53. package/vendor/tdai/utils/managed-timer.js +127 -127
  54. package/vendor/tdai/utils/manifest.js +91 -91
  55. package/vendor/tdai/utils/memory-cleaner.js +300 -300
  56. package/vendor/tdai/utils/no-think-fetch.js +109 -109
  57. package/vendor/tdai/utils/openclaw-state-dir.js +26 -26
  58. package/vendor/tdai/utils/pipeline-factory.js +517 -517
  59. package/vendor/tdai/utils/pipeline-manager.js +892 -892
  60. package/vendor/tdai/utils/sanitize.js +360 -360
  61. package/vendor/tdai/utils/serial-queue.js +103 -103
  62. package/vendor/tdai/utils/session-filter.js +88 -88
  63. package/vendor/tdai/utils/text-utils.js +29 -29
  64. package/vendor/tdai/utils/time.js +252 -252
@@ -1,379 +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
- }
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
+ }