dsh-tdai-memory 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/THIRD-PARTY-NOTICES.md +20 -0
- package/index.js +328 -0
- package/package.json +53 -0
- package/recall-inject.js +80 -0
- package/vendor/tdai/adapters/index.js +15 -0
- package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -0
- package/vendor/tdai/adapters/openclaw/index.js +5 -0
- package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -0
- package/vendor/tdai/adapters/standalone/host-adapter.js +58 -0
- package/vendor/tdai/adapters/standalone/index.js +5 -0
- package/vendor/tdai/adapters/standalone/llm-runner.js +246 -0
- package/vendor/tdai/config.js +363 -0
- package/vendor/tdai/core/conversation/l0-recorder.js +420 -0
- package/vendor/tdai/core/hooks/auto-capture.js +236 -0
- package/vendor/tdai/core/hooks/auto-recall.js +653 -0
- package/vendor/tdai/core/index.js +8 -0
- package/vendor/tdai/core/persona/persona-generator.js +184 -0
- package/vendor/tdai/core/persona/persona-trigger.js +94 -0
- package/vendor/tdai/core/profile/profile-sync.js +200 -0
- package/vendor/tdai/core/prompts/l1-dedup.js +136 -0
- package/vendor/tdai/core/prompts/l1-extraction.js +132 -0
- package/vendor/tdai/core/prompts/persona-generation.js +160 -0
- package/vendor/tdai/core/prompts/scene-extraction.js +269 -0
- package/vendor/tdai/core/record/l1-dedup.js +318 -0
- package/vendor/tdai/core/record/l1-extractor.js +379 -0
- package/vendor/tdai/core/record/l1-reader.js +178 -0
- package/vendor/tdai/core/record/l1-writer.js +141 -0
- package/vendor/tdai/core/report/reporter.js +86 -0
- package/vendor/tdai/core/scene/filename-normalizer.js +161 -0
- package/vendor/tdai/core/scene/scene-extractor.js +410 -0
- package/vendor/tdai/core/scene/scene-format.js +53 -0
- package/vendor/tdai/core/scene/scene-index.js +82 -0
- package/vendor/tdai/core/scene/scene-navigation.js +60 -0
- package/vendor/tdai/core/seed/input.js +404 -0
- package/vendor/tdai/core/seed/seed-runtime.js +314 -0
- package/vendor/tdai/core/seed/types.js +9 -0
- package/vendor/tdai/core/store/bm25-client.js +117 -0
- package/vendor/tdai/core/store/bm25-local.js +68 -0
- package/vendor/tdai/core/store/embedding.js +632 -0
- package/vendor/tdai/core/store/factory.js +95 -0
- package/vendor/tdai/core/store/search-utils.js +54 -0
- package/vendor/tdai/core/store/sqlite.js +1822 -0
- package/vendor/tdai/core/store/tcvdb-client.js +221 -0
- package/vendor/tdai/core/store/tcvdb.js +1047 -0
- package/vendor/tdai/core/store/types.js +17 -0
- package/vendor/tdai/core/tdai-core.js +446 -0
- package/vendor/tdai/core/tools/conversation-search.js +199 -0
- package/vendor/tdai/core/tools/memory-search.js +207 -0
- package/vendor/tdai/core/types.js +12 -0
- package/vendor/tdai/utils/backup.js +189 -0
- package/vendor/tdai/utils/checkpoint.js +356 -0
- package/vendor/tdai/utils/clean-context-runner.js +472 -0
- package/vendor/tdai/utils/ensure-hook-policy.js +213 -0
- package/vendor/tdai/utils/env.js +13 -0
- package/vendor/tdai/utils/managed-timer.js +127 -0
- package/vendor/tdai/utils/manifest.js +91 -0
- package/vendor/tdai/utils/memory-cleaner.js +300 -0
- package/vendor/tdai/utils/no-think-fetch.js +109 -0
- package/vendor/tdai/utils/openclaw-state-dir.js +26 -0
- package/vendor/tdai/utils/pipeline-factory.js +517 -0
- package/vendor/tdai/utils/pipeline-manager.js +892 -0
- package/vendor/tdai/utils/sanitize.js +360 -0
- package/vendor/tdai/utils/serial-queue.js +103 -0
- package/vendor/tdai/utils/session-filter.js +88 -0
- package/vendor/tdai/utils/text-utils.js +29 -0
- package/vendor/tdai/utils/time.js +252 -0
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auto-capture hook (v3): records conversation messages locally (L0),
|
|
3
|
+
* then notifies the MemoryPipelineManager for L1/L2/L3 scheduling.
|
|
4
|
+
*
|
|
5
|
+
* Key design decisions:
|
|
6
|
+
* - Always write L0 locally via l0-recorder.
|
|
7
|
+
* - When VectorStore + EmbeddingService are available, also write L0 vector index.
|
|
8
|
+
* - Notify MemoryPipelineManager for L1/L2/L3 trigger evaluation.
|
|
9
|
+
* - L1 Runner reads from VectorStore DB (primary) or L0 JSONL files (fallback).
|
|
10
|
+
* - Extraction is NOT triggered here. The pipeline manager decides when.
|
|
11
|
+
*/
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import { CheckpointManager } from "../../utils/checkpoint.js";
|
|
14
|
+
import { recordConversation } from "../conversation/l0-recorder.js";
|
|
15
|
+
const TAG = "[memory-tdai] [capture]";
|
|
16
|
+
/**
|
|
17
|
+
* Generate a unique L0 record ID for vector indexing.
|
|
18
|
+
* Includes an index to distinguish multiple messages within the same round.
|
|
19
|
+
*/
|
|
20
|
+
function generateL0RecordId(sessionKey, index) {
|
|
21
|
+
return `l0_${sessionKey}_${Date.now()}_${index}_${crypto.randomBytes(3).toString("hex")}`;
|
|
22
|
+
}
|
|
23
|
+
export async function performAutoCapture(params) {
|
|
24
|
+
const { messages, sessionKey, sessionId, cfg, pluginDataDir, logger, scheduler, originalUserText, originalUserMessageCount, pluginStartTimestamp, vectorStore, embeddingService, bgTaskRegistry, } = params;
|
|
25
|
+
const tCaptureStart = performance.now();
|
|
26
|
+
const checkpoint = new CheckpointManager(pluginDataDir, logger);
|
|
27
|
+
// ============================
|
|
28
|
+
// Step 1 + 2: L0 recording + checkpoint update (ATOMIC)
|
|
29
|
+
// ============================
|
|
30
|
+
// These steps are combined inside captureAtomically() to prevent the race
|
|
31
|
+
// condition where two concurrent agent_end events both read the same stale
|
|
32
|
+
// cursor and produce duplicate L0 records. The file lock is held for the
|
|
33
|
+
// entire read-cursor → recordConversation → advance-cursor sequence.
|
|
34
|
+
const tL0RecordStart = performance.now();
|
|
35
|
+
let filteredMessages = [];
|
|
36
|
+
try {
|
|
37
|
+
await checkpoint.captureAtomically(sessionKey, pluginStartTimestamp, async (afterTimestamp) => {
|
|
38
|
+
logger?.debug?.(`${TAG} L0 capture cursor (per-session, atomic): afterTimestamp=${afterTimestamp} session=${sessionKey}`);
|
|
39
|
+
if (afterTimestamp === pluginStartTimestamp && pluginStartTimestamp && pluginStartTimestamp > 0) {
|
|
40
|
+
logger?.debug?.(`${TAG} No per-session checkpoint cursor found for session=${sessionKey} — ` +
|
|
41
|
+
`using pluginStartTimestamp as floor: ` +
|
|
42
|
+
`${afterTimestamp} (${new Date(afterTimestamp).toISOString()})`);
|
|
43
|
+
}
|
|
44
|
+
filteredMessages = await recordConversation({
|
|
45
|
+
sessionKey,
|
|
46
|
+
sessionId,
|
|
47
|
+
rawMessages: messages,
|
|
48
|
+
baseDir: pluginDataDir,
|
|
49
|
+
logger,
|
|
50
|
+
originalUserText,
|
|
51
|
+
afterTimestamp,
|
|
52
|
+
originalUserMessageCount,
|
|
53
|
+
});
|
|
54
|
+
if (filteredMessages.length === 0) {
|
|
55
|
+
return null; // Nothing captured — cursor stays unchanged
|
|
56
|
+
}
|
|
57
|
+
logger?.debug?.(`${TAG} L0 recorded: ${filteredMessages.length} messages for session ${sessionKey}`);
|
|
58
|
+
const maxTs = Math.max(...filteredMessages.map((m) => m.timestamp));
|
|
59
|
+
return { maxTimestamp: maxTs, messageCount: filteredMessages.length };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
logger?.error(`${TAG} L0 recording failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
64
|
+
}
|
|
65
|
+
const tL0RecordEnd = performance.now();
|
|
66
|
+
// ============================
|
|
67
|
+
// Step 1.5: L0 vector indexing
|
|
68
|
+
// ============================
|
|
69
|
+
// Two paths depending on store capabilities:
|
|
70
|
+
//
|
|
71
|
+
// A) Store supports updateL0Embedding (sqlite):
|
|
72
|
+
// - Write metadata + FTS immediately WITHOUT embedding (~ms)
|
|
73
|
+
// - Fire-and-forget background task: embedBatch + updateL0Embedding
|
|
74
|
+
// - PERF: avoids blocking agent_end with 2-3s embedding calls
|
|
75
|
+
//
|
|
76
|
+
// B) Store does NOT support updateL0Embedding (VDB / remote):
|
|
77
|
+
// - Embed synchronously, then upsertL0 with embedding in one call
|
|
78
|
+
// - VDB backends handle embedding server-side or need it upfront
|
|
79
|
+
const tL0VecStart = performance.now();
|
|
80
|
+
let l0VectorsWritten = 0;
|
|
81
|
+
let l0EmbedTotalMs = 0;
|
|
82
|
+
let l0UpsertTotalMs = 0;
|
|
83
|
+
logger?.debug?.(`${TAG} [L0-vec-index] Check: filteredMessages=${filteredMessages.length}, ` +
|
|
84
|
+
`vectorStore=${vectorStore ? "available" : "UNAVAILABLE"}, ` +
|
|
85
|
+
`embeddingService=${embeddingService ? "available" : "UNAVAILABLE"}`);
|
|
86
|
+
const supportsBgEmbed = vectorStore?.supportsDeferredEmbedding === true;
|
|
87
|
+
if (filteredMessages.length > 0 && vectorStore) {
|
|
88
|
+
const now = new Date().toISOString();
|
|
89
|
+
const bgRecords = [];
|
|
90
|
+
logger?.debug?.(`${TAG} [L0-vec-index] START indexing ${filteredMessages.length} message(s) for session ${sessionKey} ` +
|
|
91
|
+
`(mode=${supportsBgEmbed ? "async-bg" : "sync"})`);
|
|
92
|
+
for (let i = 0; i < filteredMessages.length; i++) {
|
|
93
|
+
const msg = filteredMessages[i];
|
|
94
|
+
try {
|
|
95
|
+
const l0Record = {
|
|
96
|
+
id: generateL0RecordId(sessionKey, i),
|
|
97
|
+
sessionKey,
|
|
98
|
+
sessionId: sessionId || "",
|
|
99
|
+
role: msg.role,
|
|
100
|
+
messageText: msg.content,
|
|
101
|
+
recordedAt: now,
|
|
102
|
+
timestamp: msg.timestamp,
|
|
103
|
+
};
|
|
104
|
+
let embedding;
|
|
105
|
+
if (!supportsBgEmbed && embeddingService) {
|
|
106
|
+
// Path B (VDB): embed synchronously — needed for upsertL0
|
|
107
|
+
// Skip local embed when using server-side embedding (NoopEmbeddingService, dims=0)
|
|
108
|
+
if (embeddingService.getDimensions() === 0) {
|
|
109
|
+
logger?.debug?.(`${TAG} [L0-vec-index] Server-side embedding (dims=0), skipping local embed for message ${i}`);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
const tEmbedStart = performance.now();
|
|
113
|
+
try {
|
|
114
|
+
embedding = await embeddingService.embed(msg.content);
|
|
115
|
+
l0EmbedTotalMs += performance.now() - tEmbedStart;
|
|
116
|
+
logger?.debug?.(`${TAG} [L0-vec-index] Embedding OK: dims=${embedding.length}, ` +
|
|
117
|
+
`norm=${Math.sqrt(Array.from(embedding).reduce((s, v) => s + v * v, 0)).toFixed(4)}`);
|
|
118
|
+
}
|
|
119
|
+
catch (embedErr) {
|
|
120
|
+
l0EmbedTotalMs += performance.now() - tEmbedStart;
|
|
121
|
+
logger?.warn(`${TAG} [L0-vec-index] Embedding FAILED for message ${i}, ` +
|
|
122
|
+
`will write metadata only: ${embedErr instanceof Error ? embedErr.message : String(embedErr)}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Path A (sqlite): pass undefined embedding — metadata + FTS only
|
|
127
|
+
// Path B (VDB): pass embedding (may be undefined on failure)
|
|
128
|
+
const tUpsertStart = performance.now();
|
|
129
|
+
const upsertOk = await vectorStore.upsertL0(l0Record, supportsBgEmbed ? undefined : embedding);
|
|
130
|
+
l0UpsertTotalMs += performance.now() - tUpsertStart;
|
|
131
|
+
if (upsertOk) {
|
|
132
|
+
l0VectorsWritten++;
|
|
133
|
+
if (supportsBgEmbed) {
|
|
134
|
+
bgRecords.push({ recordId: l0Record.id, content: msg.content });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
logger?.warn(`${TAG} [L0-vec-index] upsertL0 returned false for message ${i}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
logger?.warn?.(`${TAG} [L0-vec-index] FAILED for message ${i} (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const modeLabel = supportsBgEmbed ? "metadata-only, embed=background" : `embed=${l0EmbedTotalMs.toFixed(0)}ms, upsert=${l0UpsertTotalMs.toFixed(0)}ms`;
|
|
146
|
+
logger?.debug?.(`${TAG} [L0-vec-index] DONE: ${l0VectorsWritten}/${filteredMessages.length} records written (${modeLabel})`);
|
|
147
|
+
// Path A only: fire-and-forget background embedding for sqlite stores
|
|
148
|
+
if (supportsBgEmbed && bgRecords.length > 0 && embeddingService) {
|
|
149
|
+
const bgVectorStore = vectorStore;
|
|
150
|
+
const bgEmbeddingService = embeddingService;
|
|
151
|
+
const bgSnapshot = [...bgRecords];
|
|
152
|
+
const bgLogger = logger;
|
|
153
|
+
// Do NOT await — runs in background after response is sent.
|
|
154
|
+
//
|
|
155
|
+
// Register the task in bgTaskRegistry (if provided) so TdaiCore.destroy()
|
|
156
|
+
// can await it before closing vectorStore / embeddingService. The
|
|
157
|
+
// ``.finally`` clean-up ensures the entry is removed on both success
|
|
158
|
+
// and failure; without that the set would leak and eventually block
|
|
159
|
+
// shutdown indefinitely.
|
|
160
|
+
const bgPromise = (async () => {
|
|
161
|
+
const tBgStart = performance.now();
|
|
162
|
+
try {
|
|
163
|
+
const texts = bgSnapshot.map((r) => r.content);
|
|
164
|
+
const embeddings = await bgEmbeddingService.embedBatch(texts);
|
|
165
|
+
let bgUpdated = 0;
|
|
166
|
+
for (let i = 0; i < bgSnapshot.length; i++) {
|
|
167
|
+
try {
|
|
168
|
+
const ok = await bgVectorStore.updateL0Embedding(bgSnapshot[i].recordId, embeddings[i]);
|
|
169
|
+
if (ok)
|
|
170
|
+
bgUpdated++;
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
bgLogger?.warn?.(`${TAG} [L0-vec-index-bg] Failed to update embedding for ${bgSnapshot[i].recordId}: ` +
|
|
174
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const bgMs = performance.now() - tBgStart;
|
|
178
|
+
bgLogger?.debug?.(`${TAG} [L0-vec-index-bg] Background embedding complete: ${bgUpdated}/${bgSnapshot.length} vectors updated (${bgMs.toFixed(0)}ms)`);
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
const bgMs = performance.now() - tBgStart;
|
|
182
|
+
bgLogger?.warn?.(`${TAG} [L0-vec-index-bg] Background embedding failed (${bgMs.toFixed(0)}ms, non-fatal): ` +
|
|
183
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
184
|
+
}
|
|
185
|
+
})();
|
|
186
|
+
if (bgTaskRegistry) {
|
|
187
|
+
bgTaskRegistry.add(bgPromise);
|
|
188
|
+
void bgPromise.finally(() => {
|
|
189
|
+
bgTaskRegistry.delete(bgPromise);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else if (filteredMessages.length > 0) {
|
|
195
|
+
logger?.warn(`${TAG} [L0-vec-index] SKIPPED: vectorStore not available`);
|
|
196
|
+
}
|
|
197
|
+
const tL0VecEnd = performance.now();
|
|
198
|
+
// ============================
|
|
199
|
+
// Step 3: Notify scheduler of this conversation round
|
|
200
|
+
// ============================
|
|
201
|
+
const tNotifyStart = performance.now();
|
|
202
|
+
// Pass empty array: L1 Runner reads from VectorStore DB (or L0 JSONL fallback), not from in-memory buffers.
|
|
203
|
+
if (scheduler) {
|
|
204
|
+
await scheduler.notifyConversation(sessionKey, []);
|
|
205
|
+
logger?.debug?.(`${TAG} Scheduler notified of conversation round (sessionKey=${sessionKey})`);
|
|
206
|
+
const totalMs = performance.now() - tCaptureStart;
|
|
207
|
+
const vecDetail = supportsBgEmbed
|
|
208
|
+
? `metadata-only, embed=background, msgs=${filteredMessages.length}`
|
|
209
|
+
: `embed=${l0EmbedTotalMs.toFixed(0)}ms, upsert=${l0UpsertTotalMs.toFixed(0)}ms, msgs=${filteredMessages.length}`;
|
|
210
|
+
logger?.info(`${TAG} ⏱ Capture timing: total=${totalMs.toFixed(0)}ms, ` +
|
|
211
|
+
`l0Record+checkpoint=${(tL0RecordEnd - tL0RecordStart).toFixed(0)}ms, ` +
|
|
212
|
+
`l0VecIndex=${(tL0VecEnd - tL0VecStart).toFixed(0)}ms (${vecDetail}), ` +
|
|
213
|
+
`notify=${(performance.now() - tNotifyStart).toFixed(0)}ms`);
|
|
214
|
+
return {
|
|
215
|
+
schedulerNotified: true,
|
|
216
|
+
l0RecordedCount: filteredMessages.length,
|
|
217
|
+
l0VectorsWritten,
|
|
218
|
+
filteredMessages,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
const totalMs = performance.now() - tCaptureStart;
|
|
222
|
+
const vecDetail = supportsBgEmbed
|
|
223
|
+
? `metadata-only, embed=background, msgs=${filteredMessages.length}`
|
|
224
|
+
: `embed=${l0EmbedTotalMs.toFixed(0)}ms, upsert=${l0UpsertTotalMs.toFixed(0)}ms, msgs=${filteredMessages.length}`;
|
|
225
|
+
logger?.info(`${TAG} ⏱ Capture timing: total=${totalMs.toFixed(0)}ms, ` +
|
|
226
|
+
`l0Record+checkpoint=${(tL0RecordEnd - tL0RecordStart).toFixed(0)}ms, ` +
|
|
227
|
+
`l0VecIndex=${(tL0VecEnd - tL0VecStart).toFixed(0)}ms (${vecDetail}), ` +
|
|
228
|
+
`notify=${(performance.now() - tNotifyStart).toFixed(0)}ms`);
|
|
229
|
+
logger?.debug?.(`${TAG} No scheduler provided, skipping notification`);
|
|
230
|
+
return {
|
|
231
|
+
schedulerNotified: false,
|
|
232
|
+
l0RecordedCount: filteredMessages.length,
|
|
233
|
+
l0VectorsWritten,
|
|
234
|
+
filteredMessages,
|
|
235
|
+
};
|
|
236
|
+
}
|