dsh-tdai-memory 0.2.13 → 0.2.14
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 -21
- package/THIRD-PARTY-NOTICES.md +20 -20
- package/client.js +8 -4
- package/cordis.patch.yml +8 -8
- package/index.js +414 -414
- package/package.json +1 -1
- package/recall-inject.js +86 -86
- package/vendor/tdai/adapters/index.js +15 -15
- package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -83
- package/vendor/tdai/adapters/openclaw/index.js +5 -5
- package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -69
- package/vendor/tdai/adapters/standalone/host-adapter.js +58 -58
- package/vendor/tdai/adapters/standalone/index.js +5 -5
- package/vendor/tdai/adapters/standalone/llm-runner.js +246 -246
- package/vendor/tdai/config.js +363 -363
- package/vendor/tdai/core/conversation/l0-recorder.js +420 -420
- package/vendor/tdai/core/hooks/auto-capture.js +236 -236
- package/vendor/tdai/core/hooks/auto-recall.js +653 -653
- package/vendor/tdai/core/index.js +8 -8
- package/vendor/tdai/core/persona/persona-generator.js +184 -184
- package/vendor/tdai/core/persona/persona-trigger.js +94 -94
- package/vendor/tdai/core/profile/profile-sync.js +200 -200
- package/vendor/tdai/core/prompts/l1-dedup.js +136 -136
- package/vendor/tdai/core/prompts/l1-extraction.js +132 -132
- package/vendor/tdai/core/prompts/persona-generation.js +160 -160
- package/vendor/tdai/core/prompts/scene-extraction.js +269 -269
- package/vendor/tdai/core/record/l1-dedup.js +318 -318
- package/vendor/tdai/core/record/l1-extractor.js +379 -379
- package/vendor/tdai/core/record/l1-reader.js +178 -178
- package/vendor/tdai/core/record/l1-writer.js +141 -141
- package/vendor/tdai/core/report/reporter.js +86 -86
- package/vendor/tdai/core/scene/filename-normalizer.js +161 -161
- package/vendor/tdai/core/scene/scene-extractor.js +410 -410
- package/vendor/tdai/core/scene/scene-format.js +53 -53
- package/vendor/tdai/core/scene/scene-index.js +82 -82
- package/vendor/tdai/core/scene/scene-navigation.js +60 -60
- package/vendor/tdai/core/seed/input.js +404 -404
- package/vendor/tdai/core/seed/seed-runtime.js +314 -314
- package/vendor/tdai/core/seed/types.js +9 -9
- package/vendor/tdai/core/store/bm25-client.js +117 -117
- package/vendor/tdai/core/store/bm25-local.js +68 -68
- package/vendor/tdai/core/store/embedding.js +632 -632
- package/vendor/tdai/core/store/factory.js +95 -95
- package/vendor/tdai/core/store/search-utils.js +54 -54
- package/vendor/tdai/core/store/sqlite.js +1822 -1822
- package/vendor/tdai/core/store/tcvdb-client.js +221 -221
- package/vendor/tdai/core/store/tcvdb.js +1047 -1047
- package/vendor/tdai/core/store/types.js +17 -17
- package/vendor/tdai/core/tdai-core.js +446 -446
- package/vendor/tdai/core/tools/conversation-search.js +199 -199
- package/vendor/tdai/core/tools/memory-search.js +207 -207
- package/vendor/tdai/core/types.js +12 -12
- package/vendor/tdai/utils/backup.js +189 -189
- package/vendor/tdai/utils/checkpoint.js +360 -360
- package/vendor/tdai/utils/clean-context-runner.js +472 -472
- package/vendor/tdai/utils/ensure-hook-policy.js +213 -213
- package/vendor/tdai/utils/env.js +13 -13
- package/vendor/tdai/utils/managed-timer.js +127 -127
- package/vendor/tdai/utils/manifest.js +91 -91
- package/vendor/tdai/utils/memory-cleaner.js +300 -300
- package/vendor/tdai/utils/no-think-fetch.js +109 -109
- package/vendor/tdai/utils/openclaw-state-dir.js +26 -26
- package/vendor/tdai/utils/pipeline-factory.js +517 -517
- package/vendor/tdai/utils/pipeline-manager.js +892 -892
- package/vendor/tdai/utils/sanitize.js +360 -360
- package/vendor/tdai/utils/serial-queue.js +103 -103
- package/vendor/tdai/utils/session-filter.js +88 -88
- package/vendor/tdai/utils/text-utils.js +29 -29
- 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
|
+
}
|