diff --git a/docs/automation/hooks/bundled-hooks.md b/docs/automation/hooks/bundled-hooks.md index 1ce04707c29a..7d75426ad782 100644 --- a/docs/automation/hooks/bundled-hooks.md +++ b/docs/automation/hooks/bundled-hooks.md @@ -133,7 +133,8 @@ command source or automatic reset reason. The hook captures the departing conversation before a reset closes its active window, then writes the snapshot in the background. Capture is bounded to -4,096 scanned messages and 8 MiB. +4,096 scanned messages and 8 MiB. Incognito sessions do not create memory +artifacts, including on manual or automatic reset. Manual resets do not await the file write or optional slug-model call; automatic reset dispatch also runs independently of the successor turn. Wait for `Session context saved to ...` in logs before expecting the file. diff --git a/docs/plugins/memory-lancedb.md b/docs/plugins/memory-lancedb.md index e51692c57efe..183aa02ae681 100644 --- a/docs/plugins/memory-lancedb.md +++ b/docs/plugins/memory-lancedb.md @@ -253,6 +253,10 @@ entry, or one inheriting a disabled top-level search, also gets none of the `mem or `memory_forget` tools and does not participate in automatic recall or capture, even when the plugin-level `autoRecall`/`autoCapture` flags are on. +Incognito sessions skip automatic recall and capture. Their prompts are not +sent to the embedding provider for automatic recall, and `memory_store` refuses +to save them. Explicit tool calls still follow their normal data-handling rules. + ## Commands `memory-lancedb` registers the `ltm` CLI namespace whenever it is installed diff --git a/docs/tools/self-learning.md b/docs/tools/self-learning.md index 7ac3bbe61c1c..03d00731b9f1 100644 --- a/docs/tools/self-learning.md +++ b/docs/tools/self-learning.md @@ -56,8 +56,8 @@ Experience review starts only when all of these conditions hold: - the foreground turn completed or was interrupted, but did not end in a provider or prompt error. - the current turn used at least 10 model iterations. -- the run was an eligible foreground conversation, not cron, heartbeat, memory, - overflow, hook, subagent, or review work. +- the run was an eligible foreground conversation, not Incognito, cron, + heartbeat, memory, overflow, hook, subagent, or review work. - the runtime reported the resolved provider, model, and actual availability of `skill_workshop`. - the system has been quiet for 30 seconds. diff --git a/docs/tools/skill-workshop/configuration.md b/docs/tools/skill-workshop/configuration.md index 9b30c8b7137d..03b087612628 100644 --- a/docs/tools/skill-workshop/configuration.md +++ b/docs/tools/skill-workshop/configuration.md @@ -37,6 +37,7 @@ In `propose` and `auto` modes, OpenClaw can review one finished substantial turn after the agent system becomes idle. It records the finished turn's boundary and reads that turn's model context asynchronously with the same provider and model. Review transcript and session metadata stay detached from foreground work. +Incognito turns are excluded from automatic experience review. In `propose` mode, only `skill_workshop` executes and the reviewer can stage one pending mutation. In `auto` mode, ordinary file tools can inspect, edit, and verify several connected files in the Workshop directory. The review inherits diff --git a/extensions/memory-lancedb/auto-recall.test.ts b/extensions/memory-lancedb/auto-recall.test.ts new file mode 100644 index 000000000000..546a9cabf466 --- /dev/null +++ b/extensions/memory-lancedb/auto-recall.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test, vi } from "vitest"; +import { createAutoRecallHook } from "./auto-recall.js"; +import { MemoryDB } from "./lancedb-store.js"; + +describe("automatic memory recall privacy", () => { + test.each([ + { sessionKey: "agent:main:dashboard:incognito-recall", expectedCalls: 0 }, + { sessionKey: "agent:main:dashboard:ordinary-recall", expectedCalls: 1 }, + ])("limits embedding and search for $sessionKey", async ({ sessionKey, expectedCalls }) => { + const db = new MemoryDB("unused-auto-recall-test-db", 3); + const search = vi.spyOn(db, "search").mockResolvedValue([]); + const embed = vi.fn(async () => [0.1, 0.2, 0.3]); + const hook = createAutoRecallHook({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + db, + embeddings: { embed }, + resolveCurrentConfig: () => ({ + embedding: { provider: "openai", model: "synthetic-test-embedding" }, + autoRecall: true, + captureMaxChars: 500, + recallMaxChars: 1000, + }), + resolveEnabledAgentId: (agentId) => agentId, + readCooldown: () => undefined, + recordCooldown: vi.fn(), + }); + + await expect( + hook( + { prompt: "SYNTHETIC_INCOGNITO_EMBEDDING_SENTINEL", messages: [] }, + { + agentId: "main", + sessionKey, + toolAuthority: { + allows: (toolName) => toolName === "memory_recall", + assertActive: () => undefined, + }, + }, + ), + ).resolves.toBeUndefined(); + expect(embed).toHaveBeenCalledTimes(expectedCalls); + expect(search).toHaveBeenCalledTimes(expectedCalls); + }); +}); diff --git a/extensions/memory-lancedb/auto-recall.ts b/extensions/memory-lancedb/auto-recall.ts index af5c77eb5fec..ca2101781439 100644 --- a/extensions/memory-lancedb/auto-recall.ts +++ b/extensions/memory-lancedb/auto-recall.ts @@ -1,4 +1,5 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { isIncognitoSessionKey } from "openclaw/plugin-sdk/routing"; import type { OpenClawPluginApi } from "./api.js"; import type { MemoryConfig } from "./config.js"; import { @@ -26,6 +27,7 @@ type AutoRecallToolAuthority = { type AutoRecallHookContext = { agentId?: string; + sessionKey?: string; toolAuthority?: AutoRecallToolAuthority; }; @@ -44,6 +46,9 @@ export function createAutoRecallHook(params: { recordCooldown: (agentId: string, error: string) => void; }) { return async (event: AutoRecallHookEvent, ctx: AutoRecallHookContext) => { + if (isIncognitoSessionKey(ctx.sessionKey)) { + return undefined; + } const currentCfg = params.resolveCurrentConfig(); const recallMaxChars = currentCfg.recallMaxChars; if (!currentCfg.autoRecall) { diff --git a/src/hooks/bundled/session-memory/HOOK.md b/src/hooks/bundled/session-memory/HOOK.md index 12616ab59480..a985cc339b2d 100644 --- a/src/hooks/bundled/session-memory/HOOK.md +++ b/src/hooks/bundled/session-memory/HOOK.md @@ -18,6 +18,8 @@ metadata: Automatically saves session context to workspace memory on `/new`, `/reset`, daily reset, or idle expiry. +Incognito sessions are excluded and never create these memory files. + ## What It Does When a manual or automatic reset starts a fresh session: diff --git a/src/hooks/bundled/session-memory/handler.test.ts b/src/hooks/bundled/session-memory/handler.test.ts index 2a80aa030064..7728f40364f9 100644 --- a/src/hooks/bundled/session-memory/handler.test.ts +++ b/src/hooks/bundled/session-memory/handler.test.ts @@ -17,7 +17,12 @@ import { import { parseAgentSessionKey } from "../../../routing/session-key.js"; import { writeWorkspaceFile } from "../../../test-helpers/workspace.js"; import { withEnvAsync } from "../../../test-utils/env.js"; -import { createInternalHookEvent as createHookEvent } from "../../internal-hooks.js"; +import { + createInternalHookEvent as createHookEvent, + registerInternalHook, + triggerInternalHook, + unregisterInternalHook, +} from "../../internal-hooks.js"; import { generateSlugViaLLM } from "../../llm-slug-generator.js"; // Avoid calling the embedded OpenClaw agent (global command lane); keep this unit test deterministic. @@ -241,6 +246,35 @@ async function expectPathMissing(targetPath: string): Promise { } describe("session-memory hook", () => { + it.each([ + { type: "command", action: "new", sessionKey: "agent:main:dashboard:incognito-new" }, + { type: "command", action: "reset", sessionKey: "agent:main:dashboard:incognito-reset" }, + { type: "session", action: "auto-reset", sessionKey: "agent:main:dashboard:incognito-idle" }, + { type: "command", action: "reset", sessionKey: "agent:main:private", incognito: true }, + ] as const)("does not capture Incognito $type:$action memory ($sessionKey)", async (testCase) => { + const workspaceDir = await createCaseWorkspace("incognito"); + const event = createHookEvent(testCase.type, testCase.action, testCase.sessionKey, { + agentId: "main", + workspaceDir, + sessionEntry: { sessionId: "private-session", incognito: "incognito" in testCase }, + previousSessionMemory: { + status: "available", + content: "SYNTHETIC_INCOGNITO_MEMORY_SENTINEL", + originClass: "agent", + }, + reason: "idle", + }); + const eventKey = `${testCase.type}:${testCase.action}`; + registerInternalHook(eventKey, handler); + try { + await triggerInternalHook(event); + await flushSessionMemoryWritesForTest(); + await expectPathMissing(path.join(workspaceDir, "memory")); + } finally { + unregisterInternalHook(eventKey, handler); + } + }); + it("skips non-command events", async () => { const tempDir = await createCaseWorkspace("workspace"); diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts index ee41d9062cd5..ec1099ba1f6e 100644 --- a/src/hooks/bundled/session-memory/handler.ts +++ b/src/hooks/bundled/session-memory/handler.ts @@ -22,7 +22,11 @@ import { isVitestRuntimeEnv } from "../../../infra/env.js"; import { root } from "../../../infra/fs-safe.js"; import { createSubsystemLogger } from "../../../logging/subsystem.js"; import { runWithGatewayIndependentRootWorkContinuation } from "../../../process/gateway-work-admission.js"; -import { parseAgentSessionKey, toAgentStoreSessionKey } from "../../../routing/session-key.js"; +import { + isIncognitoSessionKey, + parseAgentSessionKey, + toAgentStoreSessionKey, +} from "../../../routing/session-key.js"; import { shortenHomePath } from "../../../utils.js"; import { resolveHookConfig } from "../../config.js"; import type { HookHandler } from "../../hooks.js"; @@ -299,14 +303,18 @@ const saveSessionToMemory: HookHandler = (event) => { if ((event.type !== "command" || !isResetCommand) && !isAutoReset) { return undefined; } - const agentId = requireSessionMemoryAgentId(event); - const context = event.context; const sessionEntry = ( event.type === "command" ? (context.previousSessionEntry ?? context.sessionEntry) : context.sessionEntry - ) as { sessionId?: string } | undefined; + ) as { sessionId?: string; incognito?: boolean } | undefined; + // Reset hooks run before the process-local session is retired. Never turn its + // live transcript or a previously captured excerpt into durable workspace memory. + if (isIncognitoSessionKey(event.sessionKey) || sessionEntry?.incognito === true) { + return undefined; + } + const agentId = requireSessionMemoryAgentId(event); const cfg = context.cfg as OpenClawConfig | undefined; // Gateway and soft-reset hooks already run before mutation; chat resets carry // the snapshot captured by session initialization before closing the window. diff --git a/src/skills/workshop/experience-review-scheduler.ts b/src/skills/workshop/experience-review-scheduler.ts index 315834de652b..a4ed73be1130 100644 --- a/src/skills/workshop/experience-review-scheduler.ts +++ b/src/skills/workshop/experience-review-scheduler.ts @@ -5,6 +5,7 @@ import type { TranscriptEntryAnchor } from "../../config/sessions/transcript-ent import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { runOutsidePluginRuntimeGenerationScope } from "../../plugins/runtime/generation-scope.js"; +import { isIncognitoSessionKey } from "../../routing/session-key.js"; import type { RunSkillUsage } from "../runtime/run-usage.js"; import { resolveSkillWorkshopConfig } from "./config.js"; import { @@ -159,7 +160,11 @@ export function createSkillExperienceReviewScheduler(deps: ExperienceReviewSched return { schedule(params: SkillExperienceReviewParams): void { const sessionKey = params.ctx.sessionKey?.trim(); - if (!sessionKey) { + if ( + !sessionKey || + isIncognitoSessionKey(sessionKey) || + isIncognitoSessionKey(params.source?.sessionKey) + ) { return; } // Unqualified keys such as global still belong to one foreground agent. diff --git a/src/skills/workshop/experience-review.test.ts b/src/skills/workshop/experience-review.test.ts index 74f321453401..f1a7f375aef3 100644 --- a/src/skills/workshop/experience-review.test.ts +++ b/src/skills/workshop/experience-review.test.ts @@ -121,6 +121,29 @@ afterEach(() => { }); describe("skill experience review scheduler", () => { + it.each(["context", "source"] as const)( + "does not retain or schedule Incognito %s evidence", + async (identity) => { + vi.useFakeTimers(); + const runReview = vi.fn(async () => {}); + const scheduler = createSkillExperienceReviewScheduler({ + isSystemActive: () => false, + runReview, + }); + const params = completedRun(); + const sessionKey = "agent:main:dashboard:incognito-workshop"; + if (identity === "context") { + params.ctx = { ...params.ctx, sessionKey }; + } else { + params.source = { ...params.source!, sessionKey }; + } + scheduler.schedule(params); + expect(vi.getTimerCount()).toBe(0); + await vi.runAllTimersAsync(); + expect(runReview).not.toHaveBeenCalled(); + }, + ); + it("runs detached review work outside the foreground prepared generation", async () => { const generation: PreparedModelRuntimePluginGeneration = { configuredCatalogEntries: [], @@ -655,6 +678,13 @@ describe("skill experience review prompt", () => { }); describe("skill experience review preparation", () => { + it("rejects Incognito evidence before preparing a queued review", async () => { + const params = completedRun({ sessionKey: "agent:main:dashboard:incognito-workshop" }); + await expect( + prepareSkillExperienceReviewCandidate(captureCandidate(params), params.config), + ).resolves.toBeUndefined(); + }); + it.each([ { agentId: "direct", eligible: true }, { agentId: "isolated", eligible: false }, diff --git a/src/skills/workshop/experience-review.ts b/src/skills/workshop/experience-review.ts index a4be3cee04ad..9685474fb999 100644 --- a/src/skills/workshop/experience-review.ts +++ b/src/skills/workshop/experience-review.ts @@ -22,6 +22,7 @@ import { getGatewayRestartDrainSignal, runWithGatewayDetachedWorkAdmission, } from "../../process/gateway-work-admission.js"; +import { isIncognitoSessionKey } from "../../routing/session-key.js"; import { bumpSkillsSnapshotVersion } from "../runtime/refresh-state.js"; import { recordSkillExperienceReviewOutcome } from "./collection-review-state.js"; import { resolveSkillWorkshopConfig } from "./config.js"; @@ -37,7 +38,10 @@ export async function prepareSkillExperienceReviewCandidate( candidate: ExperienceReviewCandidate, config: OpenClawConfig, ): Promise { - if (resolveSkillWorkshopConfig(config).autonomous.mode === "off") { + if ( + isIncognitoSessionKey(candidate.source.sessionKey) || + resolveSkillWorkshopConfig(config).autonomous.mode === "off" + ) { return undefined; } const { resolveConversationCapabilityProfile } =