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