Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 67 additions & 35 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<relevant-memories>` 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 = /<relevant-memories>[\s\S]*?<\/relevant-memories>\s*/g;

if (typeof message.content === "string") {
if (!message.content.includes("<relevant-memories>")) 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<Record<string, unknown>>).map((part) => {
if (part.type !== "text" || typeof part.text !== "string") return part;
if (!(part.text as string).includes("<relevant-memories>")) 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]";

/**
Expand Down Expand Up @@ -583,42 +632,25 @@ export default function register(api: OpenClawPluginApi) {
});
}

// Strip <relevant-memories> 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 <relevant-memories>)`);
// Conditionally strip <relevant-memories> 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 <relevant-memories> 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 = /<relevant-memories>[\s\S]*?<\/relevant-memories>\s*/g;

if (typeof msg.content === "string") {
if (!msg.content.includes("<relevant-memories>")) 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<Record<string, unknown>>).map((part) => {
if (part.type !== "text" || typeof part.text !== "string") return part;
if (!(part.text as string).includes("<relevant-memories>")) 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 <relevant-memories> (legacy mode)`,
);
return { message: { ...event.message, ...replacement } as typeof event.message };
});

// After agent end: auto-capture + L0 record + L1/L2/L3 schedule
Expand Down
70 changes: 70 additions & 0 deletions src/__tests__/before-message-write.test.ts
Original file line number Diff line number Diff line change
@@ -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 <relevant-memories>foo</relevant-memories> world",
};
expect(maybeStripRelevantMemoriesOnWrite(msg)).toBeNull();
});

it("strips <relevant-memories> from string content when env=1", () => {
vi.stubEnv("TDAI_STRIP_RELEVANT_MEMORIES_ON_WRITE", "1");
const msg = {
role: "user",
content:
"hello <relevant-memories>foo bar baz</relevant-memories> world",
};
const result = maybeStripRelevantMemoriesOnWrite(msg);
expect(result).not.toBeNull();
expect(result!.content).toBe("hello world");
});

it("strips <relevant-memories> 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 <relevant-memories>x</relevant-memories>" },
{ 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 <relevant-memories>x</relevant-memories>",
};
expect(maybeStripRelevantMemoriesOnWrite(msg)).toBeNull();
});

it("returns null when user message has no <relevant-memories> 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: "<relevant-memories>x</relevant-memories>",
};
expect(maybeStripRelevantMemoriesOnWrite(msg)).toBeNull();
},
);
});