From a8930a9a6ed8883c38f112d9b61e10be00d779ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Tue, 19 May 2026 00:19:24 +0800 Subject: [PATCH] fix(plugin): make before_message_write hook a no-op by default (cache-friendly) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The before_message_write hook in index.ts unconditionally stripped tags from user messages before persisting to the session JSONL. This destroyed LLM prompt-prefix stability across sub-agent / replay boundaries: each turn's in-memory effectivePrompt contained the memories block but the same turn re-read from the JSONL did not — every cross-boundary prompt suffered a first-token cache miss. Reported in #11 by @yunhao-tech; similar direction suggested by @changxu21-spec. The hook's stated dual purpose was (1) transcript cleanliness and (2) anti-feedback-loop. (2) is already handled independently by sanitizeText() in src/core/conversation/l0-recorder.ts:254 on every L0-bound message, so the hook strip is strictly about (1) and is strictly the cause of the cache miss. Default behavior change: the hook becomes a no-op. The transcript now preserves blocks; prompt cache hits across boundaries; long agent loops no longer accumulate first-token latency. Opt-in legacy strip: set TDAI_STRIP_RELEVANT_MEMORIES_ON_WRITE=1. Env is read on every hook invocation (no constructor-time caching), so operators can flip behavior without restarting OpenClaw / Hermes. Strip logic is factored into an exported helper maybeStripRelevantMemoriesOnWrite(message) so it can be unit-tested without mocking the OpenClaw runtime. Tests: new src/__tests__/before-message-write.test.ts — 11 cases covering env-unset default, env=1 strip for string/parts content, role guard, no-tag short-circuit, and a 6-row it.each over non-literal-"1" env values. Unchanged: - src/core/hooks/auto-recall.ts still injects . - src/core/conversation/l0-recorder.ts still strips via sanitizeText on L0 capture (true anti-loop defense). - src/utils/sanitize.ts itself. Hermes plugin path and Claude Code plugin path are both unaffected (neither goes through the OpenClaw before_message_write hook). Closes #11. Signed-off-by: 李冠辰 --- index.ts | 102 ++++++++++++++------- src/__tests__/before-message-write.test.ts | 70 ++++++++++++++ 2 files changed, 137 insertions(+), 35 deletions(-) create mode 100644 src/__tests__/before-message-write.test.ts diff --git a/index.ts b/index.ts index b7f67ed..79b6d7f 100644 --- a/index.ts +++ b/index.ts @@ -43,6 +43,55 @@ import { decideHookPolicy, } from "./src/utils/ensure-hook-policy.js"; +/** + * Inspect a message that the OpenClaw runtime is about to persist to the + * session JSONL via `before_message_write`. + * + * Return `null` to leave the message unchanged, or `{ content }` to signal + * the new content. The caller (the `before_message_write` hook wrapper) is + * responsible for re-wrapping the result into the OpenClaw return shape: + * `{ message: { ...event.message, content } }`. Keeping the helper's + * return shape minimal makes it trivially testable in isolation. + * + * Default: returns `null` (preserves `` blocks in the + * transcript so the LLM prompt prefix stays stable across sub-agent / + * replay boundaries — see issue #11). + * + * Opt-in to the legacy strip behavior with + * `TDAI_STRIP_RELEVANT_MEMORIES_ON_WRITE=1`. + */ +export function maybeStripRelevantMemoriesOnWrite( + message: { role?: string; content?: unknown }, +): { content: unknown } | null { + if (process.env.TDAI_STRIP_RELEVANT_MEMORIES_ON_WRITE !== "1") return null; + if (message.role !== "user") return null; + + const STRIP_RE = /[\s\S]*?<\/relevant-memories>\s*/g; + + if (typeof message.content === "string") { + if (!message.content.includes("")) return null; + const cleaned = message.content.replace(STRIP_RE, "").trim(); + if (cleaned === message.content) return null; + return { content: cleaned }; + } + + if (Array.isArray(message.content)) { + let changed = false; + const cleanedParts = (message.content as Array>).map((part) => { + if (part.type !== "text" || typeof part.text !== "string") return part; + if (!(part.text as string).includes("")) return part; + const cleaned = (part.text as string).replace(STRIP_RE, "").trim(); + if (cleaned === part.text) return part; + changed = true; + return { ...part, text: cleaned }; + }); + if (!changed) return null; + return { content: cleanedParts }; + } + + return null; +} + const TAG = "[memory-tdai]"; /** @@ -583,42 +632,25 @@ export default function register(api: OpenClawPluginApi) { }); } - // Strip from user messages before they are persisted to - // the session JSONL. The current-turn LLM already saw the full prompt - // (effectivePrompt lives in memory), but we don't want recall artifacts - // polluting the historical transcript for future replays. - api.logger.debug?.(`${TAG} Registering before_message_write hook (strip )`); + // Conditionally strip from user messages before they + // are persisted to the session JSONL. Default is no-op so the LLM prompt + // prefix stays stable across sub-agent / replay boundaries; set + // TDAI_STRIP_RELEVANT_MEMORIES_ON_WRITE=1 to restore the previous + // unconditional strip behavior. See issue #11 and the + // `maybeStripRelevantMemoriesOnWrite` helper above for details. + api.logger.debug?.( + `${TAG} Registering before_message_write hook (default no-op; ` + + `set TDAI_STRIP_RELEVANT_MEMORIES_ON_WRITE=1 to strip on write)`, + ); api.on("before_message_write", (event) => { - const msg = event.message as { role?: string; content?: unknown }; - const contentType = typeof msg.content === "string" ? "string" : Array.isArray(msg.content) ? "parts" : typeof msg.content; - api.logger.debug?.(`${TAG} [before_message_write] role=${msg.role}, contentType=${contentType}`); - - if (msg.role !== "user") return; - - // UserMessage.content: string | (TextContent | ImageContent)[] - const STRIP_RE = /[\s\S]*?<\/relevant-memories>\s*/g; - - if (typeof msg.content === "string") { - if (!msg.content.includes("")) return; - const cleaned = msg.content.replace(STRIP_RE, "").trim(); - if (cleaned === msg.content) return; - api.logger.debug?.(`${TAG} [before_message_write] Stripped: ${msg.content.length} → ${cleaned.length} chars`); - return { message: { ...event.message, content: cleaned } as typeof event.message }; - } - - if (Array.isArray(msg.content)) { - let totalStripped = 0; - const cleanedParts = (msg.content as Array>).map((part) => { - if (part.type !== "text" || typeof part.text !== "string") return part; - if (!(part.text as string).includes("")) return part; - const cleaned = (part.text as string).replace(STRIP_RE, "").trim(); - totalStripped += (part.text as string).length - cleaned.length; - return { ...part, text: cleaned }; - }); - if (totalStripped === 0) return; - api.logger.debug?.(`${TAG} [before_message_write] Stripped from parts: removed ${totalStripped} chars`); - return { message: { ...event.message, content: cleanedParts } as unknown as typeof event.message }; - } + const replacement = maybeStripRelevantMemoriesOnWrite( + event.message as { role?: string; content?: unknown }, + ); + if (!replacement) return; + api.logger.debug?.( + `${TAG} [before_message_write] Stripped (legacy mode)`, + ); + return { message: { ...event.message, ...replacement } as typeof event.message }; }); // After agent end: auto-capture + L0 record + L1/L2/L3 schedule diff --git a/src/__tests__/before-message-write.test.ts b/src/__tests__/before-message-write.test.ts new file mode 100644 index 0000000..7c4ee01 --- /dev/null +++ b/src/__tests__/before-message-write.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from "vitest"; +import { maybeStripRelevantMemoriesOnWrite } from "../../index.js"; + +describe("maybeStripRelevantMemoriesOnWrite", () => { + it("returns null when TDAI_STRIP_RELEVANT_MEMORIES_ON_WRITE is unset", () => { + const msg = { + role: "user", + content: "hello foo world", + }; + expect(maybeStripRelevantMemoriesOnWrite(msg)).toBeNull(); + }); + + it("strips from string content when env=1", () => { + vi.stubEnv("TDAI_STRIP_RELEVANT_MEMORIES_ON_WRITE", "1"); + const msg = { + role: "user", + content: + "hello foo bar baz world", + }; + const result = maybeStripRelevantMemoriesOnWrite(msg); + expect(result).not.toBeNull(); + expect(result!.content).toBe("hello world"); + }); + + it("strips from one part of array content when env=1", () => { + vi.stubEnv("TDAI_STRIP_RELEVANT_MEMORIES_ON_WRITE", "1"); + const msg = { + role: "user", + content: [ + { type: "text", text: "before x" }, + { type: "image", url: "https://example.com/cat.png" }, + { type: "text", text: "after (no memories here)" }, + ], + }; + const result = maybeStripRelevantMemoriesOnWrite(msg); + expect(result).not.toBeNull(); + const parts = result!.content as Array<{ type: string; text?: string; url?: string }>; + expect(parts).toHaveLength(3); + expect(parts[0].text).toBe("before"); + expect(parts[1].url).toBe("https://example.com/cat.png"); + expect(parts[2].text).toBe("after (no memories here)"); + }); + + it("does not touch assistant-role messages even when env=1", () => { + vi.stubEnv("TDAI_STRIP_RELEVANT_MEMORIES_ON_WRITE", "1"); + const msg = { + role: "assistant", + content: "ok x", + }; + expect(maybeStripRelevantMemoriesOnWrite(msg)).toBeNull(); + }); + + it("returns null when user message has no tag (env=1)", () => { + vi.stubEnv("TDAI_STRIP_RELEVANT_MEMORIES_ON_WRITE", "1"); + const msg = { role: "user", content: "just a plain message" }; + expect(maybeStripRelevantMemoriesOnWrite(msg)).toBeNull(); + }); + + it.each(["true", "yes", "0", "1 ", "", "TRUE"])( + "treats env=%j (anything but literal '1') as unset", + (envValue) => { + vi.stubEnv("TDAI_STRIP_RELEVANT_MEMORIES_ON_WRITE", envValue); + const msg = { + role: "user", + content: "x", + }; + expect(maybeStripRelevantMemoriesOnWrite(msg)).toBeNull(); + }, + ); +});