dsh-tdai-memory 0.3.3 → 0.3.5

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 (67) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +12 -24
  3. package/README.zh.md +4 -5
  4. package/THIRD-PARTY-NOTICES.md +20 -20
  5. package/client.js +2 -2
  6. package/cordis.patch.yml +8 -8
  7. package/index.js +8 -17
  8. package/package.json +24 -19
  9. package/recall-inject.js +86 -86
  10. package/vendor/tdai/adapters/index.js +15 -15
  11. package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -83
  12. package/vendor/tdai/adapters/openclaw/index.js +5 -5
  13. package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -69
  14. package/vendor/tdai/adapters/standalone/host-adapter.js +58 -58
  15. package/vendor/tdai/adapters/standalone/index.js +5 -5
  16. package/vendor/tdai/core/conversation/l0-recorder.js +420 -420
  17. package/vendor/tdai/core/hooks/auto-capture.js +236 -236
  18. package/vendor/tdai/core/hooks/auto-recall.js +640 -640
  19. package/vendor/tdai/core/index.js +8 -8
  20. package/vendor/tdai/core/persona/persona-generator.js +184 -184
  21. package/vendor/tdai/core/persona/persona-trigger.js +94 -94
  22. package/vendor/tdai/core/profile/profile-sync.js +200 -200
  23. package/vendor/tdai/core/prompts/l1-dedup.js +72 -72
  24. package/vendor/tdai/core/prompts/l1-extraction.js +34 -34
  25. package/vendor/tdai/core/prompts/persona-generation.js +35 -35
  26. package/vendor/tdai/core/prompts/scene-extraction.js +45 -45
  27. package/vendor/tdai/core/record/l1-dedup.js +318 -318
  28. package/vendor/tdai/core/record/l1-extractor.js +379 -379
  29. package/vendor/tdai/core/record/l1-reader.js +178 -178
  30. package/vendor/tdai/core/record/l1-writer.js +141 -141
  31. package/vendor/tdai/core/report/reporter.js +86 -86
  32. package/vendor/tdai/core/scene/filename-normalizer.js +161 -161
  33. package/vendor/tdai/core/scene/scene-extractor.js +410 -410
  34. package/vendor/tdai/core/scene/scene-format.js +53 -53
  35. package/vendor/tdai/core/scene/scene-index.js +82 -82
  36. package/vendor/tdai/core/scene/scene-navigation.js +57 -57
  37. package/vendor/tdai/core/seed/input.js +404 -404
  38. package/vendor/tdai/core/seed/types.js +9 -9
  39. package/vendor/tdai/core/store/bm25-client.js +117 -117
  40. package/vendor/tdai/core/store/bm25-local.js +68 -68
  41. package/vendor/tdai/core/store/embedding.js +632 -632
  42. package/vendor/tdai/core/store/factory.js +95 -95
  43. package/vendor/tdai/core/store/search-utils.js +54 -54
  44. package/vendor/tdai/core/store/sqlite.js +1632 -1632
  45. package/vendor/tdai/core/store/tcvdb-client.js +221 -221
  46. package/vendor/tdai/core/store/tcvdb.js +1047 -1047
  47. package/vendor/tdai/core/store/types.js +17 -17
  48. package/vendor/tdai/core/tools/conversation-search.js +199 -199
  49. package/vendor/tdai/core/tools/memory-search.js +207 -207
  50. package/vendor/tdai/core/types.js +12 -12
  51. package/vendor/tdai/utils/backup.js +189 -189
  52. package/vendor/tdai/utils/checkpoint.js +360 -360
  53. package/vendor/tdai/utils/clean-context-runner.js +472 -472
  54. package/vendor/tdai/utils/ensure-hook-policy.js +213 -213
  55. package/vendor/tdai/utils/env.js +13 -13
  56. package/vendor/tdai/utils/managed-timer.js +127 -127
  57. package/vendor/tdai/utils/manifest.js +91 -91
  58. package/vendor/tdai/utils/memory-cleaner.js +300 -300
  59. package/vendor/tdai/utils/no-think-fetch.js +109 -109
  60. package/vendor/tdai/utils/openclaw-state-dir.js +26 -26
  61. package/vendor/tdai/utils/pipeline-factory.js +517 -517
  62. package/vendor/tdai/utils/pipeline-manager.js +892 -892
  63. package/vendor/tdai/utils/sanitize.js +360 -360
  64. package/vendor/tdai/utils/serial-queue.js +103 -103
  65. package/vendor/tdai/utils/session-filter.js +88 -88
  66. package/vendor/tdai/utils/text-utils.js +29 -29
  67. package/vendor/tdai/utils/time.js +252 -252
