` and ```js\nconst a = 1;\n``` plus
markers";
+ assert.equal(neutralizeSpeakerTagSpoof(text), text);
+ });
+});
+
+describe("trimTranscriptToTagBoundary", () => {
+ it("returns transcripts within the limit unchanged", () => {
+ const transcript = "\nhi\n";
+ assert.equal(trimTranscriptToTagBoundary(transcript, 8000), transcript);
+ });
+
+ it("snaps an over-limit transcript to the next opening tag so no half message leads", () => {
+ const turns = [];
+ for (let i = 0; i < 40; i++) {
+ turns.push({ role: "user", text: `user note number ${i} ${"x".repeat(80)}` });
+ turns.push({ role: "assistant", text: `assistant reply number ${i} ${"y".repeat(80)}` });
+ }
+ const transcript = formatConversationTranscript(turns, "User");
+ const trimmed = trimTranscriptToTagBoundary(transcript, 1500);
+ assert.ok(trimmed.length <= 1500);
+ assert.ok(
+ trimmed.startsWith("") || trimmed.startsWith(""),
+ `trimmed transcript must start at a tag boundary, got: ${trimmed.slice(0, 40)}`,
+ );
+ });
+
+ it("falls back to the raw tail when no tag boundary survives the slice", () => {
+ const untagged = "z".repeat(5000);
+ assert.equal(trimTranscriptToTagBoundary(untagged, 1000), "z".repeat(1000));
+ });
+});
+
+describe("buildConversationTurnsForExtraction", () => {
+ it("skips the already-extracted prefix when new texts are a tail slice of the eligible list", () => {
+ const turns = buildConversationTurnsForExtraction({
+ messageLoopTurns: [
+ { role: "user", text: "old message" },
+ { role: "user", text: "new message" },
+ ],
+ eligibleTexts: ["old message", "new message"],
+ newUserTexts: ["new message"],
+ });
+ assert.deepEqual(turns, [{ role: "user", text: "new message" }]);
+ });
+
+ it("slices role-agnostically when turns align 1:1 with eligible texts (mixed-role eligibility)", () => {
+ const turns = buildConversationTurnsForExtraction({
+ messageLoopTurns: [
+ { role: "user", text: "seen user" },
+ { role: "assistant", text: "seen reply" },
+ { role: "user", text: "fresh user" },
+ ],
+ eligibleTexts: ["seen user", "seen reply", "fresh user"],
+ newUserTexts: ["seen reply", "fresh user"],
+ });
+ assert.deepEqual(turns, [
+ { role: "assistant", text: "seen reply" },
+ { role: "user", text: "fresh user" },
+ ]);
+ });
+
+ it("drops assistant replies together with their already-extracted user pair when counts misalign", () => {
+ const turns = buildConversationTurnsForExtraction({
+ messageLoopTurns: [
+ { role: "user", text: "first question" },
+ { role: "assistant", text: "first answer" },
+ { role: "user", text: "second question" },
+ { role: "assistant", text: "second answer" },
+ ],
+ eligibleTexts: ["first question", "second question"],
+ newUserTexts: ["second question"],
+ });
+ assert.deepEqual(turns, [
+ { role: "user", text: "second question" },
+ { role: "assistant", text: "second answer" },
+ ]);
+ });
+
+ it("falls back to flat user turns for pending-ingress replays with no eligible correlation", () => {
+ const turns = buildConversationTurnsForExtraction({
+ messageLoopTurns: [{ role: "user", text: "history text" }],
+ eligibleTexts: ["history text"],
+ newUserTexts: ["replayed ingress text"],
+ });
+ assert.deepEqual(turns, [{ role: "user", text: "replayed ingress text" }]);
+ });
+});
+
+describe("buildExtractionPrompt speaker teaching", () => {
+ const transcript = formatConversationTranscript(
+ [
+ { role: "user", text: "the deploy window moved to Friday" },
+ { role: "assistant", text: MULTI_PARAGRAPH_REPLY },
+ ],
+ "User",
+ );
+
+ it("teaches the tag format in the system half and embeds the tagged transcript under the conversation header", () => {
+ const { system, user: userPrompt } = buildExtractionPrompt(transcript, "User");
+ assert.ok(system.includes("## Transcript format"), "system must teach the transcript format");
+ assert.ok(system.includes("..."));
+ assert.ok(!system.includes("..."), "default mode carries no assistant-tag teaching (assistant lines are excluded from the transcript)");
+ const conversation = userPrompt.indexOf("## Recent Conversation");
+ assert.ok(conversation >= 0, "user half must carry the conversation header");
+ assert.ok(userPrompt.indexOf(transcript) > conversation, "tagged transcript embeds under the conversation header");
+ assert.ok(userPrompt.includes("Extract memory candidates ONLY from blocks"), "instruction must ride the user half");
+ assert.ok(!(system + userPrompt).includes('"Assistant:" lines'), "legacy prefix vocabulary must be gone");
+ });
+
+ it("omits assistant-block language entirely in the default mode (captureAssistant=false excludes assistant lines from the transcript)", () => {
+ const { system, user } = buildExtractionPrompt(transcript, "User");
+ assert.ok(!system.includes(""));
+ assert.ok(system.includes("Memories may only be grounded here."));
+ assert.ok(!system.includes("also valid sources"));
+ assert.ok(user.includes("Extract memory candidates ONLY from blocks."));
+ });
+
+ it("keeps a real configured name in the prompt header and drops the generic 'User: User' line", () => {
+ const { user: withName } = buildExtractionPrompt(transcript, "Alex");
+ const { user: generic } = buildExtractionPrompt(transcript, "User");
+ assert.ok(withName.startsWith("User: Alex\n\n"));
+ assert.ok(!generic.includes("User: User"));
+ });
+
+ it("teaches the eligible variant when assistantEligible is true, in tag vocabulary", () => {
+ const { system, user } = buildExtractionPrompt(transcript, "User", { assistantEligible: true });
+ assert.ok(system.includes(" blocks: also valid sources"));
+ assert.ok(system.includes("use the version"));
+ assert.ok(system.includes("wraps ONE message written by the AI assistant"));
+ assert.ok(!system.includes("Memories may only be grounded here."));
+ assert.ok(user.includes("attributed to their true speaker"));
+ assert.ok(!user.includes("Extract memory candidates ONLY from blocks."));
+ });
+});
diff --git a/test/memory-reflection.test.mjs b/test/memory-reflection.test.mjs
index 56ad1a88..6f98b79d 100644
--- a/test/memory-reflection.test.mjs
+++ b/test/memory-reflection.test.mjs
@@ -122,10 +122,10 @@ describe("memory reflection", () => {
const conversation = await readSessionConversationWithResetFallback(sessionPath, 10);
assert.ok(conversation);
- assert.match(conversation, /user: Please keep responses concise and factual\./);
- assert.match(conversation, /assistant: Acknowledged\. I will keep responses concise and factual\./);
+ assert.match(conversation, /\nPlease keep responses concise and factual\.\n<\/user_message>/);
+ assert.match(conversation, /\nAcknowledged\. I will keep responses concise and factual\.\n<\/assistant_message>/);
assert.doesNotMatch(conversation, /old reset snapshot/);
- assert.doesNotMatch(conversation, /^user:\s*\/new/m);
+ assert.doesNotMatch(conversation, /\/new/);
});
});
diff --git a/test/pair-window-retention.test.mjs b/test/pair-window-retention.test.mjs
new file mode 100644
index 00000000..49708df8
--- /dev/null
+++ b/test/pair-window-retention.test.mjs
@@ -0,0 +1,304 @@
+/**
+ * Rolling pair-window retention across extractions, sized by
+ * autoCaptureContextTurns.
+ *
+ * Without retention, history-carrying sessions that extract every turn see
+ * only the current pair in each transcript, so the extractor never has the
+ * conversational context to resolve references ("yes exactly, that one").
+ * The rolling window (autoCaptureRecentPairTurns, trimmed by
+ * trimTurnsToUserCap, repaired by dedupePairWindow) keeps the last N user
+ * turns with their interleaved assistant replies in the transcript across
+ * extractions. N = autoCaptureContextTurns: 0 (the default) disables
+ * retention entirely and preserves stock behavior; 1-10 sets the window
+ * size, decoupled from the extractMinMessages warm-up gate.
+ *
+ * Fixtures are entirely synthetic; no real fleet data.
+ */
+
+import { describe, it, beforeEach, afterEach } from "node:test";
+import assert from "node:assert/strict";
+import http from "node:http";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import jitiFactory from "jiti";
+
+const testDir = path.dirname(fileURLToPath(import.meta.url));
+const pluginSdkStubPath = path.resolve(testDir, "helpers", "openclaw-plugin-sdk-stub.mjs");
+const jiti = jitiFactory(import.meta.url, {
+ interopDefault: true,
+ alias: {
+ "openclaw/plugin-sdk": pluginSdkStubPath,
+ },
+});
+
+const pluginModule = jiti("../index.ts");
+const memoryLanceDBProPlugin = pluginModule.default || pluginModule;
+const resetRegistration = pluginModule.resetRegistration ?? (() => {});
+const { NoisePrototypeBank } = jiti("../src/noise-prototypes.ts");
+NoisePrototypeBank.prototype.isNoise = () => false;
+
+const EMBEDDING_DIMENSIONS = 64;
+
+function hashToIndex(text, dims) {
+ let h = 0;
+ for (let i = 0; i < text.length; i++) {
+ h = (h * 31 + text.charCodeAt(i)) >>> 0;
+ }
+ return h % dims;
+}
+
+function oneHot(text) {
+ const v = new Array(EMBEDDING_DIMENSIONS).fill(0);
+ v[hashToIndex(text || "", EMBEDDING_DIMENSIONS)] = 1;
+ return v;
+}
+
+function createEmbeddingServer() {
+ return http.createServer(async (req, res) => {
+ const chunks = [];
+ for await (const chunk of req) chunks.push(chunk);
+ const payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
+ const inputs = Array.isArray(payload.input) ? payload.input : [payload.input];
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({
+ object: "list",
+ data: inputs.map((input, index) => ({ object: "embedding", index, embedding: oneHot(String(input)) })),
+ model: payload.model || "mock-embedding-model",
+ usage: { prompt_tokens: 0, total_tokens: 0 },
+ }));
+ });
+}
+
+function createLlmServer(extractionPrompts) {
+ let calls = 0;
+ return http.createServer(async (req, res) => {
+ const chunks = [];
+ for await (const chunk of req) chunks.push(chunk);
+ const payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
+ const prompt = String(payload.messages?.map((m) => m.content).join("\n") ?? "");
+ if (prompt.includes("## Recent Conversation")) {
+ extractionPrompts.push(prompt);
+ }
+ calls += 1;
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({
+ id: "chatcmpl-test",
+ object: "chat.completion",
+ created: 1,
+ model: "mock-memory-model",
+ choices: [{
+ index: 0,
+ finish_reason: "stop",
+ message: {
+ role: "assistant",
+ content: JSON.stringify({
+ memories: [{
+ category: "preferences",
+ abstract: `Synthetic retention marker number ${calls}`,
+ overview: `## Preference\n- Marker ${calls}`,
+ content: `User stated synthetic retention marker number ${calls}.`,
+ }],
+ }),
+ },
+ }],
+ }));
+ });
+}
+
+function createPluginApiHarness({ pluginConfig, resolveRoot }) {
+ const eventHandlers = new Map();
+ const logs = { info: [], warn: [], debug: [] };
+ const api = {
+ pluginConfig,
+ resolvePath(target) {
+ if (typeof target !== "string") return target;
+ if (path.isAbsolute(target)) return target;
+ return path.join(resolveRoot, target);
+ },
+ logger: {
+ info(message) { logs.info.push(String(message)); },
+ warn(message) { logs.warn.push(String(message)); },
+ debug(message) { logs.debug.push(String(message)); },
+ },
+ registerTool() {},
+ registerCli() {},
+ registerService() {},
+ on(eventName, handler, meta) {
+ const list = eventHandlers.get(eventName) || [];
+ list.push({ handler, meta });
+ eventHandlers.set(eventName, list);
+ },
+ registerHook(eventName, handler, opts) {
+ const list = eventHandlers.get(eventName) || [];
+ list.push({ handler, meta: opts });
+ eventHandlers.set(eventName, list);
+ },
+ };
+ return { api, eventHandlers, logs };
+}
+
+function getAutoCaptureHook(eventHandlers) {
+ const hooks = eventHandlers.get("agent_end") || [];
+ assert.ok(hooks.length >= 1, "expected at least one agent_end handler");
+ return hooks[0].handler;
+}
+
+async function fireAgentEnd(hook, messages, ctx) {
+ hook({ success: true, messages }, ctx);
+ const run = hook.__lastRun;
+ assert.ok(run && typeof run.then === "function", "expected a background capture run");
+ await run;
+}
+
+const U1 = "synthetic retention fact alpha about the quartz drawer";
+const A1 = "noted, quartz drawer it is";
+const U2 = "synthetic retention fact beta about the basalt shelf";
+const A2 = "got it, basalt shelf recorded";
+const U3 = "synthetic retention fact gamma about the marble crate";
+const A3 = "marble crate, understood";
+const U4 = "synthetic retention fact delta about the granite bin";
+
+function turnMessages(count) {
+ const all = [
+ { role: "user", content: U1 },
+ { role: "assistant", content: A1 },
+ { role: "user", content: U2 },
+ { role: "assistant", content: A2 },
+ { role: "user", content: U3 },
+ { role: "assistant", content: A3 },
+ { role: "user", content: U4 },
+ ];
+ return all.slice(0, count);
+}
+
+describe("pair-window retention across successful extractions", () => {
+ let workspaceDir;
+ let embeddingServer;
+ let llmServer;
+ let extractionPrompts;
+ let hook;
+ let basePluginConfig;
+
+ beforeEach(async () => {
+ resetRegistration();
+ workspaceDir = mkdtempSync(path.join(tmpdir(), "pair-window-retention-"));
+ extractionPrompts = [];
+ embeddingServer = createEmbeddingServer();
+ llmServer = createLlmServer(extractionPrompts);
+ await new Promise((resolve) => embeddingServer.listen(0, "127.0.0.1", resolve));
+ await new Promise((resolve) => llmServer.listen(0, "127.0.0.1", resolve));
+
+ basePluginConfig = {
+ dbPath: path.join(workspaceDir, "memory-db"),
+ autoCapture: true,
+ autoRecall: false,
+ smartExtraction: true,
+ extractMinMessages: 2,
+ autoCaptureContextTurns: 2,
+ extractionThrottle: { skipLowValue: false, maxExtractionsPerHour: 200 },
+ sessionCompression: { enabled: false },
+ selfImprovement: { enabled: false, beforeResetNote: false, ensureLearningFiles: false },
+ embedding: {
+ apiKey: "test-key",
+ model: "mock-embedding-model",
+ baseURL: `http://127.0.0.1:${embeddingServer.address().port}/v1`,
+ dimensions: EMBEDDING_DIMENSIONS,
+ },
+ llm: {
+ apiKey: "test-key",
+ model: "mock-memory-model",
+ baseURL: `http://127.0.0.1:${llmServer.address().port}`,
+ },
+ };
+ const harness = createPluginApiHarness({ pluginConfig: basePluginConfig, resolveRoot: workspaceDir });
+ memoryLanceDBProPlugin.register(harness.api);
+ hook = getAutoCaptureHook(harness.eventHandlers);
+ });
+
+ function registerFresh(overrides) {
+ resetRegistration();
+ const harness = createPluginApiHarness({
+ pluginConfig: { ...basePluginConfig, ...overrides },
+ resolveRoot: workspaceDir,
+ });
+ memoryLanceDBProPlugin.register(harness.api);
+ return getAutoCaptureHook(harness.eventHandlers);
+ }
+
+ afterEach(async () => {
+ await new Promise((resolve) => embeddingServer.close(resolve));
+ await new Promise((resolve) => llmServer.close(resolve));
+ rmSync(workspaceDir, { recursive: true, force: true });
+ });
+
+ it("carries the previous pair into the next extraction after a SUCCESSFUL extraction", async () => {
+ const ctx = { sessionKey: "agent:test-agent:main", agentId: "test-agent" };
+
+ await fireAgentEnd(hook, turnMessages(4), ctx);
+ assert.equal(extractionPrompts.length, 1, "turn 1 should extract (cumulative=2 >= min=2)");
+ assert.ok(extractionPrompts[0].includes(U1) && extractionPrompts[0].includes(U2));
+
+ await fireAgentEnd(hook, turnMessages(6), ctx);
+ assert.equal(extractionPrompts.length, 2, "turn 2 should extract (delta past warm-up)");
+ assert.ok(extractionPrompts[1].includes(U3), "turn 2 must carry its own new user turn");
+ assert.ok(
+ extractionPrompts[1].includes(U2),
+ "turn 2 must retain the previous pair as context — a successful extraction may not wipe the rolling window",
+ );
+ assert.ok(
+ !extractionPrompts[1].includes(U1),
+ "the window stays trimmed to the configured cap (2 user turns), so the oldest pair drops",
+ );
+ });
+
+ it("keeps the window bounded across repeated successful extractions", async () => {
+ const ctx = { sessionKey: "agent:test-agent:main", agentId: "test-agent" };
+
+ await fireAgentEnd(hook, turnMessages(4), ctx);
+ await fireAgentEnd(hook, turnMessages(6), ctx);
+ await fireAgentEnd(hook, turnMessages(7), ctx);
+
+ assert.equal(extractionPrompts.length, 3, "all three turns should extract");
+ const third = extractionPrompts[2];
+ assert.ok(third.includes(U4), "turn 3 carries its new user turn");
+ assert.ok(third.includes(U3), "turn 3 retains the immediately previous user turn");
+ assert.ok(!third.includes(U2), "the cap keeps the window at 2 user turns");
+ assert.ok(!third.includes(U1), "long-dropped pairs never resurface");
+ });
+
+ it("retains nothing between calls when autoCaptureContextTurns is 0", async () => {
+ const zeroHook = registerFresh({
+ autoCaptureContextTurns: 0,
+ dbPath: path.join(workspaceDir, "memory-db-zero"),
+ });
+ const ctx = { sessionKey: "agent:test-agent:main", agentId: "test-agent" };
+
+ await fireAgentEnd(zeroHook, turnMessages(4), ctx);
+ assert.equal(extractionPrompts.length, 1, "turn 1 should extract");
+ await fireAgentEnd(zeroHook, turnMessages(6), ctx);
+ assert.equal(extractionPrompts.length, 2, "turn 2 should extract");
+ const second = extractionPrompts[1];
+ assert.ok(second.includes(U3), "the call's own new turn is present");
+ assert.ok(
+ !second.includes(U2) && !second.includes(U1),
+ "a disabled window may not carry prior pairs into the next extraction",
+ );
+ });
+
+ it("defaults to disabled when the knob is absent (upstream behavior preserved)", async () => {
+ const defaultHook = registerFresh({
+ autoCaptureContextTurns: undefined,
+ dbPath: path.join(workspaceDir, "memory-db-default"),
+ });
+ const ctx = { sessionKey: "agent:test-agent:main", agentId: "test-agent" };
+
+ await fireAgentEnd(defaultHook, turnMessages(4), ctx);
+ await fireAgentEnd(defaultHook, turnMessages(6), ctx);
+ assert.equal(extractionPrompts.length, 2);
+ const second = extractionPrompts[1];
+ assert.ok(second.includes(U3));
+ assert.ok(!second.includes(U2) && !second.includes(U1), "absent knob means no retained context");
+ });
+});
diff --git a/test/plugin-manifest-regression.mjs b/test/plugin-manifest-regression.mjs
index e22af0b4..590034a2 100644
--- a/test/plugin-manifest-regression.mjs
+++ b/test/plugin-manifest-regression.mjs
@@ -66,6 +66,7 @@ function createMockApi(pluginConfig, options = {}) {
for (const key of [
"smartExtraction",
"extractMinMessages",
+ "autoCaptureContextTurns",
"extractMaxChars",
"llm",
"autoRecallMaxItems",
@@ -102,6 +103,11 @@ assert.equal(
8,
"autoRecallMinRepeated schema default should be conservative",
);
+assert.equal(
+ manifest.configSchema.properties.autoCaptureContextTurns?.default,
+ 0,
+ "autoCaptureContextTurns defaults to 0 (context retention disabled upstream)",
+);
assert.equal(
manifest.configSchema.properties.extractMinMessages.default,
4,
diff --git a/test/reflection-tagged-input.test.mjs b/test/reflection-tagged-input.test.mjs
new file mode 100644
index 00000000..93f97d92
--- /dev/null
+++ b/test/reflection-tagged-input.test.mjs
@@ -0,0 +1,104 @@
+/**
+ * Tag-structured reflection distiller input.
+ *
+ * The distiller's INPUT block used to render the session as `role: text`
+ * lines inside a code fence. Any code block inside the conversation
+ * terminated that fence early and leaked the rest of the transcript out of
+ * the input frame, and mid-message clipping could open the INPUT with a
+ * headless half message. Session messages now render as /
+ * blocks (the extraction lane's transcript grammar),
+ * unfenced, with clipping snapped to whole tagged blocks.
+ *
+ * Stored session-summary rows keep the legacy labeled `role: text` shape via
+ * an explicit format switch: a stored row must never carry literal speaker
+ * tags that a later recall could replay into a prompt as fake transcript
+ * structure.
+ *
+ * Fixtures are synthetic.
+ */
+
+import { describe, it, beforeEach, afterEach } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import jitiFactory from "jiti";
+
+const jiti = jitiFactory(import.meta.url, { interopDefault: true });
+const { buildReflectionPrompt, readSessionConversationWithResetFallback } = jiti("../index.ts");
+
+const TAGGED_CONVERSATION =
+ "\nhello there\n\n\nhi, noted\n";
+
+describe("buildReflectionPrompt tagged INPUT", () => {
+ it("teaches the tag grammar up front", () => {
+ const prompt = buildReflectionPrompt(TAGGED_CONVERSATION, 4000, []);
+ assert.ok(prompt.includes("The INPUT transcript is a sequence of tagged blocks in chronological order:"));
+ assert.ok(prompt.includes("- ... wraps ONE message written by the human user."));
+ assert.ok(prompt.includes("- ... wraps ONE message written by the AI assistant."));
+ });
+
+ it("carries the transcript unfenced at the tail (a fence would break on any code block inside the conversation)", () => {
+ const prompt = buildReflectionPrompt(TAGGED_CONVERSATION, 4000, []);
+ assert.ok(prompt.endsWith(`INPUT:\n${TAGGED_CONVERSATION}`), "the tagged transcript must ride unfenced at the tail");
+ assert.ok(!prompt.includes("INPUT:\n```"), "no code fence may wrap the transcript");
+ });
+
+ it("keeps a fenced code block INSIDE a message intact within its tags", () => {
+ const withCode =
+ "\nhere is my snippet:\n```js\nconst a = 1;\n```\ndoes it look right?\n";
+ const prompt = buildReflectionPrompt(withCode, 4000, []);
+ assert.ok(prompt.endsWith("does it look right?\n"));
+ assert.ok(prompt.includes("```js\nconst a = 1;\n```"), "inner fences ride safely inside the tags");
+ });
+
+ it("snaps an over-limit clip to the next whole tagged block, never a headless half message", () => {
+ const transcript =
+ `\n${"a".repeat(120)}\n\n\nkeep this tail reply\n`;
+ const prompt = buildReflectionPrompt(transcript, 70, []);
+ assert.ok(prompt.includes("INPUT:\n"), "the clipped transcript must open at a block boundary");
+ assert.ok(!prompt.includes("aaaa"), "the sliced-away user block must not bleed in headless");
+ });
+});
+
+describe("session conversation formats", () => {
+ let workDir;
+
+ beforeEach(() => {
+ workDir = mkdtempSync(path.join(tmpdir(), "reflection-tagged-"));
+ });
+
+ afterEach(() => {
+ rmSync(workDir, { recursive: true, force: true });
+ });
+
+ function writeSessionFile(name = "session.jsonl") {
+ const sessionPath = path.join(workDir, name);
+ const lines = [
+ { type: "message", message: { role: "user", content: "I switched the standup to Tuesdays" } },
+ { type: "message", message: { role: "assistant", content: "Noted: standup moves to Tuesdays." } },
+ ];
+ writeFileSync(sessionPath, lines.map((line) => JSON.stringify(line)).join("\n"));
+ return sessionPath;
+ }
+
+ it("renders the distiller input as tagged blocks by default", async () => {
+ const sessionPath = writeSessionFile();
+ const conversation = await readSessionConversationWithResetFallback(sessionPath, 10);
+ assert.equal(
+ conversation,
+ "\nI switched the standup to Tuesdays\n\n"
+ + "\nNoted: standup moves to Tuesdays.\n",
+ );
+ });
+
+ it("keeps the labeled role-colon shape for stored artifacts via the explicit format switch", async () => {
+ const sessionPath = writeSessionFile();
+ const conversation = await readSessionConversationWithResetFallback(sessionPath, 10, "labeled");
+ assert.equal(
+ conversation,
+ "user: I switched the standup to Tuesdays\nassistant: Noted: standup moves to Tuesdays.",
+ );
+ assert.ok(!conversation.includes(""), "stored artifacts must never carry literal speaker tags");
+ });
+});
diff --git a/test/smart-extractor-branches.mjs b/test/smart-extractor-branches.mjs
index 2a9d7ac9..8c080f28 100644
--- a/test/smart-extractor-branches.mjs
+++ b/test/smart-extractor-branches.mjs
@@ -241,7 +241,7 @@ async function runScenario(mode) {
llmCalls += 1;
let content;
- if (prompt.includes("Analyze the following session context")) {
+ if (prompt.includes("## Recent Conversation")) {
content = JSON.stringify({
memories: [
{
@@ -377,7 +377,7 @@ async function runMultiRoundScenario() {
const prompt = payload.messages?.[1]?.content || "";
let content;
- if (prompt.includes("Analyze the following session context")) {
+ if (prompt.includes("## Recent Conversation")) {
extractionCall += 1;
if (extractionCall === 1) {
content = JSON.stringify({
@@ -1023,7 +1023,7 @@ async function runUserMdExclusiveProfileScenario() {
const prompt = payload.messages?.[1]?.content || "";
let content = JSON.stringify({ memories: [] });
- if (prompt.includes("Analyze the following session context")) {
+ if (prompt.includes("## Recent Conversation")) {
content = JSON.stringify({
memories: [
{
@@ -1121,7 +1121,7 @@ async function runBoundarySkipKeepsRegexFallbackScenario() {
const prompt = payload.messages?.[1]?.content || "";
let content = JSON.stringify({ memories: [] });
- if (prompt.includes("Analyze the following session context")) {
+ if (prompt.includes("## Recent Conversation")) {
content = JSON.stringify({
memories: [
{
@@ -1223,7 +1223,7 @@ async function runInboundMetadataCleanupScenario() {
llmCalls += 1;
let content;
- if (prompt.includes("Analyze the following session context")) {
+ if (prompt.includes("## Recent Conversation")) {
extractionPrompt = prompt;
content = JSON.stringify({
memories: [
@@ -1593,7 +1593,7 @@ async function runDedupDecisionLLMCallScenario() {
const payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
const prompt = payload.messages?.[1]?.content || "";
- if (prompt.includes("Analyze the following session context")) {
+ if (prompt.includes("## Recent Conversation")) {
extractCalls += 1;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
diff --git a/test/temporal-facts.test.mjs b/test/temporal-facts.test.mjs
index 970a599d..59b9f5ba 100644
--- a/test/temporal-facts.test.mjs
+++ b/test/temporal-facts.test.mjs
@@ -88,7 +88,7 @@ async function runTest() {
const prompt = payload.messages?.[1]?.content || "";
let content = JSON.stringify({ memories: [] });
- if (prompt.includes("Analyze the following session context")) {
+ if (prompt.includes("## Recent Conversation")) {
content = JSON.stringify({
memories: [{
category: "preferences",