UNPKG

@tencentdb-agent-memory/memory-tencentdb

Version:

Four-layer local memory system plugin for OpenClaw — auto-captures, structures, and profiles conversational knowledge using local LLM + SQLite vector search (L0→L1→L2→L3 pipeline)

236 lines (208 loc) 8.5 kB
/** * Capture hook (client mode): * 1. Extract user/assistant messages from the agent_end raw message array * 2. Apply position slice + (optional) timestamp cursor to keep only this turn * 3. Replace the polluted user message with the cached original prompt * 4. Sanitize text + strip code blocks (assistant) + filter noise * 5. POST the cleaned messages to the gateway via SDK addConversation * * Mirrors the structural cleanup in extensions/memory-tencentdb/src/core/conversation/l0-recorder.ts * (recordConversation), but does not write any local JSONL — the server is * authoritative for L0 storage. */ import type { MemoryClient } from "@tencentdb-agent-memory/memory-sdk-ts"; import { sanitizeText, stripCodeBlocks, shouldCaptureL0 } from "../sanitize.js"; const TAG = "[memory-client][capture]"; interface Logger { debug?: (msg: string) => void; info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void; } interface ExtractedMessage { role: "user" | "assistant"; content: string; timestamp: number; } export interface CaptureContext { /** sessionKey from agent_end ctx — used as conversation key on the server. */ sessionKey: string; /** sessionId from agent_end ctx — passed to addConversation when present. */ sessionId?: string; /** Raw event.messages array (full session history at agent_end time). */ rawMessages: unknown[]; /** Clean original user prompt (cached at before_prompt_build, pre-pollution). */ originalUserText?: string; /** * Number of messages in the session at before_prompt_build time. * Used to position-slice rawMessages so we only re-send messages added in this turn. */ originalUserMessageCount?: number; /** * Epoch ms cursor: only messages with timestamp > this are sent. * Used as a fallback when position slice is unavailable. */ afterTimestamp?: number; } export interface CaptureResult { /** Number of messages actually sent to the gateway (post-filter). */ capturedCount: number; /** Server-reported total count after this batch. */ serverTotalCount?: number; /** Max timestamp among captured messages — caller should advance its cursor to this. */ maxTimestamp?: number; } /** * Run a single agent_end capture. * * Returns immediately with `capturedCount: 0` if there is nothing to send. * Errors from the gateway are thrown — caller should wrap in try/catch. */ export async function performCapture( client: MemoryClient, ctx: CaptureContext, logger?: Logger, ): Promise<CaptureResult> { const { sessionKey, sessionId, rawMessages, originalUserText, originalUserMessageCount, afterTimestamp } = ctx; // ── Step 1. Position slice ── // Only consider messages added AFTER before_prompt_build, i.e. this turn's input. const usePositionSlice = originalUserMessageCount != null && originalUserMessageCount > 0 && originalUserMessageCount <= rawMessages.length; const slicedMessages = usePositionSlice ? rawMessages.slice(originalUserMessageCount) : rawMessages; if (usePositionSlice) { logger?.debug?.( `${TAG} Position slice: ${rawMessages.length} raw → ${slicedMessages.length} new ` + `(sliceStart=${originalUserMessageCount})`, ); } // ── Step 2. Extract user/assistant messages ── const allExtracted = extractUserAssistantMessages(slicedMessages); logger?.debug?.( `${TAG} Extracted ${allExtracted.length} user/assistant messages from ${slicedMessages.length} raw`, ); // ── Step 3. Timestamp cursor (fallback when position slice unavailable) ── const cursor = afterTimestamp ?? 0; const filteredByTime = cursor !== 0 ? allExtracted.filter((m) => m.timestamp > cursor) : allExtracted; if (cursor > 0) { logger?.debug?.( `${TAG} Timestamp filter: ${allExtracted.length}${filteredByTime.length} (cursor=${cursor})`, ); } if (filteredByTime.length === 0) { logger?.debug?.(`${TAG} No new messages to capture`); return { capturedCount: 0 }; } // ── Step 4. Replace polluted user message with cached original ── // The framework appends the user's message AFTER before_prompt_build and // injects prependContext into it. Without this swap, the captured user // text would contain the recall blob, causing a feedback loop. if (originalUserText) { const targetRaw = usePositionSlice ? (slicedMessages[0] as Record<string, unknown> | undefined) : (originalUserMessageCount != null && originalUserMessageCount >= 0 && originalUserMessageCount < rawMessages.length) ? (rawMessages[originalUserMessageCount] as Record<string, unknown> | undefined) : undefined; const targetTs = typeof targetRaw?.timestamp === "number" ? targetRaw.timestamp : undefined; if (targetTs != null) { let replaced = false; for (let i = 0; i < filteredByTime.length; i++) { if (filteredByTime[i].role === "user" && filteredByTime[i].timestamp === targetTs) { logger?.debug?.( `${TAG} Replacing polluted user message (ts=${targetTs}, ` + `${filteredByTime[i].content.length}${originalUserText.length} chars)`, ); filteredByTime[i] = { ...filteredByTime[i], content: originalUserText }; replaced = true; break; } } if (!replaced) { logger?.warn?.(`${TAG} Could not match cached prompt to any extracted user message — relying on sanitizeText()`); } } } // ── Step 5. Sanitize + strip code + filter ── const cleaned = filteredByTime .map((m) => { let content = sanitizeText(m.content); if (m.role === "assistant") content = stripCodeBlocks(content); return { role: m.role, content, timestamp: m.timestamp }; }) .filter((m) => shouldCaptureL0(m.content)); logger?.debug?.( `${TAG} After sanitize+filter: ${cleaned.length} messages (from ${filteredByTime.length})`, ); if (cleaned.length === 0) { logger?.info(`${TAG} All messages filtered out, skipping POST`); return { capturedCount: 0 }; } // ── Step 6. POST to gateway ── const result = await client.addConversation({ session_id: sessionId ?? sessionKey, messages: cleaned.map((m) => ({ role: m.role, content: m.content, timestamp: new Date(m.timestamp).toISOString(), })), }); const maxTimestamp = Math.max(...cleaned.map((m) => m.timestamp)); logger?.info( `${TAG} Captured ${cleaned.length} message(s) (server total=${result.total_count}, ` + `sessionKey=${sessionKey.slice(0, 32)}${sessionKey.length > 32 ? "…" : ""})`, ); return { capturedCount: cleaned.length, serverTotalCount: result.total_count, maxTimestamp, }; } /** * Extract user/assistant entries from the framework's raw message array. * * Handles both content shapes the framework may produce: * - `content: string` * - `content: Array<{ type: "text", text: string } | ...>` * * Strips inline base64 image data URIs (replaces with `[image]`) so they do * not bloat the request payload or pollute downstream FTS / embeddings. */ function extractUserAssistantMessages(messages: unknown[]): ExtractedMessage[] { const result: ExtractedMessage[] = []; for (const msg of messages) { if (!msg || typeof msg !== "object") continue; const m = msg as Record<string, unknown>; const role = m.role as string | undefined; if (role !== "user" && role !== "assistant") continue; let content: string | undefined; if (typeof m.content === "string") { content = m.content; } else if (Array.isArray(m.content)) { const parts: string[] = []; for (const part of m.content) { if (part && typeof part === "object" && (part as Record<string, unknown>).type === "text") { const text = (part as Record<string, unknown>).text; if (typeof text === "string") parts.push(text); } } content = parts.join("\n"); } if (content && /data:image\/[a-z+]+;base64,/i.test(content)) { content = content.replace(/data:image\/[a-z+]+;base64,[A-Za-z0-9+/=]+/gi, "[image]"); } if (content && content.trim()) { const ts = typeof m.timestamp === "number" ? m.timestamp : Date.now(); result.push({ role: role as "user" | "assistant", content: content.trim(), timestamp: ts, }); } } return result; }