diff --git a/src/hooks/bundled/session-memory/handler.test.ts b/src/hooks/bundled/session-memory/handler.test.ts index fb7e9ca0a4d..b5b5ef3ce13 100644 --- a/src/hooks/bundled/session-memory/handler.test.ts +++ b/src/hooks/bundled/session-memory/handler.test.ts @@ -572,4 +572,81 @@ describe("session-memory hook", () => { expect(memoryContent).toContain("user: Only message 1"); expect(memoryContent).toContain("assistant: Only message 2"); }); + + it("blockSessionSave prevents memory file creation", async () => { + const tempDir = await createCaseWorkspace("block-save"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + const sessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: createMockSessionContent([{ role: "user", content: "secret" }]), + }); + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg: { agents: { defaults: { workspace: tempDir } } } satisfies OpenClawConfig, + previousSessionEntry: { sessionId: "s1", sessionFile }, + }); + event.context.blockSessionSave = true; + + await handler(event); + + const memoryDir = path.join(tempDir, "memory"); + const memoryFiles = await fs.readdir(memoryDir).catch(() => [] as string[]); + expect(memoryFiles.filter((f) => f.endsWith(".md"))).toHaveLength(0); + }); + + it("sessionSaveContent overrides saved content", async () => { + const tempDir = await createCaseWorkspace("custom-content"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + const sessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: createMockSessionContent([{ role: "user", content: "original" }]), + }); + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg: { agents: { defaults: { workspace: tempDir } } } satisfies OpenClawConfig, + previousSessionEntry: { sessionId: "s1", sessionFile }, + }); + event.context.sessionSaveContent = "Custom summary from upstream hook"; + + await handler(event); + + const memoryDir = path.join(tempDir, "memory"); + const files = (await fs.readdir(memoryDir)).filter((f) => f.endsWith(".md")); + expect(files.length).toBeGreaterThan(0); + const content = await fs.readFile(path.join(memoryDir, files[0]), "utf-8"); + expect(content).toContain("Custom summary from upstream hook"); + expect(content).not.toContain("original"); + }); + + it("sessionSaveContent empty string writes blank marker file", async () => { + const tempDir = await createCaseWorkspace("empty-content"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + const sessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: createMockSessionContent([{ role: "user", content: "sensitive data" }]), + }); + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg: { agents: { defaults: { workspace: tempDir } } } satisfies OpenClawConfig, + previousSessionEntry: { sessionId: "s1", sessionFile }, + }); + // Empty string is a valid override — persists a blank marker without + // loading the transcript or generating an LLM slug. + event.context.sessionSaveContent = ""; + + await handler(event); + + const memoryDir = path.join(tempDir, "memory"); + const files = (await fs.readdir(memoryDir)).filter((f) => f.endsWith(".md")); + expect(files.length).toBeGreaterThan(0); + const content = await fs.readFile(path.join(memoryDir, files[0]), "utf-8"); + // Should be truly empty — blank marker file + expect(content).toBe(""); + }); }); diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts index 32fc36b23f0..c6bfa6d0a9a 100644 --- a/src/hooks/bundled/session-memory/handler.ts +++ b/src/hooks/bundled/session-memory/handler.ts @@ -207,6 +207,13 @@ const saveSessionToMemory: HookHandler = async (event) => { log.debug("Hook triggered for reset/new command", { action: event.action }); const context = event.context || {}; + + // Check if another hook (e.g., security plugin) blocked the save. + if (context.blockSessionSave === true) { + log.debug("Session save blocked by upstream hook"); + return; + } + const cfg = context.cfg as OpenClawConfig | undefined; const contextWorkspaceDir = typeof context.workspaceDir === "string" && context.workspaceDir.trim().length > 0 @@ -279,8 +286,9 @@ const saveSessionToMemory: HookHandler = async (event) => { let slug: string | null = null; let sessionContent: string | null = null; + const hasCustomContent = typeof context.sessionSaveContent === "string"; - if (sessionFile) { + if (sessionFile && !hasCustomContent) { // Get recent conversation content, with fallback to rotated reset transcript. sessionContent = await getRecentSessionContentWithResetFallback(sessionFile, messageCount); log.debug("Session content loaded", { @@ -341,7 +349,11 @@ const saveSessionToMemory: HookHandler = async (event) => { entryParts.push("## Conversation Summary", "", sessionContent, ""); } - const entry = entryParts.join("\n"); + // Use custom content from upstream hook if available, otherwise use built entry. + // An empty string is a valid redaction signal — hooks may intentionally + // set it to persist a blank marker while avoiding transcript retention. + const customContent = context.sessionSaveContent; + const entry = typeof customContent === "string" ? customContent : entryParts.join("\n"); // Write under memory root with alias-safe file validation. await writeFileWithinRoot({