@@ -1,420 +1,420 @@
1
- /**
2
- * L0 Conversation Recorder: records raw conversation messages to local JSONL files.
3
- *
4
- * Triggered from agent_end hook. Receives the conversation messages directly from
5
- * the hook context (no file I/O needed), sanitizes them, filters out noise, and
6
- * writes to ~/.openclaw/memory-tdai/conversations/YYYY-MM-DD.jsonl
7
- *
8
- * Design decisions:
9
- * - Uses JSONL format (**one message per line** — flat, easy to grep/stream)
10
- * - One file per day (all sessions merged into the same daily file)
11
- * - sessionKey is stored as a field in each JSONL line, not in the filename
12
- * - Independent from system session files — format fully controlled by plugin
13
- * - Messages are sanitized to remove injected tags (prevent feedback loops)
14
- * - Short/long/command messages are filtered out
15
- */
16
- import fs from "node:fs/promises";
17
- import path from "node:path";
18
- import crypto from "node:crypto";
19
- import { sanitizeText, stripCodeBlocks, shouldCaptureL0 } from "../../utils/sanitize.js";
20
- import { formatLocalDate } from "../../utils/time.js";
21
- /**
22
- * Generate a short unique message ID.
23
- */
24
- function generateMessageId() {
25
- return `msg_${Date.now()}_${crypto.randomBytes(3).toString("hex")}`;
26
- }
27
- const TAG = "[memory-tdai][l0]";
28
- // ============================
29
- // Core function
30
- // ============================
31
- /**
32
- * Record a conversation round to the L0 JSONL file.
33
- *
34
- * Only records **incremental** messages (new since the last capture).
35
- * Uses `afterTimestamp` as the primary filter to skip already-captured history.
36
- *
37
- * @param sessionKey - The session key for this conversation
38
- * @param rawMessages - Raw messages from the agent_end hook context (full session history)
39
- * @param baseDir - Base data directory (~/.openclaw/memory-tdai/)
40
- * @param logger - Optional logger
41
- * @param originalUserText - Clean original user prompt (pre-prependContext)
42
- * @param afterTimestamp - Epoch ms cursor: only messages with timestamp > this are new.
43
- * Pass 0 or omit for the first capture of a session.
44
- * @returns Filtered messages (for L1 to use directly), or empty array if nothing worth recording
45
- */
46
- export async function recordConversation(params) {
47
- const { sessionKey, sessionId, rawMessages, baseDir, logger, originalUserText, afterTimestamp, originalUserMessageCount } = params;
48
- // Step 1: Position slice + extract user/assistant messages.
49
- //
50
- // Dual protection against duplicate capture:
51
- // Layer 1 (position slice): Use originalUserMessageCount (cached at before_prompt_build)
52
- // to slice rawMessages — only keep messages added AFTER the prompt build, i.e. this
53
- // turn's new messages. This is immune to timestamp drift after gateway restarts.
54
- // Layer 2 (timestamp cursor): The existing afterTimestamp filter below acts as a fallback
55
- // when the position slice is unavailable (cache expired, process restart, etc.).
56
- const usePositionSlice = originalUserMessageCount != null && originalUserMessageCount > 0
57
- && originalUserMessageCount <= rawMessages.length;
58
- const slicedMessages = usePositionSlice
59
- ? rawMessages.slice(originalUserMessageCount)
60
- : rawMessages;
61
- const allExtracted = extractUserAssistantMessages(slicedMessages);
62
- if (usePositionSlice) {
63
- logger?.debug?.(`${TAG} Position slice: ${rawMessages.length} raw → ${slicedMessages.length} new (sliceStart=${originalUserMessageCount})`);
64
- }
65
- // Diagnostic: check whether the framework actually provides timestamp on raw messages.
66
- // If all raw timestamps are missing, the timestamp cursor is effectively useless and
67
- // position slice becomes the sole incremental mechanism.
68
- if (slicedMessages.length > 0) {
69
- const firstRaw = slicedMessages[0];
70
- const rawTs = firstRaw?.timestamp;
71
- const hasRawTs = typeof rawTs === "number";
72
- logger?.debug?.(`${TAG} Raw message[0] timestamp probe: ${hasRawTs ? `present (${rawTs})` : `missing (type=${typeof rawTs}, value=${String(rawTs)})`}`);
73
- }
74
- logger?.debug?.(`${TAG} Extracted ${allExtracted.length} user/assistant messages from ${slicedMessages.length} total`);
75
- // Step 1.5: Incremental filter — only keep messages newer than the cursor.
76
- //
77
- // Uses strict greater-than (>) which is safe because:
78
- // - The cursor is set to max(timestamps) of the LAST recorded batch.
79
- // - The next agent turn's messages will have timestamps strictly greater than
80
- // the previous turn (there's at least one LLM API call between turns, which
81
- // takes hundreds of milliseconds minimum — no same-millisecond collision).
82
- // - All messages within a single turn are captured together as one batch,
83
- // so even if multiple messages share the same timestamp, they are either
84
- // all included (new batch) or all excluded (already captured).
85
- // - If a message lacks a timestamp field, extractUserAssistantMessages()
86
- // assigns Date.now() at extraction time, which is always > previous cursor.
87
- const cursor = afterTimestamp ?? 0;
88
- const extracted = cursor !== 0
89
- ? allExtracted.filter((m) => m.timestamp > cursor)
90
- : allExtracted;
91
- if (extracted.length > 0) {
92
- const first = extracted[0];
93
- logger?.debug?.(`${TAG} First captured message: role=${first.role}, ts=${first.timestamp}, ` +
94
- `date=${new Date(first.timestamp).toISOString()}, content=${first.content.slice(0, 80)}${first.content.length > 80 ? "…" : ""}`);
95
- }
96
- if (cursor > 0) {
97
- logger?.debug?.(`${TAG} Incremental filter: ${allExtracted.length} total → ${extracted.length} new (cursor=${cursor})`);
98
- // Safety valve: if timestamp filter passed everything through and position slice
99
- // was not available, this likely indicates timestamp drift after a gateway restart.
100
- if (!usePositionSlice && extracted.length === allExtracted.length && allExtracted.length > 8) {
101
- logger?.warn?.(`${TAG} ⚠ Safety valve: all ${allExtracted.length} messages passed timestamp filter (cursor=${cursor}) — ` +
102
- `possible timestamp drift after gateway restart. Position slice was not available (no cached messageCount).`);
103
- }
104
- }
105
- if (extracted.length === 0) {
106
- logger?.debug?.(`${TAG} No new user/assistant messages to record`);
107
- return [];
108
- }
109
- // Step 2: Replace polluted user messages with cached original prompt.
110
- //
111
- // Background:
112
- // The framework appends the user's message to the session after before_prompt_build,
113
- // then injects prependContext into it. So the user message in rawMessages is polluted.
114
- // We cached the clean prompt (originalUserText) and the message count at
115
- // before_prompt_build time (originalUserMessageCount) to identify which raw message
116
- // is the real user input.
117
- //
118
- // Strategy:
119
- // When position slice is active, the polluted user message is slicedMessages[0].
120
- // Otherwise, fall back to rawMessages[originalUserMessageCount].
121
- // In both cases, find the timestamp and match it in `extracted` for replacement.
122
- // If matching fails, skip replacement — sanitizeText() in Step 3 is the safety net.
123
- if (originalUserText) {
124
- // Determine the target raw message that contains the polluted user prompt
125
- const targetRaw = usePositionSlice
126
- ? slicedMessages[0]
127
- : (originalUserMessageCount != null && originalUserMessageCount >= 0 && originalUserMessageCount < rawMessages.length)
128
- ? rawMessages[originalUserMessageCount]
129
- : undefined;
130
- const targetTs = targetRaw && typeof targetRaw.timestamp === "number" ? targetRaw.timestamp : undefined;
131
- if (targetTs != null) {
132
- let replaced = false;
133
- for (let i = 0; i < extracted.length; i++) {
134
- if (extracted[i].role === "user" && extracted[i].timestamp === targetTs) {
135
- logger?.debug?.(`${TAG} Replacing user message at timestamp=${targetTs} with cached original prompt ` +
136
- `(${originalUserText.length} chars, was ${extracted[i].content.length} chars) [positionSlice=${usePositionSlice}]`);
137
- extracted[i] = { ...extracted[i], content: originalUserText };
138
- replaced = true;
139
- break;
140
- }
141
- }
142
- if (!replaced) {
143
- logger?.warn?.(`${TAG} Target user message (ts=${targetTs}) not found in extracted batch — ` +
144
- `possibly filtered by cursor. Skipping replacement, will rely on sanitizeText().`);
145
- }
146
- }
147
- else if (targetRaw) {
148
- logger?.warn?.(`${TAG} Target raw message has no valid timestamp — ` +
149
- `skipping replacement, will rely on sanitizeText().`);
150
- }
151
- else {
152
- logger?.warn?.(`${TAG} Have originalUserText but cannot locate target raw message — ` +
153
- `skipping replacement, will rely on sanitizeText().`);
154
- }
155
- }
156
- // Step 3: Sanitize and filter
157
- const filtered = extracted
158
- .map((m) => {
159
- let content = sanitizeText(m.content);
160
- // Strip fenced code blocks from assistant replies to reduce embedding noise
161
- if (m.role === "assistant") {
162
- content = stripCodeBlocks(content);
163
- }
164
- return { id: m.id, role: m.role, content, timestamp: m.timestamp };
165
- })
166
- .filter((m) => shouldCaptureL0(m.content));
167
- logger?.debug?.(`${TAG} After sanitize+filter: ${filtered.length} messages (from ${extracted.length})`);
168
- if (filtered.length === 0) {
169
- logger?.debug?.(`${TAG} All messages filtered out, skipping L0 write`);
170
- return [];
171
- }
172
- // Step 4: Write to JSONL file — one message per line (flat format)
173
- const now = new Date().toISOString();
174
- const lines = [];
175
- for (const msg of filtered) {
176
- const record = {
177
- sessionKey,
178
- sessionId: sessionId || "",
179
- recordedAt: now,
180
- id: msg.id,
181
- role: msg.role,
182
- content: msg.content,
183
- timestamp: msg.timestamp,
184
- };
185
- lines.push(JSON.stringify(record));
186
- }
187
- const shardDate = formatLocalDate(new Date());
188
- const outDir = path.join(baseDir, "conversations");
189
- const outPath = path.join(outDir, `${shardDate}.jsonl`);
190
- try {
191
- await fs.mkdir(outDir, { recursive: true });
192
- // Append each message as its own JSONL line
193
- await fs.appendFile(outPath, lines.join("\n") + "\n", "utf-8");
194
- logger?.debug?.(`${TAG} Recorded ${filtered.length} messages to ${outPath}`);
195
- }
196
- catch (err) {
197
- logger?.error(`${TAG} Failed to write L0 file: ${err instanceof Error ? err.message : String(err)}`);
198
- // Return filtered messages anyway so L1 can still process them
199
- }
200
- return filtered;
201
- }
202
- /**
203
- * Read all L0 conversation records for a session.
204
- * Returns records in chronological order.
205
- *
206
- * File format: `YYYY-MM-DD.jsonl` (daily files, all sessions merged).
207
- * Each line is an L0MessageRecord; filtered by sessionKey at line level.
208
- */
209
- export async function readConversationRecords(sessionKey, baseDir, logger) {
210
- const conversationsDir = path.join(baseDir, "conversations");
211
- // Daily file pattern: YYYY-MM-DD.jsonl
212
- const dateFilePattern = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
213
- let entries;
214
- try {
215
- const dirEntries = await fs.readdir(conversationsDir, { withFileTypes: true });
216
- entries = dirEntries
217
- .filter((entry) => entry.isFile())
218
- .map((entry) => entry.name);
219
- }
220
- catch {
221
- // Directory doesn't exist yet — normal for first conversation
222
- return [];
223
- }
224
- const targetFiles = entries
225
- .filter((name) => dateFilePattern.test(name))
226
- .sort();
227
- if (targetFiles.length === 0) {
228
- return [];
229
- }
230
- const records = [];
231
- for (const fileName of targetFiles) {
232
- const filePath = path.join(conversationsDir, fileName);
233
- let raw;
234
- try {
235
- raw = await fs.readFile(filePath, "utf-8");
236
- }
237
- catch {
238
- logger?.warn?.(`${TAG} Failed to read L0 file: ${filePath}`);
239
- continue;
240
- }
241
- const lines = raw.split("\n").filter((line) => line.trim());
242
- for (let i = 0; i < lines.length; i++) {
243
- const line = lines[i];
244
- try {
245
- const parsed = JSON.parse(line);
246
- // Filter by sessionKey at line level
247
- const lineSessionKey = parsed.sessionKey;
248
- if (lineSessionKey !== sessionKey)
249
- continue;
250
- if (typeof parsed.role === "string" && typeof parsed.content === "string") {
251
- // Flat format: { sessionKey, sessionId, recordedAt, id, role, content, timestamp }
252
- // Wrap into L0ConversationRecord for uniform downstream consumption
253
- const msg = {
254
- id: (typeof parsed.id === "string" && parsed.id) ? parsed.id : generateMessageId(),
255
- role: parsed.role,
256
- content: parsed.content,
257
- timestamp: typeof parsed.timestamp === "number" ? parsed.timestamp : Date.now(),
258
- };
259
- records.push({
260
- sessionKey: parsed.sessionKey || sessionKey,
261
- sessionId: parsed.sessionId || "",
262
- recordedAt: parsed.recordedAt || new Date().toISOString(),
263
- messageCount: 1,
264
- messages: [msg],
265
- });
266
- }
267
- else {
268
- logger?.warn?.(`${TAG} Unrecognized JSONL line format in ${filePath}:${i + 1}`);
269
- }
270
- }
271
- catch {
272
- logger?.warn?.(`${TAG} Skipping malformed JSONL line in ${filePath}:${i + 1}`);
273
- }
274
- }
275
- }
276
- records.sort((a, b) => {
277
- const ta = Date.parse(a.recordedAt);
278
- const tb = Date.parse(b.recordedAt);
279
- const na = Number.isFinite(ta) ? ta : Number.POSITIVE_INFINITY;
280
- const nb = Number.isFinite(tb) ? tb : Number.POSITIVE_INFINITY;
281
- return na - nb;
282
- });
283
- return records;
284
- }
285
- /**
286
- * Read L0 messages across all conversation records for a session,
287
- * optionally filtered by a cursor timestamp (messages after the cursor).
288
- *
289
- * When `limit` is provided, only the **newest** `limit` messages are returned
290
- * (matching the DB path's `ORDER BY timestamp DESC LIMIT ?` behavior).
291
- * Returned messages are always in chronological order (oldest → newest).
292
- *
293
- * NOTE: potential optimization — records are chronologically ordered (append-only JSONL),
294
- * so a reverse scan could skip entire old records. Deferred for now; see Issue 5 in
295
- * docs/05-known-issues.md.
296
- */
297
- export async function readConversationMessages(sessionKey, baseDir, afterTimestamp, logger, limit) {
298
- const records = await readConversationRecords(sessionKey, baseDir, logger);
299
- const allMessages = [];
300
- for (const record of records) {
301
- for (const msg of record.messages) {
302
- if (afterTimestamp && msg.timestamp <= afterTimestamp)
303
- continue;
304
- allMessages.push(msg);
305
- }
306
- }
307
- // Truncate to newest `limit` messages (keep tail, since array is chronological)
308
- if (limit != null && limit > 0 && allMessages.length > limit) {
309
- logger?.debug?.(`${TAG} readConversationMessages: truncating ${allMessages.length} → ${limit} (newest)`);
310
- return allMessages.slice(-limit);
311
- }
312
- return allMessages;
313
- }
314
- /**
315
- * Read L0 messages for a session, grouped by sessionId.
316
- *
317
- * Within the same sessionKey, different sessionIds represent different conversation
318
- * instances (e.g. after /reset). L1 extraction should process each group independently
319
- * so that each group's sessionId is correctly associated with its extracted memories.
320
- *
321
- * When `limit` is provided, only the **newest** `limit` messages (across all groups)
322
- * are retained — matching the DB path's `ORDER BY recorded_at DESC LIMIT ?` behavior.
323
- * Groups that become empty after truncation are dropped.
324
- *
325
- * Groups are returned in chronological order (by earliest message timestamp).
326
- * Messages within each group are also in chronological order.
327
- *
328
- * @param afterRecordedAtMs - Epoch ms cursor: only messages with recordedAt > this are included.
329
- */
330
- export async function readConversationMessagesGroupedBySessionId(sessionKey, baseDir, afterRecordedAtMs, logger, limit) {
331
- const records = await readConversationRecords(sessionKey, baseDir, logger);
332
- // Collect all messages with their sessionId, filtering by recorded_at cursor
333
- const allMessages = [];
334
- for (const record of records) {
335
- const sid = record.sessionId || "";
336
- const recMs = Date.parse(record.recordedAt) || 0;
337
- if (afterRecordedAtMs && recMs <= afterRecordedAtMs)
338
- continue;
339
- for (const msg of record.messages) {
340
- allMessages.push({ sessionId: sid, msg: { ...msg, recordedAtMs: recMs } });
341
- }
342
- }
343
- // Sort by timestamp ASC (chronological) — records are already roughly ordered
344
- // by recordedAt, but messages within may not be perfectly sorted by timestamp.
345
- allMessages.sort((a, b) => a.msg.timestamp - b.msg.timestamp);
346
- // Truncate to newest `limit` messages (keep tail)
347
- let selected = allMessages;
348
- if (limit != null && limit > 0 && allMessages.length > limit) {
349
- logger?.debug?.(`${TAG} readConversationMessagesGroupedBySessionId: truncating ${allMessages.length} → ${limit} (newest)`);
350
- selected = allMessages.slice(-limit);
351
- }
352
- // Re-group by sessionId
353
- const groupMap = new Map();
354
- for (const { sessionId, msg } of selected) {
355
- let group = groupMap.get(sessionId);
356
- if (!group) {
357
- group = [];
358
- groupMap.set(sessionId, group);
359
- }
360
- group.push(msg);
361
- }
362
- // Convert to array, sorted by earliest message timestamp in each group
363
- const groups = [];
364
- for (const [sessionId, messages] of groupMap) {
365
- if (messages.length > 0) {
366
- groups.push({ sessionId, messages });
367
- }
368
- }
369
- groups.sort((a, b) => a.messages[0].timestamp - b.messages[0].timestamp);
370
- return groups;
371
- }
372
- // ============================
373
- // Helpers
374
- // ============================
375
- /**
376
- * Extract user and assistant messages from raw hook message array.
377
- */
378
- function extractUserAssistantMessages(messages) {
379
- const result = [];
380
- for (const msg of messages) {
381
- if (!msg || typeof msg !== "object")
382
- continue;
383
- const m = msg;
384
- const role = m.role;
385
- if (role !== "user" && role !== "assistant")
386
- continue;
387
- let content;
388
- if (typeof m.content === "string") {
389
- content = m.content;
390
- }
391
- else if (Array.isArray(m.content)) {
392
- const textParts = [];
393
- for (const part of m.content) {
394
- if (part &&
395
- typeof part === "object" &&
396
- part.type === "text") {
397
- const text = part.text;
398
- if (typeof text === "string")
399
- textParts.push(text);
400
- }
401
- }
402
- content = textParts.join("\n");
403
- }
404
- // Strip inline base64 image data URIs that some providers embed in string content.
405
- // These are not useful for memory and would pollute FTS / embedding indexes.
406
- if (content && /data:image\/[a-z+]+;base64,/i.test(content)) {
407
- content = content.replace(/data:image\/[a-z+]+;base64,[A-Za-z0-9+/=]+/gi, "[image]");
408
- }
409
- if (content && content.trim()) {
410
- const ts = typeof m.timestamp === "number" ? m.timestamp : Date.now();
411
- result.push({
412
- id: (typeof m.id === "string" && m.id) ? m.id : generateMessageId(),
413
- role: role,
414
- content: content.trim(),
415
- timestamp: ts,
416
- });
417
- }
418
- }
419
- return result;
420
- }
1
+ /**
2
+ * L0 Conversation Recorder: records raw conversation messages to local JSONL files.
3
+ *
4
+ * Triggered from agent_end hook. Receives the conversation messages directly from
5
+ * the hook context (no file I/O needed), sanitizes them, filters out noise, and
6
+ * writes to ~/.openclaw/memory-tdai/conversations/YYYY-MM-DD.jsonl
7
+ *
8
+ * Design decisions:
9
+ * - Uses JSONL format (**one message per line** — flat, easy to grep/stream)
10
+ * - One file per day (all sessions merged into the same daily file)
11
+ * - sessionKey is stored as a field in each JSONL line, not in the filename
12
+ * - Independent from system session files — format fully controlled by plugin
13
+ * - Messages are sanitized to remove injected tags (prevent feedback loops)
14
+ * - Short/long/command messages are filtered out
15
+ */
16
+ import fs from "node:fs/promises";
17
+ import path from "node:path";
18
+ import crypto from "node:crypto";
19
+ import { sanitizeText, stripCodeBlocks, shouldCaptureL0 } from "../../utils/sanitize.js";
20
+ import { formatLocalDate } from "../../utils/time.js";
21
+ /**
22
+ * Generate a short unique message ID.
23
+ */
24
+ function generateMessageId() {
25
+ return `msg_${Date.now()}_${crypto.randomBytes(3).toString("hex")}`;
26
+ }
27
+ const TAG = "[memory-tdai][l0]";
28
+ // ============================
29
+ // Core function
30
+ // ============================
31
+ /**
32
+ * Record a conversation round to the L0 JSONL file.
33
+ *
34
+ * Only records **incremental** messages (new since the last capture).
35
+ * Uses `afterTimestamp` as the primary filter to skip already-captured history.
36
+ *
37
+ * @param sessionKey - The session key for this conversation
38
+ * @param rawMessages - Raw messages from the agent_end hook context (full session history)
39
+ * @param baseDir - Base data directory (~/.openclaw/memory-tdai/)
40
+ * @param logger - Optional logger
41
+ * @param originalUserText - Clean original user prompt (pre-prependContext)
42
+ * @param afterTimestamp - Epoch ms cursor: only messages with timestamp > this are new.
43
+ * Pass 0 or omit for the first capture of a session.
44
+ * @returns Filtered messages (for L1 to use directly), or empty array if nothing worth recording
45
+ */
46
+ export async function recordConversation(params) {
47
+ const { sessionKey, sessionId, rawMessages, baseDir, logger, originalUserText, afterTimestamp, originalUserMessageCount } = params;
48
+ // Step 1: Position slice + extract user/assistant messages.
49
+ //
50
+ // Dual protection against duplicate capture:
51
+ // Layer 1 (position slice): Use originalUserMessageCount (cached at before_prompt_build)
52
+ // to slice rawMessages — only keep messages added AFTER the prompt build, i.e. this
53
+ // turn's new messages. This is immune to timestamp drift after gateway restarts.
54
+ // Layer 2 (timestamp cursor): The existing afterTimestamp filter below acts as a fallback
55
+ // when the position slice is unavailable (cache expired, process restart, etc.).
56
+ const usePositionSlice = originalUserMessageCount != null && originalUserMessageCount > 0
57
+ && originalUserMessageCount <= rawMessages.length;
58
+ const slicedMessages = usePositionSlice
59
+ ? rawMessages.slice(originalUserMessageCount)
60
+ : rawMessages;
61
+ const allExtracted = extractUserAssistantMessages(slicedMessages);
62
+ if (usePositionSlice) {
63
+ logger?.debug?.(`${TAG} Position slice: ${rawMessages.length} raw → ${slicedMessages.length} new (sliceStart=${originalUserMessageCount})`);
64
+ }
65
+ // Diagnostic: check whether the framework actually provides timestamp on raw messages.
66
+ // If all raw timestamps are missing, the timestamp cursor is effectively useless and
67
+ // position slice becomes the sole incremental mechanism.
68
+ if (slicedMessages.length > 0) {
69
+ const firstRaw = slicedMessages[0];
70
+ const rawTs = firstRaw?.timestamp;
71
+ const hasRawTs = typeof rawTs === "number";
72
+ logger?.debug?.(`${TAG} Raw message[0] timestamp probe: ${hasRawTs ? `present (${rawTs})` : `missing (type=${typeof rawTs}, value=${String(rawTs)})`}`);
73
+ }
74
+ logger?.debug?.(`${TAG} Extracted ${allExtracted.length} user/assistant messages from ${slicedMessages.length} total`);
75
+ // Step 1.5: Incremental filter — only keep messages newer than the cursor.
76
+ //
77
+ // Uses strict greater-than (>) which is safe because:
78
+ // - The cursor is set to max(timestamps) of the LAST recorded batch.
79
+ // - The next agent turn's messages will have timestamps strictly greater than
80
+ // the previous turn (there's at least one LLM API call between turns, which
81
+ // takes hundreds of milliseconds minimum — no same-millisecond collision).
82
+ // - All messages within a single turn are captured together as one batch,
83
+ // so even if multiple messages share the same timestamp, they are either
84
+ // all included (new batch) or all excluded (already captured).
85
+ // - If a message lacks a timestamp field, extractUserAssistantMessages()
86
+ // assigns Date.now() at extraction time, which is always > previous cursor.
87
+ const cursor = afterTimestamp ?? 0;
88
+ const extracted = cursor !== 0
89
+ ? allExtracted.filter((m) => m.timestamp > cursor)
90
+ : allExtracted;
91
+ if (extracted.length > 0) {
92
+ const first = extracted[0];
93
+ logger?.debug?.(`${TAG} First captured message: role=${first.role}, ts=${first.timestamp}, ` +
94
+ `date=${new Date(first.timestamp).toISOString()}, content=${first.content.slice(0, 80)}${first.content.length > 80 ? "…" : ""}`);
95
+ }
96
+ if (cursor > 0) {
97
+ logger?.debug?.(`${TAG} Incremental filter: ${allExtracted.length} total → ${extracted.length} new (cursor=${cursor})`);
98
+ // Safety valve: if timestamp filter passed everything through and position slice
99
+ // was not available, this likely indicates timestamp drift after a gateway restart.
100
+ if (!usePositionSlice && extracted.length === allExtracted.length && allExtracted.length > 8) {
101
+ logger?.warn?.(`${TAG} ⚠ Safety valve: all ${allExtracted.length} messages passed timestamp filter (cursor=${cursor}) — ` +
102
+ `possible timestamp drift after gateway restart. Position slice was not available (no cached messageCount).`);
103
+ }
104
+ }
105
+ if (extracted.length === 0) {
106
+ logger?.debug?.(`${TAG} No new user/assistant messages to record`);
107
+ return [];
108
+ }
109
+ // Step 2: Replace polluted user messages with cached original prompt.
110
+ //
111
+ // Background:
112
+ // The framework appends the user's message to the session after before_prompt_build,
113
+ // then injects prependContext into it. So the user message in rawMessages is polluted.
114
+ // We cached the clean prompt (originalUserText) and the message count at
115
+ // before_prompt_build time (originalUserMessageCount) to identify which raw message
116
+ // is the real user input.
117
+ //
118
+ // Strategy:
119
+ // When position slice is active, the polluted user message is slicedMessages[0].
120
+ // Otherwise, fall back to rawMessages[originalUserMessageCount].
121
+ // In both cases, find the timestamp and match it in `extracted` for replacement.
122
+ // If matching fails, skip replacement — sanitizeText() in Step 3 is the safety net.
123
+ if (originalUserText) {
124
+ // Determine the target raw message that contains the polluted user prompt
125
+ const targetRaw = usePositionSlice
126
+ ? slicedMessages[0]
127
+ : (originalUserMessageCount != null && originalUserMessageCount >= 0 && originalUserMessageCount < rawMessages.length)
128
+ ? rawMessages[originalUserMessageCount]
129
+ : undefined;
130
+ const targetTs = targetRaw && typeof targetRaw.timestamp === "number" ? targetRaw.timestamp : undefined;
131
+ if (targetTs != null) {
132
+ let replaced = false;
133
+ for (let i = 0; i < extracted.length; i++) {
134
+ if (extracted[i].role === "user" && extracted[i].timestamp === targetTs) {
135
+ logger?.debug?.(`${TAG} Replacing user message at timestamp=${targetTs} with cached original prompt ` +
136
+ `(${originalUserText.length} chars, was ${extracted[i].content.length} chars) [positionSlice=${usePositionSlice}]`);
137
+ extracted[i] = { ...extracted[i], content: originalUserText };
138
+ replaced = true;
139
+ break;
140
+ }
141
+ }
142
+ if (!replaced) {
143
+ logger?.warn?.(`${TAG} Target user message (ts=${targetTs}) not found in extracted batch — ` +
144
+ `possibly filtered by cursor. Skipping replacement, will rely on sanitizeText().`);
145
+ }
146
+ }
147
+ else if (targetRaw) {
148
+ logger?.warn?.(`${TAG} Target raw message has no valid timestamp — ` +
149
+ `skipping replacement, will rely on sanitizeText().`);
150
+ }
151
+ else {
152
+ logger?.warn?.(`${TAG} Have originalUserText but cannot locate target raw message — ` +
153
+ `skipping replacement, will rely on sanitizeText().`);
154
+ }
155
+ }
156
+ // Step 3: Sanitize and filter
157
+ const filtered = extracted
158
+ .map((m) => {
159
+ let content = sanitizeText(m.content);
160
+ // Strip fenced code blocks from assistant replies to reduce embedding noise
161
+ if (m.role === "assistant") {
162
+ content = stripCodeBlocks(content);
163
+ }
164
+ return { id: m.id, role: m.role, content, timestamp: m.timestamp };
165
+ })
166
+ .filter((m) => shouldCaptureL0(m.content));
167
+ logger?.debug?.(`${TAG} After sanitize+filter: ${filtered.length} messages (from ${extracted.length})`);
168
+ if (filtered.length === 0) {
169
+ logger?.debug?.(`${TAG} All messages filtered out, skipping L0 write`);
170
+ return [];
171
+ }
172
+ // Step 4: Write to JSONL file — one message per line (flat format)
173
+ const now = new Date().toISOString();
174
+ const lines = [];
175
+ for (const msg of filtered) {
176
+ const record = {
177
+ sessionKey,
178
+ sessionId: sessionId || "",
179
+ recordedAt: now,
180
+ id: msg.id,
181
+ role: msg.role,
182
+ content: msg.content,
183
+ timestamp: msg.timestamp,
184
+ };
185
+ lines.push(JSON.stringify(record));
186
+ }
187
+ const shardDate = formatLocalDate(new Date());
188
+ const outDir = path.join(baseDir, "conversations");
189
+ const outPath = path.join(outDir, `${shardDate}.jsonl`);
190
+ try {
191
+ await fs.mkdir(outDir, { recursive: true });
192
+ // Append each message as its own JSONL line
193
+ await fs.appendFile(outPath, lines.join("\n") + "\n", "utf-8");
194
+ logger?.debug?.(`${TAG} Recorded ${filtered.length} messages to ${outPath}`);
195
+ }
196
+ catch (err) {
197
+ logger?.error(`${TAG} Failed to write L0 file: ${err instanceof Error ? err.message : String(err)}`);
198
+ // Return filtered messages anyway so L1 can still process them
199
+ }
200
+ return filtered;
201
+ }
202
+ /**
203
+ * Read all L0 conversation records for a session.
204
+ * Returns records in chronological order.
205
+ *
206
+ * File format: `YYYY-MM-DD.jsonl` (daily files, all sessions merged).
207
+ * Each line is an L0MessageRecord; filtered by sessionKey at line level.
208
+ */
209
+ export async function readConversationRecords(sessionKey, baseDir, logger) {
210
+ const conversationsDir = path.join(baseDir, "conversations");
211
+ // Daily file pattern: YYYY-MM-DD.jsonl
212
+ const dateFilePattern = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
213
+ let entries;
214
+ try {
215
+ const dirEntries = await fs.readdir(conversationsDir, { withFileTypes: true });
216
+ entries = dirEntries
217
+ .filter((entry) => entry.isFile())
218
+ .map((entry) => entry.name);
219
+ }
220
+ catch {
221
+ // Directory doesn't exist yet — normal for first conversation
222
+ return [];
223
+ }
224
+ const targetFiles = entries
225
+ .filter((name) => dateFilePattern.test(name))
226
+ .sort();
227
+ if (targetFiles.length === 0) {
228
+ return [];
229
+ }
230
+ const records = [];
231
+ for (const fileName of targetFiles) {
232
+ const filePath = path.join(conversationsDir, fileName);
233
+ let raw;
234
+ try {
235
+ raw = await fs.readFile(filePath, "utf-8");
236
+ }
237
+ catch {
238
+ logger?.warn?.(`${TAG} Failed to read L0 file: ${filePath}`);
239
+ continue;
240
+ }
241
+ const lines = raw.split("\n").filter((line) => line.trim());
242
+ for (let i = 0; i < lines.length; i++) {
243
+ const line = lines[i];
244
+ try {
245
+ const parsed = JSON.parse(line);
246
+ // Filter by sessionKey at line level
247
+ const lineSessionKey = parsed.sessionKey;
248
+ if (lineSessionKey !== sessionKey)
249
+ continue;
250
+ if (typeof parsed.role === "string" && typeof parsed.content === "string") {
251
+ // Flat format: { sessionKey, sessionId, recordedAt, id, role, content, timestamp }
252
+ // Wrap into L0ConversationRecord for uniform downstream consumption
253
+ const msg = {
254
+ id: (typeof parsed.id === "string" && parsed.id) ? parsed.id : generateMessageId(),
255
+ role: parsed.role,
256
+ content: parsed.content,
257
+ timestamp: typeof parsed.timestamp === "number" ? parsed.timestamp : Date.now(),
258
+ };
259
+ records.push({
260
+ sessionKey: parsed.sessionKey || sessionKey,
261
+ sessionId: parsed.sessionId || "",
262
+ recordedAt: parsed.recordedAt || new Date().toISOString(),
263
+ messageCount: 1,
264
+ messages: [msg],
265
+ });
266
+ }
267
+ else {
268
+ logger?.warn?.(`${TAG} Unrecognized JSONL line format in ${filePath}:${i + 1}`);
269
+ }
270
+ }
271
+ catch {
272
+ logger?.warn?.(`${TAG} Skipping malformed JSONL line in ${filePath}:${i + 1}`);
273
+ }
274
+ }
275
+ }
276
+ records.sort((a, b) => {
277
+ const ta = Date.parse(a.recordedAt);
278
+ const tb = Date.parse(b.recordedAt);
279
+ const na = Number.isFinite(ta) ? ta : Number.POSITIVE_INFINITY;
280
+ const nb = Number.isFinite(tb) ? tb : Number.POSITIVE_INFINITY;
281
+ return na - nb;
282
+ });
283
+ return records;
284
+ }
285
+ /**
286
+ * Read L0 messages across all conversation records for a session,
287
+ * optionally filtered by a cursor timestamp (messages after the cursor).
288
+ *
289
+ * When `limit` is provided, only the **newest** `limit` messages are returned
290
+ * (matching the DB path's `ORDER BY timestamp DESC LIMIT ?` behavior).
291
+ * Returned messages are always in chronological order (oldest → newest).
292
+ *
293
+ * NOTE: potential optimization — records are chronologically ordered (append-only JSONL),
294
+ * so a reverse scan could skip entire old records. Deferred for now; see Issue 5 in
295
+ * docs/05-known-issues.md.
296
+ */
297
+ export async function readConversationMessages(sessionKey, baseDir, afterTimestamp, logger, limit) {
298
+ const records = await readConversationRecords(sessionKey, baseDir, logger);
299
+ const allMessages = [];
300
+ for (const record of records) {
301
+ for (const msg of record.messages) {
302
+ if (afterTimestamp && msg.timestamp <= afterTimestamp)
303
+ continue;
304
+ allMessages.push(msg);
305
+ }
306
+ }
307
+ // Truncate to newest `limit` messages (keep tail, since array is chronological)
308
+ if (limit != null && limit > 0 && allMessages.length > limit) {
309
+ logger?.debug?.(`${TAG} readConversationMessages: truncating ${allMessages.length} → ${limit} (newest)`);
310
+ return allMessages.slice(-limit);
311
+ }
312
+ return allMessages;
313
+ }
314
+ /**
315
+ * Read L0 messages for a session, grouped by sessionId.
316
+ *
317
+ * Within the same sessionKey, different sessionIds represent different conversation
318
+ * instances (e.g. after /reset). L1 extraction should process each group independently
319
+ * so that each group's sessionId is correctly associated with its extracted memories.
320
+ *
321
+ * When `limit` is provided, only the **newest** `limit` messages (across all groups)
322
+ * are retained — matching the DB path's `ORDER BY recorded_at DESC LIMIT ?` behavior.
323
+ * Groups that become empty after truncation are dropped.
324
+ *
325
+ * Groups are returned in chronological order (by earliest message timestamp).
326
+ * Messages within each group are also in chronological order.
327
+ *
328
+ * @param afterRecordedAtMs - Epoch ms cursor: only messages with recordedAt > this are included.
329
+ */
330
+ export async function readConversationMessagesGroupedBySessionId(sessionKey, baseDir, afterRecordedAtMs, logger, limit) {
331
+ const records = await readConversationRecords(sessionKey, baseDir, logger);
332
+ // Collect all messages with their sessionId, filtering by recorded_at cursor
333
+ const allMessages = [];
334
+ for (const record of records) {
335
+ const sid = record.sessionId || "";
336
+ const recMs = Date.parse(record.recordedAt) || 0;
337
+ if (afterRecordedAtMs && recMs <= afterRecordedAtMs)
338
+ continue;
339
+ for (const msg of record.messages) {
340
+ allMessages.push({ sessionId: sid, msg: { ...msg, recordedAtMs: recMs } });
341
+ }
342
+ }
343
+ // Sort by timestamp ASC (chronological) — records are already roughly ordered
344
+ // by recordedAt, but messages within may not be perfectly sorted by timestamp.
345
+ allMessages.sort((a, b) => a.msg.timestamp - b.msg.timestamp);
346
+ // Truncate to newest `limit` messages (keep tail)
347
+ let selected = allMessages;
348
+ if (limit != null && limit > 0 && allMessages.length > limit) {
349
+ logger?.debug?.(`${TAG} readConversationMessagesGroupedBySessionId: truncating ${allMessages.length} → ${limit} (newest)`);
350
+ selected = allMessages.slice(-limit);
351
+ }
352
+ // Re-group by sessionId
353
+ const groupMap = new Map();
354
+ for (const { sessionId, msg } of selected) {
355
+ let group = groupMap.get(sessionId);
356
+ if (!group) {
357
+ group = [];
358
+ groupMap.set(sessionId, group);
359
+ }
360
+ group.push(msg);
361
+ }
362
+ // Convert to array, sorted by earliest message timestamp in each group
363
+ const groups = [];
364
+ for (const [sessionId, messages] of groupMap) {
365
+ if (messages.length > 0) {
366
+ groups.push({ sessionId, messages });
367
+ }
368
+ }
369
+ groups.sort((a, b) => a.messages[0].timestamp - b.messages[0].timestamp);
370
+ return groups;
371
+ }
372
+ // ============================
373
+ // Helpers
374
+ // ============================
375
+ /**
376
+ * Extract user and assistant messages from raw hook message array.
377
+ */
378
+ function extractUserAssistantMessages(messages) {
379
+ const result = [];
380
+ for (const msg of messages) {
381
+ if (!msg || typeof msg !== "object")
382
+ continue;
383
+ const m = msg;
384
+ const role = m.role;
385
+ if (role !== "user" && role !== "assistant")
386
+ continue;
387
+ let content;
388
+ if (typeof m.content === "string") {
389
+ content = m.content;
390
+ }
391
+ else if (Array.isArray(m.content)) {
392
+ const textParts = [];
393
+ for (const part of m.content) {
394
+ if (part &&
395
+ typeof part === "object" &&
396
+ part.type === "text") {
397
+ const text = part.text;
398
+ if (typeof text === "string")
399
+ textParts.push(text);
400
+ }
401
+ }
402
+ content = textParts.join("\n");
403
+ }
404
+ // Strip inline base64 image data URIs that some providers embed in string content.
405
+ // These are not useful for memory and would pollute FTS / embedding indexes.
406
+ if (content && /data:image\/[a-z+]+;base64,/i.test(content)) {
407
+ content = content.replace(/data:image\/[a-z+]+;base64,[A-Za-z0-9+/=]+/gi, "[image]");
408
+ }
409
+ if (content && content.trim()) {
410
+ const ts = typeof m.timestamp === "number" ? m.timestamp : Date.now();
411
+ result.push({
412
+ id: (typeof m.id === "string" && m.id) ? m.id : generateMessageId(),
413
+ role: role,
414
+ content: content.trim(),
415
+ timestamp: ts,
416
+ });
417
+ }
418
+ }
419
+ return result;
420
+ }