From 4b3ee5e7eb623368d367a5fc2fa3712bc8095f16 Mon Sep 17 00:00:00 2001 From: Dave Morin Date: Sat, 18 Jul 2026 01:21:43 -0600 Subject: [PATCH] feat: let agents remember across private conversations (#100140) * feat(memory): remember across private conversations Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com> * chore(docs): regenerate config baseline * fix(memory): restore recall configuration wiring * fix(memory): scope recall transcript indexing * test(memory): repair conversation recall fixtures * test(memory): split session visibility coverage * style(memory): format type imports --------- Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Co-authored-by: Peter Steinberger --- docs/.generated/config-baseline.sha256 | 8 +- docs/concepts/active-memory.md | 178 ++- docs/concepts/session.md | 21 + docs/docs_map.md | 7 +- docs/plugins/memory-lancedb.md | 9 + docs/reference/memory-config.md | 87 +- extensions/active-memory/index.test.ts | 325 +++- extensions/active-memory/index.ts | 127 +- extensions/active-memory/openclaw.plugin.json | 10 +- extensions/active-memory/query.ts | 8 +- extensions/active-memory/recall-run.ts | 16 +- extensions/active-memory/recall.ts | 33 +- extensions/active-memory/session-policy.ts | 56 +- extensions/active-memory/types.ts | 3 + extensions/memory-core/index.ts | 2 + .../src/memory-tool-manager.test-mocks.ts | 2 + .../memory-core/src/memory/index.test.ts | 43 + extensions/memory-core/src/memory/manager.ts | 5 +- .../src/memory/qmd-document-resolver.ts | 11 + .../src/memory/qmd-manager.test.ts | 88 ++ .../memory-core/src/memory/qmd-manager.ts | 16 +- ...sion-search-visibility.cross-agent.test.ts | 322 ++++ .../src/session-search-visibility.qmd.test.ts | 527 +++++++ .../src/session-search-visibility.test.ts | 1381 +++++++++-------- .../src/session-search-visibility.ts | 207 ++- .../memory-core/src/tools.test-helpers.ts | 3 + extensions/memory-core/src/tools.test.ts | 254 ++- extensions/memory-core/src/tools.ts | 67 +- .../mock-openai/mock-openai-assistant-text.ts | 5 +- .../mock-openai/mock-openai-tooling.ts | 6 + .../src/providers/mock-openai/server.test.ts | 102 ++ .../src/providers/mock-openai/server.ts | 10 +- .../qa-lab/src/scenario-catalog.test.ts | 28 + .../src/host/backend-config.test.ts | 84 + .../src/host/backend-config.ts | 30 +- .../memory-host-sdk/src/host/config-utils.ts | 5 +- .../memory/remember-across-conversations.yaml | 526 +++++++ ...tools.create-openclaw-coding-tools.test.ts | 24 + src/agents/agent-tools.ts | 5 + src/agents/conversation-recall.types.ts | 8 + .../run.overflow-compaction.test.ts | 7 + .../run/attempt-tool-base-prepare.ts | 1 + .../embedded-agent-runner/run/params.ts | 3 + .../run/run-attempt-dispatch.ts | 1 + src/agents/memory-search.test.ts | 71 + src/agents/memory-search.ts | 22 +- .../openclaw-tools.plugin-context.test.ts | 16 + src/agents/openclaw-tools.plugin-context.ts | 3 + src/agents/openclaw-tools.ts | 3 + src/commands/doctor-memory-search.test.ts | 190 +++ src/commands/doctor-memory-search.ts | 124 +- src/config/schema.help.models.ts | 2 + src/config/schema.labels.ts | 1 + src/config/types.tools.ts | 2 + src/config/zod-schema.agent-runtime.ts | 1 + src/plugins/tool-types.ts | 3 + 56 files changed, 4275 insertions(+), 824 deletions(-) create mode 100644 extensions/memory-core/src/session-search-visibility.cross-agent.test.ts create mode 100644 extensions/memory-core/src/session-search-visibility.qmd.test.ts create mode 100644 qa/scenarios/memory/remember-across-conversations.yaml create mode 100644 src/agents/conversation-recall.types.ts diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index ff0f29b5a36e..753bc17ead07 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -2754733cc04ff86a4cfa7bf5744ab43f2ae85d8c15f5a5ec31e7e2b13878692d config-baseline.json -a965433f02c4b3e1e3eacb57b17ca6c9e0916fa26d91e1d6413e65eec3873f72 config-baseline.core.json -2ef4f1bd48c016d94ee65ff38c7cf3639829dd9a5e64eeaf4d91bad8633c970c config-baseline.channel.json -43eb8f2defc538efe85401a099a865fd8771c9037ee360e7f603a369e6341102 config-baseline.plugin.json +cc6b32ad0a77ec983c3b97fadee565f93e0c9437845335a9dd55ea83e60a366d config-baseline.json +17d536dc4bb6a97a18120cb4ed1884a2f4e250a881277cee28dfe26aaeeb005c config-baseline.core.json +f75efa4f3fa4ac7c2e7dc6580a1dd1efaa8e2e13ec7df9c21838c8ebc44a3dca config-baseline.channel.json +ddde5320a53d2cb2bb41f4624ae09a4e05b11eba2f3f9d0a53a50ebf65d13ca5 config-baseline.plugin.json diff --git a/docs/concepts/active-memory.md b/docs/concepts/active-memory.md index d361ff27cb91..bd45f94554a4 100644 --- a/docs/concepts/active-memory.md +++ b/docs/concepts/active-memory.md @@ -15,10 +15,55 @@ moment for the recalled fact to feel natural has passed. Active memory gives the system one bounded chance to surface relevant memory before the main reply is generated. -## Quick start +## Remember across conversations -Paste into `openclaw.json` for a safe default: plugin on, scoped to `main`, -direct-message sessions only, model inherited from the session. +For a personal or fully trusted agent, enable bounded recall across its other +private conversations with one per-agent setting: + +```json5 +{ + agents: { + list: [ + { + id: "personal", + memorySearch: { + rememberAcrossConversations: true, + }, + }, + ], + }, +} +``` + +The setting is off by default. When enabled, OpenClaw indexes that agent's +session transcripts and runs an Active Memory retrieval pass before eligible +private replies. The pass can read relevant transcript excerpts from the same +agent's other private conversations. It excludes the conversation already +being answered. + +The privacy boundary is fixed: + +- private direct and persistent explicit UI conversations can recall one another +- groups and channels are neither recall sources nor recall destinations +- another agent's transcripts are never eligible +- unknown or archived transcripts without enough conversation metadata are rejected + +This does not merge transcripts, change session keys or delivery routes, widen +`tools.sessions.visibility`, or grant broader `sessions_*` tool access. Shared +workspace memory (`MEMORY.md` and `memory/*.md`) keeps its existing behavior. + +Active Memory must remain enabled. Retrieval adds a bounded blocking step to +eligible replies; timeout, unavailable search, and empty results all continue +the reply without recalled transcript context. OpenClaw's built-in memory +provider supports this protected transcript-recall path with both the builtin +and QMD backends. Other memory providers keep their own recall behavior but do +not automatically receive private transcript authorization. `openclaw doctor` +reports an unsupported provider or missing `memory_search` tool. + +## Advanced Active Memory quick start + +Paste into `openclaw.json` for an advanced safe default: plugin on, scoped to +`main`, direct-message sessions only, model inherited from the session. ```json5 { @@ -90,14 +135,14 @@ without extra context. Active memory is a conversational enrichment feature, not a platform-wide inference feature: -| Surface | Runs active memory? | -| ------------------------------------------------------------------- | ------------------------------------------------------- | -| Control UI / web chat persistent sessions | Yes, if the plugin is enabled and the agent is targeted | -| Other interactive channel sessions on the same persistent chat path | Yes, if the plugin is enabled and the agent is targeted | -| Headless one-shot runs | No | -| Heartbeat/background runs | No | -| Generic internal `agent-command` paths | No | -| Sub-agent/internal helper execution | No | +| Surface | Runs active memory? | +| ------------------------------------------------------------------- | -------------------------------------------------------- | +| Control UI / web chat persistent sessions | Yes, when either activation path targets the agent | +| Other interactive channel sessions on the same persistent chat path | Yes, when either activation path allows the conversation | +| Headless one-shot runs | No | +| Heartbeat/background runs | No | +| Generic internal `agent-command` paths | No | +| Sub-agent/internal helper execution | No | Use it when the session is persistent and user-facing, the agent has meaningful long-term memory to search, and continuity/personalization matter @@ -108,32 +153,26 @@ personalization would be surprising. ## When it runs -Two gates must both pass: +Active Memory has two activation paths: -1. **Config opt-in** — the plugin is enabled and the current agent id is in `config.agents`. -2. **Runtime eligibility** — the session is an eligible interactive persistent chat session, its chat type is allowed, and its conversation id is not filtered out. +1. **Remember across conversations** automatically targets agents whose + `memorySearch.rememberAcrossConversations` setting is enabled, but only for + private direct or persistent explicit UI conversations. +2. **Advanced Active Memory** targets agent IDs listed in + `plugins.entries.active-memory.config.agents` and applies the plugin's chat + type and chat ID controls. -```text -plugin enabled -+ -agent id targeted -+ -allowed chat type -+ -allowed/not-denied chat id -+ -eligible interactive persistent chat session -= -active memory runs -``` - -If any condition fails, active memory does not run for that turn (and the -main reply is unaffected). +Both paths require the plugin to be enabled and an eligible interactive +persistent conversation. A session-scoped `/active-memory off` pauses both +paths for that conversation. If any condition fails, active memory does not run +for that turn, and the main reply is unaffected. ### Session types -`config.allowedChatTypes` controls which kinds of conversations may run -active memory. Default: +`config.allowedChatTypes` controls which kinds of conversations may run the +advanced Active Memory path. It cannot widen Remember across conversations: +that product setting remains private-only even when advanced Active Memory is +allowed in groups or channels. Default: ```json5 allowedChatTypes: ["direct"]; @@ -185,7 +224,9 @@ config: ``` This only affects the current session; it does not change -`plugins.entries.active-memory.config.enabled` or other global configuration. +`plugins.entries.active-memory.config.enabled`, an agent's +`memorySearch.rememberAcrossConversations` setting, or other global +configuration. To pause/resume for all sessions instead, use the global form (requires owner or `operator.admin`): @@ -392,12 +433,12 @@ model — `/v1/models` visibility alone does not guarantee it. ## Memory tools `config.toolsAllow` sets the concrete tool names the blocking sub-agent may -call. Defaults depend on the active memory provider: +call for advanced Active Memory. Defaults depend on the current memory provider: -| `plugins.slots.memory` | Default `toolsAllow` | -| -------------------------------- | --------------------------------- | -| unset / `memory-core` (built-in) | `["memory_search", "memory_get"]` | -| `memory-lancedb` | `["memory_recall"]` | +| Memory provider | Default `toolsAllow` | +| --------------- | --------------------------------- | +| Built-in memory | `["memory_search", "memory_get"]` | +| LanceDB | `["memory_recall"]` | If none of the configured tools are available, or the sub-agent run fails, active memory skips recall for that turn and the main reply continues @@ -409,7 +450,7 @@ explicitly report an empty result or failure. entries, and core agent tools (`read`, `exec`, `message`, `web_search`, and similar) are silently filtered out before the hidden sub-agent starts. -### Built-in memory-core +### Built-in memory No explicit `toolsAllow` needed: @@ -431,24 +472,13 @@ No explicit `toolsAllow` needed: ### LanceDB memory -Selecting the memory slot is enough for active memory to use `memory_recall`: +After [installing and configuring LanceDB](/plugins/memory-lancedb), Active +Memory automatically uses `memory_recall`; no explicit `toolsAllow` is needed: ```json5 { plugins: { - slots: { - memory: "memory-lancedb", - }, entries: { - "memory-lancedb": { - enabled: true, - config: { - embedding: { - provider: "openai", - model: "text-embedding-3-small", - }, - }, - }, "active-memory": { enabled: true, config: { @@ -461,6 +491,11 @@ Selecting the memory slot is enough for active memory to use `memory_recall`: } ``` +This is the advanced Active Memory path for LanceDB's own stored memories. +`memorySearch.rememberAcrossConversations` does not expose private session +transcripts through `memory_recall`. Use LanceDB's auto-recall or the advanced +configuration above when LanceDB is the active memory provider. + ### Lossless Claw [Lossless Claw](https://github.com/martian-engineering/lossless-claw) is an @@ -472,6 +507,9 @@ point active memory at its tools: ```json5 { plugins: { + slots: { + contextEngine: "lossless-claw", + }, entries: { "lossless-claw": { enabled: true, @@ -480,7 +518,7 @@ point active memory at its tools: enabled: true, config: { agents: ["main"], - toolsAllow: ["lcm_grep", "lcm_describe", "lcm_expand_query"], + toolsAllow: ["memory_search", "lcm_grep", "lcm_describe", "lcm_expand_query"], promptAppend: "Use lcm_grep first for compacted conversation recall. Use lcm_describe to inspect a specific summary. Use lcm_expand_query only when the latest user message needs exact details that may have been compacted away. Return NONE if the retrieved context is not clearly useful.", }, }, @@ -491,7 +529,11 @@ point active memory at its tools: Do not add `lcm_expand` to `toolsAllow` here; Lossless Claw uses it as a lower-level tool for delegated expansion, not meant for the top-level -active-memory sub-agent. +active-memory sub-agent. Lossless Claw changes context assembly without +replacing the current memory provider. Keep `memory_search` in `toolsAllow` +when also using `rememberAcrossConversations`; an LCM-only tool list remains +valid for advanced Active Memory but disables the product transcript-recall +path. ## Advanced escape hatches @@ -685,10 +727,16 @@ while warm-up finishes. If active memory is not showing up where you expect: 1. Confirm the plugin is enabled under `plugins.entries.active-memory.enabled`. -2. Confirm the current agent id is listed in `config.agents`. -3. Confirm you are testing through an interactive persistent chat session. -4. Turn on `config.logging: true` and watch the gateway logs. -5. Verify memory search itself works with `openclaw status --deep`. +2. For Remember across conversations, confirm the agent's + `memorySearch.rememberAcrossConversations` setting is `true`, run + `openclaw doctor` to verify the current memory provider supports protected + transcript recall, and confirm `config.toolsAllow` includes `memory_search` + when explicitly configured. For advanced Active Memory, confirm the agent ID + is listed in `config.agents`. +3. Confirm you are testing through an eligible interactive persistent conversation. +4. Remember that groups and channels never use cross-conversation transcript recall. +5. Turn on `config.logging: true` and watch the gateway logs. +6. Verify memory search itself works with `openclaw status --deep`. If memory hits are noisy, tighten `maxSummaryChars`. If active memory is too slow, lower `queryMode`, lower `timeoutMs`, or reduce recent turn counts and @@ -696,12 +744,14 @@ per-turn char caps. ## Common issues -Active memory rides on the configured memory plugin's recall pipeline, so -most recall surprises are embedding-provider problems, not active-memory -bugs. The default `memory-core` path uses `memory_search` and `memory_get`; -the `memory-lancedb` slot uses `memory_recall`. If you use another memory -plugin, confirm `config.toolsAllow` names the tools that plugin actually -registers. +Advanced Active Memory rides on the configured memory plugin's recall +pipeline, so most recall surprises are embedding-provider problems, not +active-memory bugs. The default `memory-core` path uses `memory_search` and +`memory_get`; the `memory-lancedb` slot uses `memory_recall`. If you use another +memory plugin, confirm `config.toolsAllow` names the tools that plugin actually +registers. Remember across conversations is narrower: the current memory +provider must support OpenClaw's protected same-agent/private-session recall +path. diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 4a98ef670018..cc0fc4008d4b 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -64,6 +64,27 @@ troubleshooting. Verify your setup with `openclaw security audit`. +## Remember across conversations + +Separate transcripts control each conversation's local history. For a personal +or fully trusted agent, `memorySearch.rememberAcrossConversations: true` +adds an optional retrieval step across that agent's other private +conversations; it does not combine their transcripts. + +Private direct and persistent explicit UI conversations can supply relevant +context to one another. Groups and channels stay separate in both directions: +their transcripts are not private recall sources, and replies in those +conversations do not receive private transcript context. The current +conversation is also excluded because its history is already loaded. + +This setting does not change session keys, DM scope, routing, delivery, or +`tools.sessions.visibility`. Shared workspace memory in `MEMORY.md` and +`memory/*.md` also keeps its existing behavior. The current memory provider +must support protected private transcript recall; context engines such as +Lossless Claw remain independent and can run alongside it. See +[Active Memory](/concepts/active-memory#remember-across-conversations) for setup +and runtime details. + ## Session lifecycle Sessions are reused until they expire under `session.reset`: diff --git a/docs/docs_map.md b/docs/docs_map.md index 9223990d043b..f1469775cf43 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -2215,7 +2215,8 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Route: /concepts/active-memory - Headings: - - H2: Quick start + - H2: Remember across conversations + - H2: Advanced Active Memory quick start - H2: How it works - H2: When it runs - H3: Session types @@ -2227,7 +2228,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: Speed recommendations - H4: Cerebras setup - H2: Memory tools - - H3: Built-in memory-core + - H3: Built-in memory - H3: LanceDB memory - H3: Lossless Claw - H2: Advanced escape hatches @@ -2941,6 +2942,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: How messages are routed - H2: DM isolation - H3: Dock linked channels + - H2: Remember across conversations - H2: Session lifecycle - H2: Where state lives - H2: Session maintenance @@ -8681,6 +8683,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Route: /reference/memory-config - Headings: + - H2: Remember across conversations - H2: Provider selection - H3: Custom provider ids - H3: API key resolution diff --git a/docs/plugins/memory-lancedb.md b/docs/plugins/memory-lancedb.md index 6458fc31df86..b1b6f199188f 100644 --- a/docs/plugins/memory-lancedb.md +++ b/docs/plugins/memory-lancedb.md @@ -31,6 +31,15 @@ Companion plugins such as `memory-wiki` can run alongside `memory-lancedb`, but only one plugin owns the active memory slot at a time. + +LanceDB's `memory_recall` does not receive the protected private transcript +authorization used by `memorySearch.rememberAcrossConversations`. Use LanceDB's +`autoRecall` or its `memory_recall` tool through +[advanced Active Memory](/concepts/active-memory#lancedb-memory). +`openclaw doctor` reports when Remember across conversations is unavailable +with the current memory provider. + + ## Quick start ```json5 diff --git a/docs/reference/memory-config.md b/docs/reference/memory-config.md index e771cb8da465..33f6e447ebae 100644 --- a/docs/reference/memory-config.md +++ b/docs/reference/memory-config.md @@ -32,18 +32,69 @@ This page lists every configuration knob for OpenClaw memory search. For concept All memory search settings live under `agents.defaults.memorySearch` in `openclaw.json` (or a per-agent `agents.list[].memorySearch` override) unless noted otherwise. -If you are looking for the **active memory** feature toggle and sub-agent config, that lives under `plugins.entries.active-memory` instead of `memorySearch`. +For the recommended personal-agent workflow, use +`memorySearch.rememberAcrossConversations`. Advanced Active Memory targeting, +model, prompt, and latency controls live under `plugins.entries.active-memory`. -Active memory uses a two-gate model: - -1. the plugin must be enabled and target the current agent id -2. the request must be an eligible interactive persistent chat session - -See [Active Memory](/concepts/active-memory) for the activation model, plugin-owned config, transcript persistence, and safe rollout pattern. +See [Active Memory](/concepts/active-memory) for both activation paths, +transcript persistence, and safe rollout guidance. --- +## Remember across conversations + +| Key | Type | Default | Description | +| ----------------------------- | --------- | ------- | ------------------------------------------------------------------------------ | +| `rememberAcrossConversations` | `boolean` | `false` | Use relevant context from this agent's other recognized private conversations. | + +Configure it per agent when only a trusted personal agent should use +cross-conversation transcript recall: + +```json5 +{ + agents: { + list: [ + { + id: "personal", + memorySearch: { + rememberAcrossConversations: true, + }, + }, + ], + }, +} +``` + +The value follows normal `agents.defaults.memorySearch` inheritance with a +per-agent override. Enabling it implies session transcript indexing and +adds `sessions` to the agent's resolved memory sources. With QMD, it also +enables that agent's session export; no separate +`memory.qmd.sessions.enabled` setting is required for this mode. + +OpenClaw's built-in memory provider supports this protected path with both the +builtin and QMD backends. Alternate memory providers can keep using their own +recall hooks and advanced Active Memory tools, but this setting is skipped +unless the current provider supports protected private transcript recall. +`openclaw doctor` reports an unsupported provider or an explicit Active Memory +`toolsAllow` list that omits `memory_search`. + +The retrieval boundary is narrower than general session search: + +- only the same agent's recognized private conversations are eligible +- the conversation being answered is excluded +- groups and channels are excluded as sources and destinations +- unknown conversation kinds fail closed +- sandboxed recall cannot use the special cross-conversation authorization + +The setting does not change `tools.sessions.visibility`, session keys, +transcript storage, delivery routing, or the permissions of `sessions_list`, +`sessions_history`, and `sessions_send`. Active Memory performs a bounded +read-only retrieval pass; unavailable or timed-out retrieval does not block the +reply. + +--- + ## Provider selection | Key | Type | Default | Description | @@ -483,12 +534,16 @@ Index session transcripts and surface them via `memory_search`: Session indexing is opt-in and runs asynchronously. Results can be slightly stale. Session logs live on disk, so treat filesystem access as the trust boundary. -Session transcript hits also obey +Ordinary model-invoked session transcript search obeys [`tools.sessions.visibility`](/gateway/config-tools#toolssessions). The default `tree` visibility only exposes the current session and sessions it spawned. To -recall an unrelated same-agent gateway-dispatched session from a different -session, such as a DM, intentionally widen visibility to `agent` (or `all` only -when cross-agent recall is also required and agent-to-agent policy allows it). +let ordinary `memory_search` calls inspect unrelated sessions, intentionally +widen visibility to `agent` (or `all` only when cross-agent recall is also +required and agent-to-agent policy allows it). + +`rememberAcrossConversations` does not widen that setting. It supplies a +separate runtime-only authorization limited to same-agent private +transcripts during the bounded Active Memory pass. The examples below place these settings under `agents.defaults`. You can also apply equivalent `memorySearch` settings in a per-agent override when only one @@ -541,7 +596,15 @@ For same-agent gateway-to-DM recall: When using QMD, `agents.defaults.memorySearch.experimental.sessionMemory` and `sources: ["sessions"]` do not by themselves export transcripts into QMD. Set -`memory.qmd.sessions.enabled: true` as well. +`memory.qmd.sessions.enabled: true` as well. The higher-level +`rememberAcrossConversations: true` setting is the exception: it implies the +required QMD session export for that agent. Implied exports stay private: +they always use the default internal export location (a configured +`sessions.exportDir` applies only to explicit exports), they are searched only +during that agent's cross-conversation recall, and ordinary `memory_get` +cannot read them. Explicit +`memory.qmd.sessions.enabled: true` keeps its existing behavior and makes +exported transcripts part of the ordinary memory corpus. --- diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 8a4f045d34a3..0fa561aa96ac 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -180,6 +180,7 @@ describe("active-memory plugin", () => { agents: ["main"], logging: true, }; + let apiConfig: Record = {}; const syncRuntimePluginConfig = (nextPluginConfig: Record) => { pluginConfig = nextPluginConfig; const plugins = configFile.plugins as Record | undefined; @@ -220,7 +221,17 @@ describe("active-memory plugin", () => { set pluginConfig(nextPluginConfig: Record) { syncRuntimePluginConfig(nextPluginConfig); }, - config: {}, + get config() { + return apiConfig; + }, + set config(nextConfig: Record) { + apiConfig = nextConfig; + const plugins = configFile.plugins; + configFile = { + ...nextConfig, + ...(plugins ? { plugins } : {}), + }; + }, id: "active-memory", name: "Active Memory", logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }, @@ -810,6 +821,318 @@ describe("active-memory plugin", () => { ); }); + it("runs product recall for an opted-in direct session without an Active Memory agent entry", async () => { + syncRuntimePluginConfig({ agents: [], logging: true }); + configFile = { + ...configFile, + agents: { + list: [ + { + id: "personal", + workspace: "/tmp/live-personal-workspace", + agentDir: "/tmp/live-personal-agent", + model: { primary: "openai/gpt-5.5" }, + memorySearch: { rememberAcrossConversations: true }, + }, + ], + }, + }; + const sessionKey = "agent:personal:telegram:direct:owner"; + hoisted.sessionStore[sessionKey] = { sessionId: "s-personal", updatedAt: 0 }; + + await requireHook("before_prompt_build")( + { prompt: "what do I usually order?", messages: [] }, + { + agentId: "personal", + trigger: "user", + sessionKey, + messageProvider: "telegram", + channelId: "owner", + }, + ); + + expect(lastEmbeddedRunParams().conversationRecall).toEqual({ + anchorSessionKey: sessionKey, + scope: "same-agent-private", + corpus: "sessions", + }); + expect(lastEmbeddedRunParams().toolsAllow).toEqual(["memory_search"]); + expect(lastEmbeddedRunParams()).toMatchObject({ + workspaceDir: "/tmp/live-personal-workspace", + agentDir: "/tmp/live-personal-agent", + provider: "openai", + model: "gpt-5.5", + }); + expect(embeddedRunConfig()).toMatchObject({ + agents: { + list: [ + { + id: "personal", + workspace: "/tmp/live-personal-workspace", + agentDir: "/tmp/live-personal-agent", + model: { primary: "openai/gpt-5.5" }, + memorySearch: { rememberAcrossConversations: true }, + }, + ], + }, + }); + }); + + it("keeps product recall available when Lossless Claw owns the context-engine slot", async () => { + syncRuntimePluginConfig({ agents: [], logging: true }); + configFile = { + ...configFile, + agents: { + list: [ + { + id: "personal", + model: { primary: "github-copilot/gpt-5.4-mini" }, + memorySearch: { rememberAcrossConversations: true }, + }, + ], + }, + plugins: { + ...(configFile.plugins as Record), + slots: { contextEngine: "lossless-claw" }, + }, + }; + const sessionKey = "agent:personal:telegram:direct:owner"; + hoisted.sessionStore[sessionKey] = { sessionId: "s-personal", updatedAt: 0 }; + + await requireHook("before_prompt_build")( + { prompt: "what do I usually order?", messages: [] }, + { + agentId: "personal", + trigger: "user", + sessionKey, + messageProvider: "telegram", + channelId: "owner", + }, + ); + + expect(lastEmbeddedRunParams().conversationRecall).toEqual({ + anchorSessionKey: sessionKey, + scope: "same-agent-private", + corpus: "sessions", + }); + expect(hasWarnLine("does not support protected private transcript recall")).toBe(false); + }); + + it("matches case-preserved conversation ids against lowercased deny lists", async () => { + api.pluginConfig = { + agents: ["main"], + allowedChatTypes: ["direct", "group"], + deniedChatIds: ["AbCdEfGh=="], + }; + plugin.register(api as unknown as OpenClawPluginApi); + + const result = await requireHook("before_prompt_build")( + { prompt: "hi", messages: [] }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:signal:group:AbCdEfGh==", + messageProvider: "signal", + channelId: "signal", + }, + ); + + expect(runEmbeddedAgent).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it.each([ + { + name: "a denylisted main-scope DM", + sessionKey: "agent:personal:main", + messageProvider: "telegram", + channelId: "owner", + pluginConfig: { agents: [], logging: true, deniedChatIds: ["owner"] }, + memorySlot: "memory-core", + warn: undefined, + }, + { + name: "a group destination", + sessionKey: "agent:personal:telegram:group:family", + messageProvider: "telegram", + channelId: "family", + pluginConfig: { agents: [], logging: true }, + memorySlot: "memory-core", + warn: undefined, + }, + { + name: "a headless explicit destination", + sessionKey: "agent:personal:explicit:headless-run", + messageProvider: undefined, + channelId: undefined, + pluginConfig: { agents: [], logging: true }, + memorySlot: "memory-core", + warn: undefined, + }, + { + name: "an unsupported memory provider", + sessionKey: "agent:personal:telegram:direct:owner", + messageProvider: "telegram", + channelId: "owner", + pluginConfig: { agents: [], toolsAllow: ["memory_search"], logging: true }, + memorySlot: "memory-lancedb", + warn: "does not support protected private transcript recall", + }, + { + name: "a missing memory_search tool", + sessionKey: "agent:personal:telegram:direct:owner", + messageProvider: "telegram", + channelId: "owner", + pluginConfig: { agents: [], toolsAllow: ["memory_get"], logging: true }, + memorySlot: "memory-core", + warn: "memory_search is unavailable", + }, + ])("fails closed without product recall for $name", async (testCase) => { + syncRuntimePluginConfig(testCase.pluginConfig); + setMemorySlot(testCase.memorySlot); + configFile = { + ...configFile, + agents: { + list: [{ id: "personal", memorySearch: { rememberAcrossConversations: true } }], + }, + }; + hoisted.sessionStore[testCase.sessionKey] = { sessionId: "s-personal", updatedAt: 0 }; + + const result = await requireHook("before_prompt_build")( + { prompt: "what do I usually order?", messages: [] }, + { + agentId: "personal", + trigger: "user", + sessionKey: testCase.sessionKey, + messageProvider: testCase.messageProvider, + channelId: testCase.channelId, + }, + ); + + expect(result).toBeUndefined(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); + if (testCase.warn) { + expect(hasWarnLine(testCase.warn)).toBe(true); + } + }); + + it("runs product recall without an explicit Active Memory config entry", async () => { + configFile = { + agents: { + list: [ + { + id: "personal", + model: { primary: "github-copilot/gpt-5.4-mini" }, + memorySearch: { rememberAcrossConversations: true }, + }, + ], + }, + plugins: { entries: {} }, + }; + const sessionKey = "agent:personal:telegram:direct:owner"; + hoisted.sessionStore[sessionKey] = { sessionId: "s-personal", updatedAt: 0 }; + + await requireHook("before_prompt_build")( + { prompt: "what do I usually order?", messages: [] }, + { + agentId: "personal", + trigger: "user", + sessionKey, + messageProvider: "telegram", + channelId: "owner", + }, + ); + + expect(lastEmbeddedRunParams().conversationRecall).toEqual({ + anchorSessionKey: sessionKey, + scope: "same-agent-private", + corpus: "sessions", + }); + }); + + it("keeps advanced recall corpora separate from product recall", async () => { + syncRuntimePluginConfig({ agents: ["personal"], allowedChatTypes: ["direct", "group"] }); + configFile = { + ...configFile, + agents: { + list: [ + { + id: "personal", + model: { primary: "github-copilot/gpt-5.4-mini" }, + memorySearch: { rememberAcrossConversations: true }, + }, + ], + }, + }; + const directSessionKey = "agent:personal:telegram:direct:owner"; + hoisted.sessionStore[directSessionKey] = { sessionId: "s-personal", updatedAt: 0 }; + + await requireHook("before_prompt_build")( + { prompt: "what do I usually order?", messages: [] }, + { + agentId: "personal", + trigger: "user", + sessionKey: directSessionKey, + messageProvider: "telegram", + channelId: "owner", + }, + ); + expect(lastEmbeddedRunParams().conversationRecall).toEqual({ + anchorSessionKey: directSessionKey, + scope: "same-agent-private", + corpus: "configured", + }); + + await requireHook("before_prompt_build")( + { prompt: "what context helps here?", messages: [] }, + { + agentId: "personal", + trigger: "user", + sessionKey: "agent:personal:telegram:group:family", + messageProvider: "telegram", + channelId: "family", + }, + ); + expect(lastEmbeddedRunParams().conversationRecall).toBeUndefined(); + }); + + it("lets a remember-only agent pause recall for one session", async () => { + syncRuntimePluginConfig({ agents: [], logging: true }); + configFile = { + ...configFile, + agents: { + list: [{ id: "personal", memorySearch: { rememberAcrossConversations: true } }], + }, + }; + const sessionKey = "agent:personal:telegram:direct:owner"; + hoisted.sessionStore[sessionKey] = { sessionId: "s-personal", updatedAt: 0 }; + + const offResult = await registeredCommands["active-memory"].handler({ + channel: "telegram", + isAuthorizedSender: true, + sessionKey, + args: "off", + commandBody: "/active-memory off", + config: configFile, + requestConversationBinding: async () => ({ status: "error", message: "unsupported" }), + detachConversationBinding: async () => ({ removed: false }), + getCurrentConversationBinding: async () => null, + }); + await requireHook("before_prompt_build")( + { prompt: "what do I usually order?", messages: [] }, + { + agentId: "personal", + trigger: "user", + sessionKey, + messageProvider: "telegram", + channelId: "owner", + }, + ); + + expect(offResult.text).toBe("Active Memory: off for this session."); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); + }); + it("registers a session-scoped active-memory toggle command", async () => { const command = registeredCommands["active-memory"]; const sessionKey = "agent:main:active-memory-toggle"; diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index 2fe53e1c46e3..8c613aef8270 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -3,7 +3,10 @@ */ import { resolveAgentDir, resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime"; +import { + normalizePluginsConfig, + resolveLivePluginConfigObject, +} from "openclaw/plugin-sdk/plugin-config-runtime"; import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { applyCliRuntimeRecallTimeoutDefault, @@ -32,14 +35,18 @@ import { ACTIVE_MEMORY_GLOBAL_MUTATION_ADMIN_REQUIRED_TEXT, formatActiveMemoryCommandHelp, isActiveMemoryGloballyEnabled, + isActiveMemoryPluginEnabled, isAllowedChatId, isAllowedChatType, isEligibleInteractiveSession, isEnabledForAgent, + isPrivateRecallDestination, isSessionActiveMemoryDisabled, lacksAdminToMutateActiveMemoryGlobal, resolveCommandSessionKey, setSessionActiveMemoryDisabled, + hasRememberAcrossConversationsAgent, + shouldRememberAcrossConversations, shouldSkipActiveMemoryForHarnessSession, updateActiveMemoryGlobalEnabledInConfig, } from "./session-policy.js"; @@ -63,8 +70,11 @@ import { HOOK_TIMEOUT_RECOVERY_GRACE_MS, MAX_SETUP_GRACE_TIMEOUT_MS, MAX_TIMEOUT_MS, + type ConversationRecallContext, } from "./types.js"; +const MEMORY_CORE_PLUGIN_ID = "memory-core"; + /** Plugin entry registering Active Memory hooks, tools, config schema, and doctor cleanup. */ export default definePluginEntry({ id: "active-memory", @@ -109,7 +119,14 @@ export default definePluginEntry({ "active-memory", api.pluginConfig as Record, ); - config = normalizePluginConfig(livePluginConfig ?? { enabled: false }, readCurrentConfig()); + const liveConfig = readCurrentConfig(); + const fallbackConfig = + liveConfig && hasRememberAcrossConversationsAgent(liveConfig) ? {} : { enabled: false }; + const effectivePluginConfig = + liveConfig && !isActiveMemoryPluginEnabled(liveConfig) + ? { enabled: false } + : (livePluginConfig ?? fallbackConfig); + config = normalizePluginConfig(effectivePluginConfig, liveConfig); if (livePluginConfig) { warnDeprecatedModelFallbackPolicy(livePluginConfig); } @@ -126,6 +143,7 @@ export default definePluginEntry({ if (action === "help") { return { text: formatActiveMemoryCommandHelp() }; } + refreshLiveConfigFromRuntime(); if (isGlobal) { const currentConfig = api.runtime.config.current() as OpenClawConfig; if (action === "status") { @@ -178,7 +196,11 @@ export default definePluginEntry({ }; } const commandAgentId = resolveStatusUpdateAgentId({ sessionKey }); - if (!isEnabledForAgent(config, commandAgentId)) { + const liveConfig = readCurrentConfig() ?? api.config; + const commandRecallEnabled = + isEnabledForAgent(config, commandAgentId) || + (config.enabled && shouldRememberAcrossConversations(liveConfig, commandAgentId)); + if (!commandRecallEnabled) { return { text: "Active Memory: off for this session." }; } if (action === "status") { @@ -214,6 +236,7 @@ export default definePluginEntry({ "before_prompt_build", async (event, ctx) => { refreshLiveConfigFromRuntime(); + const liveConfig = readCurrentConfig() ?? api.config; // The hook deadline, watchdog, and embedded-run budget all flow from // this config, so the CLI-runtime default raise must happen before // any of them are armed. Budgeting shares the runner's own dispatch @@ -224,7 +247,7 @@ export default definePluginEntry({ // eligibility check treats a missing provider as ineligible. const timeoutModelRef = (timeoutAgentId - ? getModelRef(api, timeoutAgentId, config, { + ? getModelRef(liveConfig, timeoutAgentId, config, { modelProviderId: ctx.modelProviderId, modelId: ctx.modelId, }) @@ -232,12 +255,12 @@ export default definePluginEntry({ const cliDispatchEligibility = api.runtime.agent.resolveCliBackendDispatchEligibility({ provider: timeoutModelRef.provider, model: timeoutModelRef.model, - config: api.config, + config: liveConfig, ...(timeoutAgentId ? { agentId: timeoutAgentId, - agentDir: resolveAgentDir(api.config, timeoutAgentId), - workspaceDir: resolveAgentWorkspaceDir(api.config, timeoutAgentId), + agentDir: resolveAgentDir(liveConfig, timeoutAgentId), + workspaceDir: resolveAgentWorkspaceDir(liveConfig, timeoutAgentId), } : {}), }); @@ -298,7 +321,11 @@ export default definePluginEntry({ }); return undefined; } - if (!isEnabledForAgent(invocationConfig, effectiveAgentId)) { + const sessionContext = { + ...ctx, + sessionKey: resolvedSessionKey ?? ctx.sessionKey, + }; + if (!isEligibleInteractiveSession(sessionContext)) { await persistPluginStatusLines({ api, agentId: effectiveAgentId, @@ -306,39 +333,43 @@ export default definePluginEntry({ }); return undefined; } - if ( - !isEligibleInteractiveSession({ - ...ctx, - sessionKey: resolvedSessionKey ?? ctx.sessionKey, - }) - ) { - await persistPluginStatusLines({ - api, - agentId: effectiveAgentId, - sessionKey: resolvedSessionKey, - }); - return undefined; + const destinationContext = { + ...sessionContext, + mainKey: liveConfig.session?.mainKey ?? api.config.session?.mainKey, + }; + const activeMemoryConfigured = isEnabledForAgent(invocationConfig, effectiveAgentId); + const chatIdAllowed = isAllowedChatId(invocationConfig, { + sessionKey: destinationContext.sessionKey, + messageProvider: destinationContext.messageProvider, + channelId: destinationContext.channelId, + }); + const activeMemoryAllowed = + activeMemoryConfigured && + isAllowedChatType(invocationConfig, destinationContext) && + chatIdAllowed; + const productRecallRequested = Boolean( + invocationConfig.enabled && + resolvedSessionKey && + shouldRememberAcrossConversations(liveConfig, effectiveAgentId) && + isPrivateRecallDestination(destinationContext) && + chatIdAllowed, + ); + const memorySlot = normalizePluginsConfig(liveConfig.plugins).slots.memory; + const productRecallEligible = + productRecallRequested && memorySlot === MEMORY_CORE_PLUGIN_ID; + if (productRecallRequested && !productRecallEligible) { + api.logger.warn?.( + "active-memory: the current memory provider does not support protected private transcript recall; skipping Remember across conversations", + ); } - if ( - !isAllowedChatType(invocationConfig, { - ...ctx, - sessionKey: resolvedSessionKey ?? ctx.sessionKey, - mainKey: api.config.session?.mainKey, - }) - ) { - await persistPluginStatusLines({ - api, - agentId: effectiveAgentId, - sessionKey: resolvedSessionKey, - }); - return undefined; + const productRecallAllowed = + productRecallEligible && invocationConfig.toolsAllow.includes("memory_search"); + if (productRecallEligible && !productRecallAllowed) { + api.logger.warn?.( + "active-memory: memory_search is unavailable; skipping Remember across conversations private transcript recall", + ); } - if ( - !isAllowedChatId(invocationConfig, { - sessionKey: resolvedSessionKey ?? ctx.sessionKey, - messageProvider: ctx.messageProvider, - }) - ) { + if (!activeMemoryAllowed && !productRecallAllowed) { await persistPluginStatusLines({ api, agentId: effectiveAgentId, @@ -346,11 +377,23 @@ export default definePluginEntry({ }); return undefined; } + const conversationRecall: ConversationRecallContext | undefined = + productRecallAllowed && resolvedSessionKey + ? { + anchorSessionKey: resolvedSessionKey, + scope: "same-agent-private", + corpus: activeMemoryAllowed ? "configured" : "sessions", + } + : undefined; + const recallConfig = + productRecallAllowed && !activeMemoryAllowed + ? { ...invocationConfig, toolsAllow: ["memory_search"] } + : invocationConfig; const recentTurns = extractRecentTurns(event.messages); const query = buildQuery({ latestUserMessage: event.prompt, recentTurns, - config: invocationConfig, + config: recallConfig, }); const searchQuery = buildSearchQuery({ latestUserMessage: event.prompt, @@ -361,7 +404,8 @@ export default definePluginEntry({ armHookDeadline(liveRecallTimeoutMs, "recall"); const result = await maybeResolveActiveRecall({ api, - config: invocationConfig, + runtimeConfig: liveConfig, + config: recallConfig, agentId: effectiveAgentId, sessionKey: resolvedSessionKey, sessionId: ctx.sessionId, @@ -371,6 +415,7 @@ export default definePluginEntry({ searchQuery, currentModelProviderId: ctx.modelProviderId, currentModelId: ctx.modelId, + conversationRecall, abortSignal: deadlineController.signal, }); deadlineController.signal.throwIfAborted(); diff --git a/extensions/active-memory/openclaw.plugin.json b/extensions/active-memory/openclaw.plugin.json index 79f4e21a3443..45a38adff3bb 100644 --- a/extensions/active-memory/openclaw.plugin.json +++ b/extensions/active-memory/openclaw.plugin.json @@ -4,7 +4,7 @@ "onStartup": true }, "name": "Active Memory", - "description": "Runs a bounded blocking memory sub-agent before eligible conversational replies and injects relevant memory into prompt context.", + "description": "Runs bounded pre-reply memory retrieval and implements per-agent Remember across conversations for eligible private conversations.", "configSchema": { "type": "object", "additionalProperties": false, @@ -98,11 +98,11 @@ "uiHints": { "enabled": { "label": "Active Memory Recall", - "help": "Globally enable or pause Active Memory recall while keeping the plugin command available." + "help": "Globally enable or pause Active Memory recall while keeping the plugin command available. Remember across conversations requires this plugin to remain enabled." }, "agents": { "label": "Target Agents", - "help": "Explicit agent ids that may use active memory." + "help": "Advanced: explicit agent IDs that may use configured Active Memory recall. Agents with Remember across conversations enabled are automatically targeted for private-conversation transcript recall." }, "model": { "label": "Memory Model", @@ -118,7 +118,7 @@ }, "allowedChatTypes": { "label": "Allowed Chat Types", - "help": "Choose which session types may run Active Memory. Defaults to direct-message style sessions only, but explicit portal/webchat sessions can also be enabled." + "help": "Advanced: choose which conversation types may run configured Active Memory. Remember across conversations remains limited to private direct and explicit UI conversations regardless of this setting." }, "allowedChatIds": { "label": "Allowed Chat IDs", @@ -146,7 +146,7 @@ }, "toolsAllow": { "label": "Allowed Memory Tools", - "help": "Advanced: tool names the blocking memory sub-agent may use. Defaults to memory_search and memory_get, or memory_recall when plugins.slots.memory selects memory-lancedb; configure this for other non-core memory providers. Wildcards, group entries, and core agent tools are ignored." + "help": "Advanced: tool names the blocking memory sub-agent may use. Defaults to memory_search and memory_get for built-in memory, or memory_recall for LanceDB; configure this for other memory providers. Wildcards, group entries, and core agent tools are ignored." }, "thinking": { "label": "Thinking Override", diff --git a/extensions/active-memory/query.ts b/extensions/active-memory/query.ts index 01fada2f2444..24db32303a47 100644 --- a/extensions/active-memory/query.ts +++ b/extensions/active-memory/query.ts @@ -4,7 +4,7 @@ import { resolveAgentEffectiveModelPrimary, resolveDefaultModelForAgent, } from "openclaw/plugin-sdk/agent-runtime"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { ACTIVE_MEMORY_CLOSE_TAG, @@ -291,7 +291,7 @@ function parseModelCandidate(modelRef: string | undefined, defaultProvider = DEF } function getModelRef( - api: OpenClawPluginApi, + runtimeConfig: OpenClawConfig, agentId: string, config: ResolvedActiveRecallPluginConfig, ctx?: { @@ -301,8 +301,8 @@ function getModelRef( ): { provider: string; model: string } | undefined { const currentRunModel = ctx?.modelProviderId && ctx?.modelId ? `${ctx.modelProviderId}/${ctx.modelId}` : undefined; - const configuredDefaultModel = resolveAgentEffectiveModelPrimary(api.config, agentId) - ? resolveDefaultModelForAgent({ cfg: api.config, agentId }) + const configuredDefaultModel = resolveAgentEffectiveModelPrimary(runtimeConfig, agentId) + ? resolveDefaultModelForAgent({ cfg: runtimeConfig, agentId }) : undefined; const defaultProvider = configuredDefaultModel?.provider ?? DEFAULT_PROVIDER; const candidates = [ diff --git a/extensions/active-memory/recall-run.ts b/extensions/active-memory/recall-run.ts index b6f15be0f784..4849fc54bc05 100644 --- a/extensions/active-memory/recall-run.ts +++ b/extensions/active-memory/recall-run.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { resolveAgentDir, resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { parseAgentSessionKey } from "openclaw/plugin-sdk/routing"; import { @@ -39,6 +40,7 @@ import { ACTIVE_MEMORY_RECALL_LANE, type ActiveMemoryFastMode, type ActiveMemoryTranscriptSource, + type ConversationRecallContext, type RecallSubagentResult, type ResolvedActiveRecallPluginConfig, } from "./types.js"; @@ -140,6 +142,7 @@ async function cleanupActiveMemoryRecallSession(params: { async function runRecallSubagent(params: { api: OpenClawPluginApi; + runtimeConfig: OpenClawConfig; config: ResolvedActiveRecallPluginConfig; agentId: string; parentSessionKey?: string; @@ -151,16 +154,17 @@ async function runRecallSubagent(params: { currentModelProviderId?: string; currentModelId?: string; modelRef?: { provider: string; model: string }; + conversationRecall?: ConversationRecallContext; storePath: string; fastMode?: ActiveMemoryFastMode; abortSignal?: AbortSignal; onTranscriptSources?: (sources: readonly ActiveMemoryTranscriptSource[]) => void; }): Promise { - const workspaceDir = resolveAgentWorkspaceDir(params.api.config, params.agentId); - const agentDir = resolveAgentDir(params.api.config, params.agentId); + const workspaceDir = resolveAgentWorkspaceDir(params.runtimeConfig, params.agentId); + const agentDir = resolveAgentDir(params.runtimeConfig, params.agentId); const modelRef = params.modelRef ?? - getModelRef(params.api, params.agentId, params.config, { + getModelRef(params.runtimeConfig, params.agentId, params.config, { modelProviderId: params.currentModelProviderId, modelId: params.currentModelId, }); @@ -258,7 +262,10 @@ async function runRecallSubagent(params: { messageProvider: params.messageProvider, channelId: params.channelId, }); - const embeddedConfig = applyActiveMemoryRuntimeConfigSnapshot(params.api.config, params.config); + const embeddedConfig = applyActiveMemoryRuntimeConfigSnapshot( + params.runtimeConfig, + params.config, + ); const embeddedTimeoutMs = params.config.timeoutMs + params.config.setupGraceTimeoutMs; const result = await params.api.runtime.agent.runEmbeddedAgent({ sessionId: subagentSessionId, @@ -283,6 +290,7 @@ async function runRecallSubagent(params: { timeoutMs: embeddedTimeoutMs, runId: subagentSessionId, trigger: "manual", + conversationRecall: params.conversationRecall, toolsAllow: [...params.config.toolsAllow], disableMessageTool: true, allowGatewaySubagentBinding: true, diff --git a/extensions/active-memory/recall.ts b/extensions/active-memory/recall.ts index ad089cae3f15..67a059624626 100644 --- a/extensions/active-memory/recall.ts +++ b/extensions/active-memory/recall.ts @@ -1,4 +1,5 @@ import { resolveAgentConfig } from "openclaw/plugin-sdk/agent-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { normalizeActiveMemoryFastMode } from "./config.js"; import { getModelRef } from "./query.js"; @@ -32,6 +33,7 @@ import type { ActiveMemoryFastMode, ActiveMemoryTranscriptSource, ActiveRecallResult, + ConversationRecallContext, ResolvedActiveRecallPluginConfig, TerminalMemorySearchWatch, } from "./types.js"; @@ -48,6 +50,7 @@ function formatActiveMemoryFastMode(fastMode: ActiveMemoryFastMode | undefined): function prepareRecallRunContext(params: { api: OpenClawPluginApi; + runtimeConfig: OpenClawConfig; config: ResolvedActiveRecallPluginConfig; agentId: string; sessionKey?: string; @@ -65,7 +68,7 @@ function prepareRecallRunContext(params: { sessionId: params.sessionId, }); const storePath = params.api.runtime.agent.session.resolveStorePath( - params.api.config.session?.store, + params.runtimeConfig.session?.store, { agentId: params.agentId }, ); if (params.config.fastMode !== undefined) { @@ -82,13 +85,14 @@ function prepareRecallRunContext(params: { const fastMode = normalizeActiveMemoryFastMode(sessionFastMode) ?? normalizeActiveMemoryFastMode( - resolveAgentConfig(params.api.config, params.agentId)?.fastModeDefault, + resolveAgentConfig(params.runtimeConfig, params.agentId)?.fastModeDefault, ); return { parentSessionKey, storePath, fastMode }; } async function maybeResolveActiveRecall(params: { api: OpenClawPluginApi; + runtimeConfig: OpenClawConfig; config: ResolvedActiveRecallPluginConfig; agentId: string; sessionKey?: string; @@ -99,18 +103,23 @@ async function maybeResolveActiveRecall(params: { searchQuery: string; currentModelProviderId?: string; currentModelId?: string; + conversationRecall?: ConversationRecallContext; abortSignal?: AbortSignal; }): Promise { params.abortSignal?.throwIfAborted(); const startedAt = Date.now(); - const cacheKey = buildCacheKey({ - agentId: params.agentId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - query: params.query, - }); - const cached = getCachedResult(cacheKey); - const resolvedModelRef = getModelRef(params.api, params.agentId, params.config, { + // Memory Core re-authorizes every conversation-recall request against live + // session state. Never replay a cached private summary after eligibility changes. + const cacheKey = params.conversationRecall + ? undefined + : buildCacheKey({ + agentId: params.agentId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + query: params.query, + }); + const cached = cacheKey ? getCachedResult(cacheKey) : undefined; + const resolvedModelRef = getModelRef(params.runtimeConfig, params.agentId, params.config, { modelProviderId: params.currentModelProviderId, modelId: params.currentModelId, }); @@ -346,7 +355,7 @@ async function maybeResolveActiveRecall(params: { searchDebug: result.searchDebug, }); params.abortSignal?.throwIfAborted(); - if (shouldCacheResult(result)) { + if (cacheKey && shouldCacheResult(result)) { setCachedResult(cacheKey, result, params.config.cacheTtlMs); } return result; @@ -379,7 +388,7 @@ async function maybeResolveActiveRecall(params: { searchDebug: result.searchDebug, }); params.abortSignal?.throwIfAborted(); - if (shouldCacheResult(result)) { + if (cacheKey && shouldCacheResult(result)) { setCachedResult(cacheKey, result, params.config.cacheTtlMs); } return result; diff --git a/extensions/active-memory/session-policy.ts b/extensions/active-memory/session-policy.ts index 9a8997085e64..087644dc33ee 100644 --- a/extensions/active-memory/session-policy.ts +++ b/extensions/active-memory/session-policy.ts @@ -1,6 +1,9 @@ import crypto from "node:crypto"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime"; +import { + normalizePluginsConfig, + resolvePluginConfigObject, +} from "openclaw/plugin-sdk/plugin-config-runtime"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { parseAgentSessionKey, parseThreadSessionSuffix } from "openclaw/plugin-sdk/routing"; import { asOptionalRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -112,6 +115,37 @@ function isActiveMemoryGloballyEnabled(cfg: OpenClawConfig): boolean { return pluginConfig?.enabled !== false; } +function isActiveMemoryPluginEnabled(cfg: OpenClawConfig): boolean { + const plugins = normalizePluginsConfig(cfg.plugins); + if (!plugins.enabled || plugins.deny.includes("active-memory")) { + return false; + } + if (plugins.allow.length > 0 && !plugins.allow.includes("active-memory")) { + return false; + } + return plugins.entries["active-memory"]?.enabled !== false; +} + +function hasRememberAcrossConversationsAgent(cfg: OpenClawConfig): boolean { + return ( + cfg.agents?.defaults?.memorySearch?.rememberAcrossConversations === true || + cfg.agents?.list?.some((agent) => agent.memorySearch?.rememberAcrossConversations === true) === + true + ); +} + +function shouldRememberAcrossConversations(cfg: OpenClawConfig, agentId: string): boolean { + const normalizedAgentId = agentId.trim().toLowerCase(); + const agent = cfg.agents?.list?.find( + (candidate) => candidate.id?.trim().toLowerCase() === normalizedAgentId, + ); + return ( + agent?.memorySearch?.rememberAcrossConversations ?? + cfg.agents?.defaults?.memorySearch?.rememberAcrossConversations ?? + false + ); +} + function updateActiveMemoryGlobalEnabledInConfig( cfg: OpenClawConfig, enabled: boolean, @@ -289,6 +323,16 @@ function isAllowedChatType( return config.allowedChatTypes.includes(chatType); } +function isPrivateRecallDestination(ctx: { + sessionKey?: string; + messageProvider?: string; + channelId?: string; + mainKey?: string; +}): boolean { + const chatType = resolveChatType(ctx); + return chatType === "direct" || chatType === "explicit"; +} + /** * Best-effort extraction of the conversation id (peer id) embedded in an * agent-scoped session key, using shared session-key utilities so we @@ -366,6 +410,7 @@ function isAllowedChatId( ctx: { sessionKey?: string; messageProvider?: string; + channelId?: string; }, ): boolean { const hasAllowlist = config.allowedChatIds.length > 0; @@ -373,7 +418,10 @@ function isAllowedChatId( if (!hasAllowlist && !hasDenylist) { return true; } - const conversationId = resolveConversationId(ctx); + // dmScope=main direct sessions omit the peer id from the key. Fall back to + // the trusted hook chat id so allow/deny lists still apply. + const conversationId = + (resolveConversationId(ctx) ?? ctx.channelId?.trim())?.toLowerCase() || undefined; if (hasAllowlist) { if (!conversationId) { return false; @@ -392,14 +440,18 @@ export { ACTIVE_MEMORY_GLOBAL_MUTATION_ADMIN_REQUIRED_TEXT, formatActiveMemoryCommandHelp, isActiveMemoryGloballyEnabled, + isActiveMemoryPluginEnabled, isAllowedChatId, isAllowedChatType, isEligibleInteractiveSession, isEnabledForAgent, + isPrivateRecallDestination, isSessionActiveMemoryDisabled, + hasRememberAcrossConversationsAgent, lacksAdminToMutateActiveMemoryGlobal, resolveCommandSessionKey, setSessionActiveMemoryDisabled, shouldSkipActiveMemoryForHarnessSession, + shouldRememberAcrossConversations, updateActiveMemoryGlobalEnabledInConfig, }; diff --git a/extensions/active-memory/types.ts b/extensions/active-memory/types.ts index a1250a0601e3..a355962de247 100644 --- a/extensions/active-memory/types.ts +++ b/extensions/active-memory/types.ts @@ -1,3 +1,4 @@ +import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry"; import type { SessionTranscriptTargetParams } from "openclaw/plugin-sdk/session-transcript-runtime"; const DEFAULT_TIMEOUT_MS = 15_000; @@ -318,6 +319,7 @@ type ActiveMemoryThinkingLevel = | "adaptive" | "max"; type ActiveMemoryFastMode = boolean | "auto"; +type ConversationRecallContext = NonNullable; type ActiveMemoryPromptStyle = | "balanced" | "strict" @@ -402,6 +404,7 @@ export type { ActiveRecallResult, CachedActiveRecallResult, CircuitBreakerEntry, + ConversationRecallContext, PluginDebugEntry, RecallSubagentResult, ResolvedActiveRecallPluginConfig, diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index 419d4329d505..e2ab88a8ad08 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -34,6 +34,7 @@ type MemoryToolOptions = { agentSessionKey?: string; sandboxed?: boolean; oneShotCliRun?: boolean; + conversationRecall?: OpenClawPluginToolContext["conversationRecall"]; acquireLocalService?: MemoryCoreAcquireLocalService; withLease?: PluginStateLeaseRunner; }; @@ -158,6 +159,7 @@ function resolveMemoryToolOptions( agentSessionKey: ctx.sessionKey, sandboxed: ctx.sandboxed, oneShotCliRun: ctx.oneShotCliRun, + conversationRecall: ctx.conversationRecall, ...(host.acquireLocalService ? { acquireLocalService: host.acquireLocalService } : {}), ...(host.withLease ? { withLease: host.withLease } : {}), }; diff --git a/extensions/memory-core/src/memory-tool-manager.test-mocks.ts b/extensions/memory-core/src/memory-tool-manager.test-mocks.ts index 8110d896beba..706fd2910bda 100644 --- a/extensions/memory-core/src/memory-tool-manager.test-mocks.ts +++ b/extensions/memory-core/src/memory-tool-manager.test-mocks.ts @@ -1,4 +1,5 @@ // Memory Core plugin module implements memory tool manager mock behavior. +import type { MemorySource } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; import type { MemorySearchRuntimeDebug } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; import type { PluginStateLeaseRunner } from "openclaw/plugin-sdk/plugin-state-runtime"; import { vi } from "vitest"; @@ -11,6 +12,7 @@ type SearchImpl = (opts?: { qmdSearchModeOverride?: "query" | "search" | "vsearch"; onDebug?: (debug: MemorySearchRuntimeDebug) => void; signal?: AbortSignal; + sources?: MemorySource[]; [key: symbol]: ((action: "pause" | "resume" | "handoff") => void) | undefined; }) => Promise; export type MemoryReadParams = { relPath: string; from?: number; lines?: number }; diff --git a/extensions/memory-core/src/memory/index.test.ts b/extensions/memory-core/src/memory/index.test.ts index d5f580cdab7c..fee22b6e5320 100644 --- a/extensions/memory-core/src/memory/index.test.ts +++ b/extensions/memory-core/src/memory/index.test.ts @@ -362,6 +362,7 @@ describe("memory index", () => { extraPaths?: string[]; sources?: Array<"memory" | "sessions">; sessionMemory?: boolean; + rememberAcrossConversations?: boolean; provider?: string; fallback?: "none" | "gemini" | "fallback-provider"; providerAliases?: NonNullable["providers"]>; @@ -411,6 +412,7 @@ describe("memory index", () => { extraPaths: params.extraPaths, multimodal: params.multimodal, sources: params.sources, + rememberAcrossConversations: params.rememberAcrossConversations, experimental: { sessionMemory: params.sessionMemory ?? false }, }, }, @@ -2976,6 +2978,47 @@ describe("memory index", () => { restoreMemoryIndexStateDir(); } }); + it("keeps remember-only session transcripts out of ordinary manager searches", async () => { + forceNoProvider = true; + setMemoryIndexStateDir(path.join(workspaceDir, ".state-remember-search-sources")); + try { + const cfg = createCfg({ + rememberAcrossConversations: true, + minScore: 0, + hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 }, + }); + const manager = await getFreshManager(cfg); + managersForCleanup.add(manager); + if (!manager.status().fts?.available) { + return; + } + + await seedMemoryIndexSessionTranscript({ + sessionId: "remember-only", + messages: [ + { + role: "assistant", + timestamp: "2026-04-07T15:25:04.113Z", + content: "Recall-only canary is NEBULA-47.", + }, + ], + }); + + await manager.sync({ reason: "test", force: true }); + + await expect( + manager.search("Recall-only canary NEBULA-47", { minScore: 0 }), + ).resolves.toEqual([]); + const trustedResults = await manager.search("Recall-only canary NEBULA-47", { + minScore: 0, + sources: ["sessions"], + }); + expect(trustedResults[0]?.source).toBe("sessions"); + } finally { + restoreMemoryIndexStateDir(); + } + }); + it("status-purpose manager detects unindexed session transcripts as dirty", async () => { // Regression test for #97814: plain openclaw memory status (purpose: status) // must report dirty=true when session files exist without index rows. diff --git a/extensions/memory-core/src/memory/manager.ts b/extensions/memory-core/src/memory/manager.ts index 6b588efd8abe..a74baae5d4fa 100644 --- a/extensions/memory-core/src/memory/manager.ts +++ b/extensions/memory-core/src/memory/manager.ts @@ -779,7 +779,10 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem ) { return []; } - const sourceFilterList = searchSources ?? [...this.sources]; + // The manager may index recall-only transcripts without making them part of + // ordinary searches. Trusted recall passes an explicit source override; + // every other caller defaults to the configured search corpus. + const sourceFilterList = searchSources ?? this.settings.searchSources; const hybrid = this.settings.query.hybrid; const candidates = Math.min( 200, diff --git a/extensions/memory-core/src/memory/qmd-document-resolver.ts b/extensions/memory-core/src/memory/qmd-document-resolver.ts index 56caedbfc289..aecda9c3c3be 100644 --- a/extensions/memory-core/src/memory/qmd-document-resolver.ts +++ b/extensions/memory-core/src/memory/qmd-document-resolver.ts @@ -41,6 +41,7 @@ export class QmdDocumentResolver { private readonly workspaceDir: string, private readonly collectionRoots: ReadonlyMap, private readonly ensureDb: () => SqliteDatabase, + private readonly sessionCollectionsReadable: boolean, ) {} clearCache(): void { @@ -154,6 +155,12 @@ export class QmdDocumentResolver { if (!rootResult) { throw new Error(`unknown qmd collection: ${collection}`); } + // Remember-only session exports are search-only for trusted recall; + // memory_get may read them only when the operator explicitly enabled + // memory.qmd.sessions. + if (rootResult.kind === "sessions" && !this.sessionCollectionsReadable) { + throw new Error("path required"); + } const resolved = path.resolve(rootResult.path, rest.join("/")); if (!isPathInside(rootResult.path, resolved)) { throw new Error("qmd path escapes collection"); @@ -300,6 +307,10 @@ export class QmdDocumentResolver { private isIndexedWorkspaceReadPath(absPath: string): boolean { const normalizedAbsPath = path.normalize(absPath); for (const [collection, rootValue] of this.collectionRoots.entries()) { + // Apply the same read gate to workspace-relative indexed-path resolution. + if (rootValue.kind === "sessions" && !this.sessionCollectionsReadable) { + continue; + } if (!isPathInside(rootValue.path, normalizedAbsPath)) { continue; } diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 72c9579d4b6a..960db829c797 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -737,6 +737,44 @@ describe("QmdMemoryManager", () => { expect(secondDebug.at(-1)?.qmd?.searchPlan?.sources).toEqual(["sessions"]); }); + it("keeps remember-only session exports out of ordinary manager searches", async () => { + cfg = { + ...cfg, + agents: { + ...cfg.agents, + list: [{ id: "main", memorySearch: { rememberAcrossConversations: true } }], + }, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "search") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", "[]"); + return child; + } + return createMockChild(); + }); + const { manager } = await createManager({ mode: "cli" }); + + await manager.search("remember-only", { + sessionKey: "agent:main:cli:direct:memory-search", + }); + + const searchCalls = spawnMock.mock.calls + .filter(([, args]) => args[0] === "search") + .map(([, args]) => args); + expect(searchCalls.some((args) => args.includes("workspace-main"))).toBe(true); + expect(searchCalls.some((args) => args.includes("sessions-main"))).toBe(false); + await manager.close(); + }); + it("rewrites stale multi-collection probe cache when combined filters are rejected", async () => { await configureMemoryCoreDreamingStateForTests(); const otherWorkspaceDir = path.join(tmpRoot, "other-workspace"); @@ -6537,6 +6575,56 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("blocks memory_get reads of remember-only session exports", async () => { + cfg = { + ...cfg, + agents: { + ...cfg.agents, + list: [{ id: "main", memorySearch: { rememberAcrossConversations: true } }], + }, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + const { manager } = await createManager(); + + // Remember-only export is search-only for trusted recall; ordinary + // memory_get must not read transcript exports the operator never opted into. + await expect(manager.readFile({ relPath: "qmd/sessions-main/export.md" })).rejects.toThrow( + "path required", + ); + + await manager.close(); + }); + + it("keeps explicitly configured session exports readable via memory_get", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + sessions: { enabled: true }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + const { manager } = await createManager(); + + await expect(manager.readFile({ relPath: "qmd/sessions-main/export.md" })).resolves.toEqual({ + path: "qmd/sessions-main/export.md", + text: "", + }); + + await manager.close(); + }); + it("rejects non-memory workspace markdown reads", async () => { await fs.writeFile(path.join(workspaceDir, "window.md"), "secret", "utf-8"); await fs.mkdir(path.join(workspaceDir, ".memory"), { recursive: true }); diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 9f26c5af3cbc..498c99412ee2 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -380,8 +380,11 @@ export class QmdMemoryManager implements MemorySearchManager { async (args, opts) => await this.commands.run(args, opts), async (signal) => await this.buildQmdCollectionValidationCacheContext(signal), ); - this.documentResolver = new QmdDocumentResolver(this.workspaceDir, this.collectionRoots, () => - this.ensureDb(), + this.documentResolver = new QmdDocumentResolver( + this.workspaceDir, + this.collectionRoots, + () => this.ensureDb(), + this.qmd.sessions.readable, ); this.closeSignal = new Promise((resolve) => { this.resolveCloseSignal = resolve; @@ -731,7 +734,14 @@ export class QmdMemoryManager implements MemorySearchManager { this.qmd.limits.maxResults, opts?.maxResults ?? this.qmd.limits.maxResults, ); - const requestedSources = opts?.sources?.length ? uniqueValues(opts.sources) : undefined; + // Remember-only session exports are indexed for trusted recall but are not + // part of ordinary manager searches. Explicit export keeps its existing + // ordinary-access behavior; trusted recall always passes sources=sessions. + const requestedSources = opts?.sources?.length + ? uniqueValues(opts.sources) + : this.qmd.sessions.readable + ? undefined + : (["memory"] satisfies MemorySource[]); const collectionNames = this.listManagedCollectionNames(requestedSources); const limit = resultLimit; if (collectionNames.length === 0) { diff --git a/extensions/memory-core/src/session-search-visibility.cross-agent.test.ts b/extensions/memory-core/src/session-search-visibility.cross-agent.test.ts new file mode 100644 index 000000000000..7ed925047155 --- /dev/null +++ b/extensions/memory-core/src/session-search-visibility.cross-agent.test.ts @@ -0,0 +1,322 @@ +// Memory Core tests cover cross-agent session search visibility behavior. +import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; +import * as sessionTranscriptHit from "openclaw/plugin-sdk/session-transcript-hit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { filterMemorySearchHitsBySessionVisibility } from "./session-search-visibility.js"; +import { asOpenClawConfig } from "./tools.test-helpers.js"; + +type TestSessionEntry = { + sessionId: string; + updatedAt: number; + sessionFile: string; + chatType?: "direct" | "group" | "channel"; + origin?: { chatType?: "direct" | "group" | "channel" }; +}; + +const crossAgentStore: Record = { + "agent:peer:only": { + sessionId: "w1", + updatedAt: 1, + sessionFile: "/tmp/sessions/w1.jsonl", + }, +}; +let combinedSessionStore: Record = crossAgentStore; + +vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadCombinedSessionStoreForGateway: vi.fn(() => ({ + storePath: "(test)", + store: combinedSessionStore, + })), + }; +}); + +describe("filterMemorySearchHitsBySessionVisibility across agents", () => { + afterEach(() => { + vi.mocked(sessionTranscriptHit.loadCombinedSessionStoreForGateway).mockClear(); + combinedSessionStore = crossAgentStore; + }); + + it("keeps same-agent session hits when visibility=all and agent-to-agent is enabled", async () => { + combinedSessionStore = { + "agent:main:only": { + sessionId: "w1", + updatedAt: 1, + sessionFile: "/tmp/sessions/w1.jsonl", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/w1.jsonl", + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "all" }, + agentToAgent: { enabled: true, allow: ["*"] }, + }, + }); + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:main", + sandboxed: false, + hits: [hit], + }); + expect(filtered).toEqual([hit]); + }); + + it("keeps built-in live SQLite session hits with agent-scoped logical paths", async () => { + combinedSessionStore = { + "agent:main:only": { + sessionId: "w1", + updatedAt: 1, + sessionFile: "sqlite-session://main/w1", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/main/w1.jsonl", + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "all" }, + agentToAgent: { enabled: true, allow: ["*"] }, + }, + }); + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:main", + sandboxed: false, + hits: [hit], + }); + expect(filtered).toEqual([hit]); + }); + + it("keeps global-scope session hits for non-default agents", async () => { + combinedSessionStore = { + global: { + sessionId: "w1", + updatedAt: 1, + sessionFile: "/tmp/sessions/w1.jsonl", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/w1.jsonl", + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ + session: { scope: "global" }, + tools: { + sessions: { visibility: "all" }, + agentToAgent: { enabled: true, allow: ["*"] }, + }, + }); + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + agentId: "secondary", + requesterSessionKey: "agent:secondary:main", + sandboxed: false, + hits: [hit], + }); + expect(filtered).toEqual([hit]); + }); + + it("does not keep cross-agent session hits outside the scoped store", async () => { + combinedSessionStore = {}; + const hit: MemorySearchResult = { + path: "sessions/w1.jsonl", + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "all" }, + agentToAgent: { enabled: true, allow: ["*"] }, + }, + }); + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:main", + sandboxed: false, + hits: [hit], + }); + expect(filtered).toStrictEqual([]); + }); + + it("does not keep cross-agent session hits when a shared store returns out-of-scope keys", async () => { + combinedSessionStore = crossAgentStore; + const hit: MemorySearchResult = { + path: "sessions/w1.jsonl", + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "all" }, + agentToAgent: { enabled: true, allow: ["*"] }, + }, + }); + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:main", + sandboxed: false, + hits: [hit], + }); + expect(filtered).toStrictEqual([]); + }); + + it("does not keep owner-qualified cross-agent hits that collide with a scoped stem", async () => { + combinedSessionStore = { + "agent:main:main": { + sessionId: "main", + updatedAt: 1, + sessionFile: "/tmp/sessions/main.jsonl", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/peer/main.jsonl", + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "all" }, + agentToAgent: { enabled: true, allow: ["*"] }, + }, + }); + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:main", + sandboxed: false, + hits: [hit], + }); + expect(filtered).toStrictEqual([]); + }); + + it("denies cross-agent session hits when agent-to-agent is disabled", async () => { + const hit: MemorySearchResult = { + path: "sessions/w1.jsonl", + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "all" }, + agentToAgent: { enabled: false }, + }, + }); + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:main", + sandboxed: false, + hits: [hit], + }); + expect(filtered).toStrictEqual([]); + }); + + it("keeps same-agent deleted archive hits using owner metadata when the live store entry is gone", async () => { + combinedSessionStore = {}; + const hit: MemorySearchResult = { + path: "sessions/main/deleted-stem.jsonl.deleted.2026-02-16T22-27-33.000Z", + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "agent" }, + }, + }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:main", + sandboxed: false, + hits: [hit], + }); + + expect(filtered).toEqual([hit]); + }); + + it("still denies cross-agent deleted archive hits resolved from owner metadata when a2a is disabled", async () => { + combinedSessionStore = {}; + const hit: MemorySearchResult = { + path: "sessions/peer/deleted-stem.jsonl.deleted.2026-02-16T22-27-33.000Z", + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "all" }, + agentToAgent: { enabled: false }, + }, + }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:main", + sandboxed: false, + hits: [hit], + }); + + expect(filtered).toStrictEqual([]); + }); + + it("does not keep cross-agent deleted archive hits outside the scoped store when a2a is allowed", async () => { + combinedSessionStore = {}; + const hit: MemorySearchResult = { + path: "sessions/peer/deleted-stem.jsonl.deleted.2026-02-16T22-27-33.000Z", + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "all" }, + agentToAgent: { enabled: true, allow: ["*"] }, + }, + }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:main", + sandboxed: false, + hits: [hit], + }); + + expect(filtered).toStrictEqual([]); + }); +}); diff --git a/extensions/memory-core/src/session-search-visibility.qmd.test.ts b/extensions/memory-core/src/session-search-visibility.qmd.test.ts new file mode 100644 index 000000000000..97717e49ba56 --- /dev/null +++ b/extensions/memory-core/src/session-search-visibility.qmd.test.ts @@ -0,0 +1,527 @@ +// Memory Core tests cover QMD session search visibility behavior. +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; +import * as sessionTranscriptHit from "openclaw/plugin-sdk/session-transcript-hit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + attachQmdSessionArtifactHit, + copyQmdSessionArtifactHit, + replaceQmdSessionArtifactMappings, + resolveQmdSessionArtifactIdentity, +} from "./qmd-session-artifacts.js"; +import { filterMemorySearchHitsBySessionVisibility } from "./session-search-visibility.js"; +import { asOpenClawConfig } from "./tools.test-helpers.js"; + +type TestSessionEntry = { + sessionId: string; + updatedAt: number; + sessionFile: string; + chatType?: "direct" | "group" | "channel"; + origin?: { chatType?: "direct" | "group" | "channel" }; +}; + +const crossAgentStore: Record = { + "agent:peer:only": { + sessionId: "w1", + updatedAt: 1, + sessionFile: "/tmp/sessions/w1.jsonl", + }, +}; +let combinedSessionStore: Record = crossAgentStore; +const tempRoots: string[] = []; + +vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadCombinedSessionStoreForGateway: vi.fn(() => ({ + storePath: "(test)", + store: combinedSessionStore, + })), + }; +}); + +describe("filterMemorySearchHitsBySessionVisibility for QMD", () => { + afterEach(async () => { + vi.mocked(sessionTranscriptHit.loadCombinedSessionStoreForGateway).mockClear(); + combinedSessionStore = crossAgentStore; + while (tempRoots.length > 0) { + const root = tempRoots.pop(); + if (root) { + await fs.rm(root, { recursive: true, force: true }); + } + } + }); + + async function createQmdArtifactIndex(params: { + agentId: string; + archived?: boolean; + artifactPath: string; + collection: string; + searchPath: string; + sessionId: string; + }): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-session-artifact-")); + tempRoots.push(root); + const indexPath = path.join(root, "index.sqlite"); + replaceQmdSessionArtifactMappings({ + collection: params.collection, + indexPath, + mappings: [ + { + agentId: params.agentId, + archived: params.archived === true, + artifactPath: params.artifactPath, + collection: params.collection, + memoryKey: sessionTranscriptHit.formatSessionTranscriptMemoryHitKey({ + agentId: params.agentId, + sessionId: params.sessionId, + }), + searchPath: params.searchPath, + sessionId: params.sessionId, + }, + ], + }); + return indexPath; + } + + function attachMappedQmdHit( + hit: MemorySearchResult, + lookup: Parameters[0], + ): MemorySearchResult { + const identity = resolveQmdSessionArtifactIdentity(lookup); + if (!identity) { + throw new Error("expected QMD session artifact identity mapping"); + } + return attachQmdSessionArtifactHit(hit, identity); + } + + it("keeps same-agent QMD-normalized archived reset .md hits when the store has a matching entry", async () => { + combinedSessionStore = { + "agent:main:abc-uuid": { + sessionId: "abc-uuid", + updatedAt: 1, + sessionFile: "/tmp/sessions/abc-uuid.jsonl", + }, + }; + const hit: MemorySearchResult = { + path: "qmd/sessions-main/abc-uuid-jsonl-reset-2026-02-16t22-26-33-000z.md", + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "agent" }, + }, + }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:main", + sandboxed: false, + hits: [hit], + }); + + expect(filtered).toEqual([hit]); + }); + + it("keeps QMD .md hits whose live session id looks like an archive name", async () => { + const sessionId = "foo.jsonl.deleted.2026-02-16T22-27-33.000Z"; + combinedSessionStore = { + "agent:main:archive-looking": { + sessionId, + updatedAt: 1, + sessionFile: `/tmp/sessions/${sessionId}.jsonl`, + }, + }; + const hit: MemorySearchResult = { + path: `qmd/sessions-main/${sessionId}.md`, + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "self" }, + }, + }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:archive-looking", + sandboxed: false, + hits: [hit], + }); + + expect(filtered).toEqual([hit]); + }); + + it("does not authorize QMD archived .md hits through lossy slug fallback", async () => { + combinedSessionStore = { + "agent:main:foo_bar": { + sessionId: "foo_bar", + updatedAt: 1, + sessionFile: "/tmp/sessions/foo_bar.jsonl", + }, + }; + const hit: MemorySearchResult = { + path: "qmd/sessions-main/foo-bar-jsonl-deleted-2026-02-16t22-26-33-000z.md", + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "self" }, + }, + }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:foo_bar", + sandboxed: false, + hits: [hit], + }); + + expect(filtered).toStrictEqual([]); + }); + + it("keeps mapped QMD session hits when the artifact filename no longer matches the session id", async () => { + combinedSessionStore = { + "agent:main:actual-key": { + sessionId: "actual-session-id", + updatedAt: 1, + sessionFile: "/tmp/sessions/actual-session-id.jsonl", + }, + }; + const searchPath = "qmd/sessions-main/lossy-export-name.md"; + const indexPath = await createQmdArtifactIndex({ + agentId: "main", + artifactPath: "lossy-export-name.md", + collection: "sessions-main", + searchPath, + sessionId: "actual-session-id", + }); + const hit = attachMappedQmdHit( + { + path: searchPath, + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }, + { + artifactPath: "lossy-export-name.md", + collection: "sessions-main", + indexPath, + searchPath, + }, + ); + const copiedHit = copyQmdSessionArtifactHit(hit, { ...hit, snippet: "trimmed" }); + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "self" }, + }, + }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:actual-key", + sandboxed: false, + hits: [copiedHit], + }); + + expect(filtered).toEqual([copiedHit]); + }); + + it("denies mapped QMD hits whose transcript file also has a shared group alias", async () => { + combinedSessionStore = { + "agent:main:telegram:direct:owner": { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + "agent:main:explicit:laptop": { + sessionId: "actual-session-id", + updatedAt: 1, + sessionFile: "/tmp/sessions/shared-transcript.jsonl", + chatType: "direct", + }, + // Same transcript file exposed under a group alias with a different + // sessionId: session-id alias resolution alone would miss this. + "agent:main:telegram:group:team": { + sessionId: "group-alias-id", + updatedAt: 1, + sessionFile: "/tmp/sessions/shared-transcript.jsonl", + chatType: "group", + }, + }; + const searchPath = "qmd/sessions-main/shared-transcript-export.md"; + const indexPath = await createQmdArtifactIndex({ + agentId: "main", + artifactPath: "shared-transcript-export.md", + collection: "sessions-main", + searchPath, + sessionId: "actual-session-id", + }); + const hit = attachMappedQmdHit( + { + path: searchPath, + source: "sessions", + score: 1, + snippet: "private context", + startLine: 1, + endLine: 2, + }, + { + artifactPath: "shared-transcript-export.md", + collection: "sessions-main", + indexPath, + searchPath, + }, + ); + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:telegram:direct:owner", + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey: "agent:main:telegram:direct:owner", + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + expect(filtered).toStrictEqual([]); + }); + + it("allows mapped QMD hits for another same-agent private conversation", async () => { + combinedSessionStore = { + "agent:main:telegram:direct:owner": { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + "agent:main:explicit:laptop": { + sessionId: "actual-session-id", + updatedAt: 1, + sessionFile: "/tmp/sessions/actual-session-id.jsonl", + chatType: "direct", + }, + }; + const searchPath = "qmd/sessions-main/lossy-export-name.md"; + const indexPath = await createQmdArtifactIndex({ + agentId: "main", + artifactPath: "lossy-export-name.md", + collection: "sessions-main", + searchPath, + sessionId: "actual-session-id", + }); + const hit = attachMappedQmdHit( + { + path: searchPath, + source: "sessions", + score: 1, + snippet: "private context", + startLine: 1, + endLine: 2, + }, + { + artifactPath: "lossy-export-name.md", + collection: "sessions-main", + indexPath, + searchPath, + }, + ); + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:telegram:direct:owner", + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey: "agent:main:telegram:direct:owner", + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + expect(filtered).toEqual([hit]); + }); + + it("denies mapped live QMD session hits when no session-store key remains", async () => { + combinedSessionStore = {}; + const searchPath = "qmd/sessions-main/orphan-live.md"; + const indexPath = await createQmdArtifactIndex({ + agentId: "main", + artifactPath: "orphan-live.md", + collection: "sessions-main", + searchPath, + sessionId: "orphan-live", + }); + const hit = attachMappedQmdHit( + { + path: searchPath, + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }, + { + artifactPath: "orphan-live.md", + collection: "sessions-main", + indexPath, + searchPath, + }, + ); + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "agent" }, + }, + }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:main", + sandboxed: false, + hits: [hit], + }); + + expect(filtered).toStrictEqual([]); + }); + + it("keeps mapped archived QMD session hits when no session-store key remains", async () => { + combinedSessionStore = {}; + const searchPath = "qmd/sessions-main/archived-jsonl-deleted-2026-02-16t22-26-33-000z.md"; + const indexPath = await createQmdArtifactIndex({ + agentId: "main", + archived: true, + artifactPath: "archived-jsonl-deleted-2026-02-16t22-26-33-000z.md", + collection: "sessions-main", + searchPath, + sessionId: "archived", + }); + const hit = attachMappedQmdHit( + { + path: searchPath, + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }, + { + artifactPath: "archived-jsonl-deleted-2026-02-16t22-26-33-000z.md", + collection: "sessions-main", + indexPath, + searchPath, + }, + ); + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "all" }, + }, + }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:main", + sandboxed: false, + hits: [hit], + }); + + expect(filtered).toEqual([hit]); + }); + + it("denies mapped QMD session hits before deprecated filename fallback", async () => { + combinedSessionStore = { + "agent:main:visible": { + sessionId: "visible", + updatedAt: 1, + sessionFile: "/tmp/sessions/visible.jsonl", + }, + }; + const searchPath = "qmd/sessions-main/visible.md"; + const indexPath = await createQmdArtifactIndex({ + agentId: "peer", + artifactPath: "visible.md", + collection: "sessions-main", + searchPath, + sessionId: "peer-session", + }); + const hit = attachMappedQmdHit( + { + path: searchPath, + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }, + { + artifactPath: "visible.md", + collection: "sessions-main", + indexPath, + searchPath, + }, + ); + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "all" }, + agentToAgent: { enabled: true, allow: ["*"] }, + }, + }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:visible", + sandboxed: false, + hits: [hit], + }); + + expect(filtered).toStrictEqual([]); + }); + + it("keeps same-agent QMD archived deleted .md hits when no store entry remains", async () => { + combinedSessionStore = {}; + const hit: MemorySearchResult = { + path: "qmd/sessions-main/abc-uuid-jsonl-deleted-2026-02-16t22-26-33-000z.md", + source: "sessions", + score: 1, + snippet: "x", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ + tools: { + sessions: { visibility: "all" }, + }, + }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:main", + sandboxed: false, + hits: [hit], + }); + + expect(filtered).toEqual([hit]); + }); +}); diff --git a/extensions/memory-core/src/session-search-visibility.test.ts b/extensions/memory-core/src/session-search-visibility.test.ts index 40fc8bfbbcc1..0baa473a3b06 100644 --- a/extensions/memory-core/src/session-search-visibility.test.ts +++ b/extensions/memory-core/src/session-search-visibility.test.ts @@ -6,12 +6,7 @@ import { DatabaseSync } from "node:sqlite"; import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; import * as sessionTranscriptHit from "openclaw/plugin-sdk/session-transcript-hit"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { - attachQmdSessionArtifactHit, - copyQmdSessionArtifactHit, - replaceQmdSessionArtifactMappings, - resolveQmdSessionArtifactIdentity, -} from "./qmd-session-artifacts.js"; +import { replaceQmdSessionArtifactMappings } from "./qmd-session-artifacts.js"; import { filterMemorySearchHitsBySessionVisibility } from "./session-search-visibility.js"; import { asOpenClawConfig } from "./tools.test-helpers.js"; @@ -19,6 +14,8 @@ type TestSessionEntry = { sessionId: string; updatedAt: number; sessionFile: string; + chatType?: "direct" | "group" | "channel"; + origin?: { chatType?: "direct" | "group" | "channel" }; }; const crossAgentStore: Record = { @@ -55,49 +52,6 @@ describe("filterMemorySearchHitsBySessionVisibility", () => { } }); - async function createQmdArtifactIndex(params: { - agentId: string; - archived?: boolean; - artifactPath: string; - collection: string; - searchPath: string; - sessionId: string; - }): Promise { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-session-artifact-")); - tempRoots.push(root); - const indexPath = path.join(root, "index.sqlite"); - replaceQmdSessionArtifactMappings({ - collection: params.collection, - indexPath, - mappings: [ - { - agentId: params.agentId, - archived: params.archived === true, - artifactPath: params.artifactPath, - collection: params.collection, - memoryKey: sessionTranscriptHit.formatSessionTranscriptMemoryHitKey({ - agentId: params.agentId, - sessionId: params.sessionId, - }), - searchPath: params.searchPath, - sessionId: params.sessionId, - }, - ], - }); - return indexPath; - } - - function attachMappedQmdHit( - hit: MemorySearchResult, - lookup: Parameters[0], - ): MemorySearchResult { - const identity = resolveQmdSessionArtifactIdentity(lookup); - if (!identity) { - throw new Error("expected QMD session artifact identity mapping"); - } - return attachQmdSessionArtifactHit(hit, identity); - } - it("migrates legacy QMD artifact mappings to STRICT without losing rows", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-session-artifact-")); tempRoots.push(root); @@ -212,6 +166,749 @@ describe("filterMemorySearchHitsBySessionVisibility", () => { expect(filtered).toEqual(hits); }); + it("allows another same-agent private transcript through trusted conversation recall", async () => { + combinedSessionStore = { + "agent:main:telegram:direct:owner": { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + "agent:main:webchat:direct:owner": { + sessionId: "past", + updatedAt: 1, + sessionFile: "/tmp/sessions/past.jsonl", + chatType: "direct", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/past.jsonl", + source: "sessions", + score: 1, + snippet: "private context", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:telegram:direct:owner", + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey: "agent:main:telegram:direct:owner", + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + expect(filtered).toEqual([hit]); + }); + + it("allows an agent-scoped builtin hit for an Active Memory private requester", async () => { + const anchorSessionKey = "agent:qa:qa-channel:direct:dm:remember-target"; + combinedSessionStore = { + [anchorSessionKey]: { + sessionId: "target-id", + updatedAt: 2, + sessionFile: "/tmp/sessions/target-id.jsonl", + chatType: "direct", + }, + "agent:qa:qa-channel:direct:dm:remember-source": { + sessionId: "source-id", + updatedAt: 1, + sessionFile: "/tmp/sessions/source-id.jsonl", + chatType: "direct", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/qa/source-id.jsonl", + source: "sessions", + score: 1, + snippet: "private context", + startLine: 1, + endLine: 2, + }; + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg: asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }), + agentId: "qa", + requesterSessionKey: `${anchorSessionKey}:active-memory:7e1ee8190516`, + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey, + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + expect(filtered).toEqual([hit]); + }); + + it("allows recognized explicit private sessions with persisted direct metadata", async () => { + const anchorSessionKey = "agent:main:explicit:laptop"; + combinedSessionStore = { + [anchorSessionKey]: { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + origin: { chatType: "direct" }, + }, + "agent:main:explicit:phone:group:shadow": { + sessionId: "explicit-private", + updatedAt: 1, + sessionFile: "/tmp/sessions/explicit-private.jsonl", + chatType: "direct", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/explicit-private.jsonl", + source: "sessions", + score: 1, + snippet: "private context", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: `${anchorSessionKey}:active-memory:123456abcdef`, + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey, + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + expect(filtered).toEqual([hit]); + }); + + it("denies recall when the anchor transcript also has a shared group alias", async () => { + combinedSessionStore = { + "agent:main:telegram:direct:owner": { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + "agent:main:telegram:group:team": { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "group", + }, + "agent:main:qa-channel:direct:dm:friend": { + sessionId: "other-private", + updatedAt: 1, + sessionFile: "/tmp/sessions/other-private.jsonl", + chatType: "direct", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/other-private.jsonl", + source: "sessions", + score: 1, + snippet: "private context", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:telegram:direct:owner", + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey: "agent:main:telegram:direct:owner", + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + expect(filtered).toStrictEqual([]); + }); + + it("denies the shared global session as a recall source despite direct metadata", async () => { + combinedSessionStore = { + "agent:main:telegram:direct:owner": { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + global: { + sessionId: "global-shared", + updatedAt: 1, + sessionFile: "/tmp/sessions/global-shared.jsonl", + chatType: "direct", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/global-shared.jsonl", + source: "sessions", + score: 1, + snippet: "shared global context", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ + session: { scope: "global" }, + tools: { sessions: { visibility: "self" } }, + }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:telegram:direct:owner", + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey: "agent:main:telegram:direct:owner", + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + expect(filtered).toStrictEqual([]); + }); + + it("denies recall anchored in the shared global session despite direct metadata", async () => { + combinedSessionStore = { + "agent:main:global": { + sessionId: "global-shared", + updatedAt: 2, + sessionFile: "/tmp/sessions/global-shared.jsonl", + chatType: "direct", + }, + "agent:main:qa-channel:direct:dm:friend": { + sessionId: "other-private", + updatedAt: 1, + sessionFile: "/tmp/sessions/other-private.jsonl", + chatType: "direct", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/other-private.jsonl", + source: "sessions", + score: 1, + snippet: "private context", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ + session: { scope: "global" }, + tools: { sessions: { visibility: "self" } }, + }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:global", + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey: "agent:main:global", + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + expect(filtered).toStrictEqual([]); + }); + + it("denies a metadata-less generated explicit model-run transcript", async () => { + combinedSessionStore = { + "agent:main:telegram:direct:owner": { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + "agent:main:explicit:model-run-probe": { + sessionId: "model-run-probe", + updatedAt: 1, + sessionFile: "/tmp/sessions/model-run-probe.jsonl", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/model-run-probe.jsonl", + source: "sessions", + score: 1, + snippet: "internal model probe", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:telegram:direct:owner", + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey: "agent:main:telegram:direct:owner", + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + expect(filtered).toStrictEqual([]); + }); + + it("denies another agent's private transcript during trusted conversation recall", async () => { + combinedSessionStore = { + "agent:main:telegram:direct:owner": { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + "agent:peer:telegram:direct:owner": { + sessionId: "peer-private", + updatedAt: 1, + sessionFile: "/tmp/sessions/peer-private.jsonl", + chatType: "direct", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/peer-private.jsonl", + source: "sessions", + score: 1, + snippet: "other agent context", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:telegram:direct:owner", + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey: "agent:main:telegram:direct:owner", + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + expect(filtered).toStrictEqual([]); + }); + + it("denies persisted Active Memory helper transcripts under explicit sessions", async () => { + combinedSessionStore = { + "agent:main:explicit:laptop": { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + "agent:main:explicit:laptop:active-memory:abcdef123456": { + sessionId: "helper", + updatedAt: 1, + sessionFile: "/tmp/sessions/helper.jsonl", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/helper.jsonl", + source: "sessions", + score: 1, + snippet: "internal helper transcript", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "agent" } } }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:explicit:laptop:active-memory:123456abcdef", + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey: "agent:main:explicit:laptop", + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + expect(filtered).toStrictEqual([]); + }); + + it("excludes the anchor transcript from trusted conversation recall", async () => { + combinedSessionStore = { + "agent:main:telegram:direct:owner": { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/current.jsonl", + source: "sessions", + score: 1, + snippet: "already in context", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "agent" } } }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:telegram:direct:owner", + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey: "agent:main:telegram:direct:owner", + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + expect(filtered).toStrictEqual([]); + }); + + it("excludes the anchor transcript when another private key aliases the same session", async () => { + combinedSessionStore = { + "agent:main:telegram:direct:owner": { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + "agent:main:explicit:legacy-owner-alias": { + sessionId: "current", + updatedAt: 1, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/current.jsonl", + source: "sessions", + score: 1, + snippet: "already in context", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "agent" } } }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:telegram:direct:owner", + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey: "agent:main:telegram:direct:owner", + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + expect(filtered).toStrictEqual([]); + }); + + it.each([ + { name: "group", chatType: "group" as const }, + { name: "channel", chatType: "channel" as const }, + { name: "unknown", chatType: undefined }, + ])("denies $name transcript hits from trusted conversation recall", async ({ chatType }) => { + combinedSessionStore = { + "agent:main:telegram:direct:owner": { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + [chatType ? "agent:main:telegram:group:family" : "agent:main:unknown-surface"]: { + sessionId: "candidate", + updatedAt: 1, + sessionFile: "/tmp/sessions/candidate.jsonl", + ...(chatType ? { chatType } : {}), + }, + }; + const hit: MemorySearchResult = { + path: "sessions/candidate.jsonl", + source: "sessions", + score: 1, + snippet: "not private", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "agent" } } }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:telegram:direct:owner", + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey: "agent:main:telegram:direct:owner", + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + expect(filtered).toStrictEqual([]); + }); + + it("rejects a transcript when one alias is private and another alias is shared", async () => { + combinedSessionStore = { + "agent:main:telegram:direct:owner": { + sessionId: "current", + updatedAt: 3, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + "agent:main:telegram:direct:private-alias": { + sessionId: "candidate", + updatedAt: 2, + sessionFile: "/tmp/sessions/candidate.jsonl", + chatType: "direct", + }, + "agent:main:telegram:group:shared-alias": { + sessionId: "candidate", + updatedAt: 1, + sessionFile: "/tmp/sessions/candidate.jsonl", + chatType: "group", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/candidate.jsonl", + source: "sessions", + score: 1, + snippet: "shared transcript", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "agent" } } }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:telegram:direct:owner", + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey: "agent:main:telegram:direct:owner", + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + expect(filtered).toStrictEqual([]); + }); + + it("denies a metadata-less main transcript during trusted conversation recall", async () => { + combinedSessionStore = { + "agent:main:telegram:direct:owner": { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + "agent:main:main": { + sessionId: "ambiguous-main", + updatedAt: 1, + sessionFile: "/tmp/sessions/ambiguous-main.jsonl", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/ambiguous-main.jsonl", + source: "sessions", + score: 1, + snippet: "unknown conversation kind", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "agent" } } }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:telegram:direct:owner", + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey: "agent:main:telegram:direct:owner", + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + expect(filtered).toStrictEqual([]); + }); + + it("rejects a synthetic recall requester that does not start with the anchor key", async () => { + combinedSessionStore = { + "agent:main:main": { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + "agent:main:webchat:direct:owner": { + sessionId: "past", + updatedAt: 1, + sessionFile: "/tmp/sessions/past.jsonl", + chatType: "direct", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/past.jsonl", + source: "sessions", + score: 1, + snippet: "private context", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:xxxx:active-memory:abcdef123456", + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey: "agent:main:main", + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + expect(filtered).toStrictEqual([]); + }); + + it("denies trusted conversation recall when the anchor is shared, mismatched, or sandboxed", async () => { + combinedSessionStore = { + "agent:main:telegram:group:family": { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "group", + }, + "agent:main:webchat:direct:owner": { + sessionId: "past", + updatedAt: 1, + sessionFile: "/tmp/sessions/past.jsonl", + chatType: "direct", + }, + }; + const hit: MemorySearchResult = { + path: "sessions/past.jsonl", + source: "sessions", + score: 1, + snippet: "private context", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "self" } } }); + const conversationRecall = { + anchorSessionKey: "agent:main:telegram:group:family", + scope: "same-agent-private" as const, + corpus: "sessions" as const, + }; + + const [sharedAnchor, mismatchedAnchor, sandboxed] = await Promise.all([ + filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: conversationRecall.anchorSessionKey, + sandboxed: false, + hits: [hit], + conversationRecall, + }), + filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:webchat:direct:owner", + sandboxed: false, + hits: [hit], + conversationRecall, + }), + filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: conversationRecall.anchorSessionKey, + sandboxed: true, + hits: [hit], + conversationRecall, + }), + ]); + + expect(sharedAnchor).toStrictEqual([]); + expect(mismatchedAnchor).toStrictEqual([]); + expect(sandboxed).toStrictEqual([]); + }); + + it("preserves ordinary memory while denying unauthorized configured transcript recall", async () => { + combinedSessionStore = {}; + const memoryHit: MemorySearchResult = { + path: "MEMORY.md", + source: "memory", + score: 1, + snippet: "shared workspace memory", + startLine: 1, + endLine: 2, + }; + const sessionHit: MemorySearchResult = { + path: "sessions/private.jsonl", + source: "sessions", + score: 0.9, + snippet: "private transcript", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:main:active-memory:abcdef123456", + sandboxed: false, + hits: [memoryHit, sessionHit], + conversationRecall: { + anchorSessionKey: "agent:main:main", + scope: "same-agent-private", + corpus: "configured", + }, + }); + + expect(filtered).toEqual([memoryHit]); + }); + + it("restricts trusted sessions-only recall to transcript hits", async () => { + combinedSessionStore = { + "agent:main:telegram:direct:owner": { + sessionId: "current", + updatedAt: 2, + sessionFile: "/tmp/sessions/current.jsonl", + chatType: "direct", + }, + }; + const hit: MemorySearchResult = { + path: "memory/private.md", + source: "memory", + score: 1, + snippet: "workspace memory", + startLine: 1, + endLine: 2, + }; + const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "agent" } } }); + + const filtered = await filterMemorySearchHitsBySessionVisibility({ + cfg, + requesterSessionKey: "agent:main:telegram:direct:owner", + sandboxed: false, + hits: [hit], + conversationRecall: { + anchorSessionKey: "agent:main:telegram:direct:owner", + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + expect(filtered).toStrictEqual([]); + }); + it("loads the combined session store once per filter pass", async () => { const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } }); const hits: MemorySearchResult[] = [ @@ -243,590 +940,4 @@ describe("filterMemorySearchHitsBySessionVisibility", () => { agentId: "main", }); }); - - it("keeps same-agent session hits when visibility=all and agent-to-agent is enabled", async () => { - combinedSessionStore = { - "agent:main:only": { - sessionId: "w1", - updatedAt: 1, - sessionFile: "/tmp/sessions/w1.jsonl", - }, - }; - const hit: MemorySearchResult = { - path: "sessions/w1.jsonl", - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "all" }, - agentToAgent: { enabled: true, allow: ["*"] }, - }, - }); - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:main", - sandboxed: false, - hits: [hit], - }); - expect(filtered).toEqual([hit]); - }); - - it("keeps built-in live SQLite session hits with agent-scoped logical paths", async () => { - combinedSessionStore = { - "agent:main:only": { - sessionId: "w1", - updatedAt: 1, - sessionFile: "sqlite-session://main/w1", - }, - }; - const hit: MemorySearchResult = { - path: "sessions/main/w1.jsonl", - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "all" }, - agentToAgent: { enabled: true, allow: ["*"] }, - }, - }); - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:main", - sandboxed: false, - hits: [hit], - }); - expect(filtered).toEqual([hit]); - }); - - it("keeps global-scope session hits for non-default agents", async () => { - combinedSessionStore = { - global: { - sessionId: "w1", - updatedAt: 1, - sessionFile: "/tmp/sessions/w1.jsonl", - }, - }; - const hit: MemorySearchResult = { - path: "sessions/w1.jsonl", - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ - session: { scope: "global" }, - tools: { - sessions: { visibility: "all" }, - agentToAgent: { enabled: true, allow: ["*"] }, - }, - }); - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - agentId: "secondary", - requesterSessionKey: "agent:secondary:main", - sandboxed: false, - hits: [hit], - }); - expect(filtered).toEqual([hit]); - }); - - it("does not keep cross-agent session hits outside the scoped store", async () => { - combinedSessionStore = {}; - const hit: MemorySearchResult = { - path: "sessions/w1.jsonl", - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "all" }, - agentToAgent: { enabled: true, allow: ["*"] }, - }, - }); - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:main", - sandboxed: false, - hits: [hit], - }); - expect(filtered).toStrictEqual([]); - }); - - it("does not keep cross-agent session hits when a shared store returns out-of-scope keys", async () => { - combinedSessionStore = crossAgentStore; - const hit: MemorySearchResult = { - path: "sessions/w1.jsonl", - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "all" }, - agentToAgent: { enabled: true, allow: ["*"] }, - }, - }); - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:main", - sandboxed: false, - hits: [hit], - }); - expect(filtered).toStrictEqual([]); - }); - - it("does not keep owner-qualified cross-agent hits that collide with a scoped stem", async () => { - combinedSessionStore = { - "agent:main:main": { - sessionId: "main", - updatedAt: 1, - sessionFile: "/tmp/sessions/main.jsonl", - }, - }; - const hit: MemorySearchResult = { - path: "sessions/peer/main.jsonl", - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "all" }, - agentToAgent: { enabled: true, allow: ["*"] }, - }, - }); - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:main", - sandboxed: false, - hits: [hit], - }); - expect(filtered).toStrictEqual([]); - }); - - it("denies cross-agent session hits when agent-to-agent is disabled", async () => { - const hit: MemorySearchResult = { - path: "sessions/w1.jsonl", - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "all" }, - agentToAgent: { enabled: false }, - }, - }); - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:main", - sandboxed: false, - hits: [hit], - }); - expect(filtered).toStrictEqual([]); - }); - - it("keeps same-agent deleted archive hits using owner metadata when the live store entry is gone", async () => { - combinedSessionStore = {}; - const hit: MemorySearchResult = { - path: "sessions/main/deleted-stem.jsonl.deleted.2026-02-16T22-27-33.000Z", - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "agent" }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:main", - sandboxed: false, - hits: [hit], - }); - - expect(filtered).toEqual([hit]); - }); - - it("still denies cross-agent deleted archive hits resolved from owner metadata when a2a is disabled", async () => { - combinedSessionStore = {}; - const hit: MemorySearchResult = { - path: "sessions/peer/deleted-stem.jsonl.deleted.2026-02-16T22-27-33.000Z", - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "all" }, - agentToAgent: { enabled: false }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:main", - sandboxed: false, - hits: [hit], - }); - - expect(filtered).toStrictEqual([]); - }); - - it("does not keep cross-agent deleted archive hits outside the scoped store when a2a is allowed", async () => { - combinedSessionStore = {}; - const hit: MemorySearchResult = { - path: "sessions/peer/deleted-stem.jsonl.deleted.2026-02-16T22-27-33.000Z", - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "all" }, - agentToAgent: { enabled: true, allow: ["*"] }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:main", - sandboxed: false, - hits: [hit], - }); - - expect(filtered).toStrictEqual([]); - }); - - it("keeps same-agent QMD-normalized archived reset .md hits when the store has a matching entry", async () => { - combinedSessionStore = { - "agent:main:abc-uuid": { - sessionId: "abc-uuid", - updatedAt: 1, - sessionFile: "/tmp/sessions/abc-uuid.jsonl", - }, - }; - const hit: MemorySearchResult = { - path: "qmd/sessions-main/abc-uuid-jsonl-reset-2026-02-16t22-26-33-000z.md", - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "agent" }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:main", - sandboxed: false, - hits: [hit], - }); - - expect(filtered).toEqual([hit]); - }); - - it("keeps QMD .md hits whose live session id looks like an archive name", async () => { - const sessionId = "foo.jsonl.deleted.2026-02-16T22-27-33.000Z"; - combinedSessionStore = { - "agent:main:archive-looking": { - sessionId, - updatedAt: 1, - sessionFile: `/tmp/sessions/${sessionId}.jsonl`, - }, - }; - const hit: MemorySearchResult = { - path: `qmd/sessions-main/${sessionId}.md`, - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "self" }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:archive-looking", - sandboxed: false, - hits: [hit], - }); - - expect(filtered).toEqual([hit]); - }); - - it("does not authorize QMD archived .md hits through lossy slug fallback", async () => { - combinedSessionStore = { - "agent:main:foo_bar": { - sessionId: "foo_bar", - updatedAt: 1, - sessionFile: "/tmp/sessions/foo_bar.jsonl", - }, - }; - const hit: MemorySearchResult = { - path: "qmd/sessions-main/foo-bar-jsonl-deleted-2026-02-16t22-26-33-000z.md", - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "self" }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:foo_bar", - sandboxed: false, - hits: [hit], - }); - - expect(filtered).toStrictEqual([]); - }); - - it("keeps mapped QMD session hits when the artifact filename no longer matches the session id", async () => { - combinedSessionStore = { - "agent:main:actual-key": { - sessionId: "actual-session-id", - updatedAt: 1, - sessionFile: "/tmp/sessions/actual-session-id.jsonl", - }, - }; - const searchPath = "qmd/sessions-main/lossy-export-name.md"; - const indexPath = await createQmdArtifactIndex({ - agentId: "main", - artifactPath: "lossy-export-name.md", - collection: "sessions-main", - searchPath, - sessionId: "actual-session-id", - }); - const hit = attachMappedQmdHit( - { - path: searchPath, - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }, - { - artifactPath: "lossy-export-name.md", - collection: "sessions-main", - indexPath, - searchPath, - }, - ); - const copiedHit = copyQmdSessionArtifactHit(hit, { ...hit, snippet: "trimmed" }); - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "self" }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:actual-key", - sandboxed: false, - hits: [copiedHit], - }); - - expect(filtered).toEqual([copiedHit]); - }); - - it("denies mapped live QMD session hits when no session-store key remains", async () => { - combinedSessionStore = {}; - const searchPath = "qmd/sessions-main/orphan-live.md"; - const indexPath = await createQmdArtifactIndex({ - agentId: "main", - artifactPath: "orphan-live.md", - collection: "sessions-main", - searchPath, - sessionId: "orphan-live", - }); - const hit = attachMappedQmdHit( - { - path: searchPath, - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }, - { - artifactPath: "orphan-live.md", - collection: "sessions-main", - indexPath, - searchPath, - }, - ); - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "agent" }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:main", - sandboxed: false, - hits: [hit], - }); - - expect(filtered).toStrictEqual([]); - }); - - it("keeps mapped archived QMD session hits when no session-store key remains", async () => { - combinedSessionStore = {}; - const searchPath = "qmd/sessions-main/archived-jsonl-deleted-2026-02-16t22-26-33-000z.md"; - const indexPath = await createQmdArtifactIndex({ - agentId: "main", - archived: true, - artifactPath: "archived-jsonl-deleted-2026-02-16t22-26-33-000z.md", - collection: "sessions-main", - searchPath, - sessionId: "archived", - }); - const hit = attachMappedQmdHit( - { - path: searchPath, - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }, - { - artifactPath: "archived-jsonl-deleted-2026-02-16t22-26-33-000z.md", - collection: "sessions-main", - indexPath, - searchPath, - }, - ); - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "all" }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:main", - sandboxed: false, - hits: [hit], - }); - - expect(filtered).toEqual([hit]); - }); - - it("denies mapped QMD session hits before deprecated filename fallback", async () => { - combinedSessionStore = { - "agent:main:visible": { - sessionId: "visible", - updatedAt: 1, - sessionFile: "/tmp/sessions/visible.jsonl", - }, - }; - const searchPath = "qmd/sessions-main/visible.md"; - const indexPath = await createQmdArtifactIndex({ - agentId: "peer", - artifactPath: "visible.md", - collection: "sessions-main", - searchPath, - sessionId: "peer-session", - }); - const hit = attachMappedQmdHit( - { - path: searchPath, - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }, - { - artifactPath: "visible.md", - collection: "sessions-main", - indexPath, - searchPath, - }, - ); - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "all" }, - agentToAgent: { enabled: true, allow: ["*"] }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:visible", - sandboxed: false, - hits: [hit], - }); - - expect(filtered).toStrictEqual([]); - }); - - it("keeps same-agent QMD archived deleted .md hits when no store entry remains", async () => { - combinedSessionStore = {}; - const hit: MemorySearchResult = { - path: "qmd/sessions-main/abc-uuid-jsonl-deleted-2026-02-16t22-26-33-000z.md", - source: "sessions", - score: 1, - snippet: "x", - startLine: 1, - endLine: 2, - }; - const cfg = asOpenClawConfig({ - tools: { - sessions: { visibility: "all" }, - }, - }); - - const filtered = await filterMemorySearchHitsBySessionVisibility({ - cfg, - requesterSessionKey: "agent:main:main", - sandboxed: false, - hits: [hit], - }); - - expect(filtered).toEqual([hit]); - }); }); diff --git a/extensions/memory-core/src/session-search-visibility.ts b/extensions/memory-core/src/session-search-visibility.ts index 1c2ade37081e..03719c3e76cd 100644 --- a/extensions/memory-core/src/session-search-visibility.ts +++ b/extensions/memory-core/src/session-search-visibility.ts @@ -1,7 +1,9 @@ // Memory Core plugin module implements session search visibility behavior. +import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; import { resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core"; +import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry"; import { extractTranscriptIdentityFromSessionsMemoryHit, loadCombinedSessionStoreForGateway, @@ -23,6 +25,114 @@ function isGlobalSessionKeyForSharedScope(cfg: OpenClawConfig, key: string): boo return cfg.session?.scope === "global" && key.trim().toLowerCase() === "global"; } +type ConversationRecallContext = NonNullable; + +type SessionStore = ReturnType["store"]; + +function isSameStoredTranscript( + anchor: SessionStore[string] | undefined, + candidate: SessionStore[string] | undefined, +): boolean { + if (!anchor || !candidate) { + return false; + } + const anchorSessionId = anchor.sessionId?.trim(); + if (anchorSessionId && candidate.sessionId?.trim() === anchorSessionId) { + return true; + } + const anchorFile = anchor.sessionFile?.trim(); + const candidateFile = candidate.sessionFile?.trim(); + return Boolean( + anchorFile && candidateFile && path.resolve(anchorFile) === path.resolve(candidateFile), + ); +} + +function isPrivateConversation(params: { + agentId: string; + entry: SessionStore[string] | undefined; + key: string; +}): boolean { + if (!params.entry) { + return false; + } + const key = params.key.trim().toLowerCase(); + const chatTypes = [params.entry.chatType, params.entry.origin?.chatType].filter( + (chatType): chatType is NonNullable => chatType !== undefined, + ); + if ( + chatTypes.some((chatType) => chatType === "group" || chatType === "channel") || + /:active-memory:[a-f0-9]{12}$/i.test(key) + ) { + return false; + } + const prefix = `agent:${params.agentId.trim().toLowerCase()}:`; + // Shared global sessions (session.scope="global") are one identity for every + // sender; direct chat metadata does not make them private conversations. + if (key === "global" || key === `${prefix}global`) { + return false; + } + if (key.startsWith(`${prefix}explicit:`)) { + // Gateway UI turns persist direct metadata before prompt hooks run. Requiring + // it distinguishes private UI sessions from headless/model-run explicit keys. + return chatTypes.length > 0 && chatTypes.every((chatType) => chatType === "direct"); + } + if ( + key.includes(":group:") || + key.includes(":channel:") || + /:(?:active-memory|cron|heartbeat|hook|node|subagent)(?::|$)/.test(key) + ) { + return false; + } + if (chatTypes.length > 0) { + return chatTypes.every((chatType) => chatType === "direct"); + } + if (key.includes(":direct:") || key.includes(":dm:")) { + return true; + } + return false; +} + +function anchorAliasesArePrivate(params: { + store: SessionStore; + agentId: string; + anchorSessionKey: string; + anchorEntry: SessionStore[string] | undefined; +}): boolean { + // The anchor/destination must satisfy the same all-alias fail-closed policy as + // candidate sources: a direct key whose transcript identity also lives under a + // group/channel alias would leak recalled private context into a shared surface. + for (const [key, entry] of Object.entries(params.store)) { + if (key === params.anchorSessionKey) { + continue; + } + if (!isSameStoredTranscript(params.anchorEntry, entry)) { + continue; + } + if (!isPrivateConversation({ agentId: params.agentId, entry, key })) { + return false; + } + } + return true; +} + +function isTrustedRecallRequester(params: { + anchorSessionKey: string; + requesterSessionKey: string | undefined; +}): boolean { + const requesterSessionKey = params.requesterSessionKey?.trim(); + if (!requesterSessionKey) { + return false; + } + if (requesterSessionKey === params.anchorSessionKey) { + return true; + } + if (!requesterSessionKey.startsWith(params.anchorSessionKey)) { + return false; + } + const recallSuffix = requesterSessionKey.slice(params.anchorSessionKey.length); + return /^:active-memory:[a-f0-9]{12}$/i.test(recallSuffix); +} + function filterSessionKeysByScopedAgent(params: { cfg: OpenClawConfig; keys: string[]; @@ -50,6 +160,7 @@ export async function filterMemorySearchHitsBySessionVisibility(params: { requesterSessionKey: string | undefined; sandboxed: boolean; hits: MemorySearchResult[]; + conversationRecall?: ConversationRecallContext; }): Promise { const visibility = resolveEffectiveSessionToolsVisibility({ cfg: params.cfg, @@ -77,13 +188,101 @@ export async function filterMemorySearchHitsBySessionVisibility(params: { scopedAgentId ? { agentId: scopedAgentId } : {}, ); + const conversationRecall = params.conversationRecall; + const anchorSessionKey = conversationRecall?.anchorSessionKey.trim(); + const recallAgentId = anchorSessionKey + ? resolveSessionAgentId({ sessionKey: anchorSessionKey, config: params.cfg }) + : undefined; + const anchorEntry = anchorSessionKey ? combinedSessionStore[anchorSessionKey] : undefined; + const recallAuthorized = Boolean( + conversationRecall && + !params.sandboxed && + conversationRecall.scope === "same-agent-private" && + (conversationRecall.corpus === "sessions" || conversationRecall.corpus === "configured") && + anchorSessionKey && + isTrustedRecallRequester({ + anchorSessionKey, + requesterSessionKey: params.requesterSessionKey, + }) && + normalizeAgentIdForCompare(recallAgentId) === normalizeAgentIdForCompare(scopedAgentId) && + recallAgentId && + isPrivateConversation({ + agentId: recallAgentId, + entry: anchorEntry, + key: anchorSessionKey, + }) && + anchorAliasesArePrivate({ + store: combinedSessionStore, + agentId: recallAgentId, + anchorSessionKey, + anchorEntry, + }), + ); + if (conversationRecall && !recallAuthorized) { + return conversationRecall.corpus === "configured" + ? params.hits.filter((hit) => hit.source !== "sessions") + : []; + } + + const isSessionKeyAllowed = (key: string): boolean => { + if (!conversationRecall || !anchorSessionKey || !recallAgentId) { + return guard?.check(key).allowed === true; + } + const candidateEntry = combinedSessionStore[key]; + // Canonical and legacy alias keys can identify one transcript. Exclude the + // anchor by transcript identity so an alias cannot re-inject current context. + if (key === anchorSessionKey || isSameStoredTranscript(anchorEntry, candidateEntry)) { + return false; + } + const candidateAgentId = resolveSessionAgentId({ sessionKey: key, config: params.cfg }); + if ( + normalizeAgentIdForCompare(candidateAgentId) !== normalizeAgentIdForCompare(recallAgentId) + ) { + return false; + } + return isPrivateConversation({ + agentId: recallAgentId, + entry: candidateEntry, + key, + }); + }; + + const expandRecallAliasKeys = (keys: string[]): string[] => { + // Alias resolution by session id can miss a group/channel alias that shares + // the same transcript file under a different session id. Recall must judge + // every alias, so expand candidates by stored transcript identity. + const expanded = new Set(keys); + for (const key of keys) { + const entry = combinedSessionStore[key]; + if (!entry) { + continue; + } + for (const [candidateKey, candidateEntry] of Object.entries(combinedSessionStore)) { + if (isSameStoredTranscript(entry, candidateEntry)) { + expanded.add(candidateKey); + } + } + } + return [...expanded]; + }; + + const areSessionKeysAllowed = (keys: string[]): boolean => { + // Product recall fails closed when aliases disagree about privacy. Ordinary + // session-tool visibility keeps its existing any-visible-alias behavior. + return conversationRecall + ? expandRecallAliasKeys(keys).every(isSessionKeyAllowed) + : keys.some(isSessionKeyAllowed); + }; + const next: MemorySearchResult[] = []; for (const hit of params.hits) { if (hit.source !== "sessions") { - next.push(hit); + if (!conversationRecall || conversationRecall.corpus === "configured") { + next.push(hit); + } continue; } - if (!params.requesterSessionKey || !guard) { + if (!params.requesterSessionKey || (!guard && !conversationRecall)) { continue; } const artifactIdentity = readQmdSessionArtifactIdentity(hit); @@ -109,7 +308,7 @@ export async function filterMemorySearchHitsBySessionVisibility(params: { if (keys.length === 0) { continue; } - const allowed = keys.some((key) => guard.check(key).allowed); + const allowed = areSessionKeysAllowed(keys); if (!allowed) { continue; } @@ -166,7 +365,7 @@ export async function filterMemorySearchHitsBySessionVisibility(params: { if (keys.length === 0) { continue; } - const allowed = keys.some((key) => guard.check(key).allowed); + const allowed = areSessionKeysAllowed(keys); if (!allowed) { continue; } diff --git a/extensions/memory-core/src/tools.test-helpers.ts b/extensions/memory-core/src/tools.test-helpers.ts index be1eb9771894..b9c704983591 100644 --- a/extensions/memory-core/src/tools.test-helpers.ts +++ b/extensions/memory-core/src/tools.test-helpers.ts @@ -1,3 +1,4 @@ +import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry"; // Memory Core helper module supports tools helpers behavior. import { expect } from "vitest"; import type { OpenClawConfig } from "../api.js"; @@ -16,12 +17,14 @@ export function createMemorySearchToolOrThrow(params?: { agentId?: string; agentSessionKey?: string; oneShotCliRun?: boolean; + conversationRecall?: OpenClawPluginToolContext["conversationRecall"]; }) { const tool = createMemorySearchTool({ config: params?.config ?? createDefaultMemoryToolConfig(), ...(params?.agentId ? { agentId: params.agentId } : {}), ...(params?.agentSessionKey ? { agentSessionKey: params.agentSessionKey } : {}), ...(params?.oneShotCliRun ? { oneShotCliRun: params.oneShotCliRun } : {}), + ...(params?.conversationRecall ? { conversationRecall: params.conversationRecall } : {}), }); if (!tool) { throw new Error("tool missing"); diff --git a/extensions/memory-core/src/tools.test.ts b/extensions/memory-core/src/tools.test.ts index 7e5f139699ae..41f867dc9190 100644 --- a/extensions/memory-core/src/tools.test.ts +++ b/extensions/memory-core/src/tools.test.ts @@ -38,8 +38,15 @@ import { const sessionStore = vi.hoisted(() => ({ "agent:main:main": { sessionId: "thread-1", - updatedAt: 1, + updatedAt: 2, sessionFile: "/tmp/sessions/thread-1.jsonl", + chatType: "direct" as const, + }, + "agent:main:webchat:direct:owner": { + sessionId: "past-thread", + updatedAt: 1, + sessionFile: "/tmp/sessions/past-thread.jsonl", + chatType: "direct" as const, }, })); @@ -138,6 +145,21 @@ describe("memory_search unavailable payloads", () => { expect(getMemorySearchManagerMockCalls()).toBe(0); }); + it("rejects an unknown corpus before searching", async () => { + const tool = createMemorySearchToolOrThrow(); + + // An unvalidated corpus string must not fall through to an unrestricted + // manager search that could surface recall-only indexed transcripts. + await expect( + tool.execute("unknown-corpus", { + query: "hello", + corpus: "everything", + }), + ).rejects.toThrow("corpus must be one of: memory, wiki, all, sessions"); + + expect(getMemorySearchManagerMockCalls()).toBe(0); + }); + it("rejects malformed minScore before searching", async () => { const tool = createMemorySearchToolOrThrow(); @@ -1314,6 +1336,226 @@ describe("memory_search corpus labels", () => { expect(getMemorySearchManagerMockConfigs()).toEqual([patchedConfig]); }); + it("keeps ordinary memory_search on explicitly configured sources when recall indexing is enabled", async () => { + let seenSources: readonly string[] | undefined; + setMemorySearchImpl(async (opts) => { + seenSources = opts?.sources; + return []; + }); + const tool = createMemorySearchToolOrThrow({ + config: { + agents: { + defaults: { memorySearch: { rememberAcrossConversations: true } }, + list: [{ id: "main", default: true }], + }, + memory: { citations: "off" }, + tools: { sessions: { visibility: "all" } }, + }, + agentSessionKey: "agent:main:main", + }); + + await tool.execute("ordinary-search", { query: "favorite food" }); + + expect(seenSources).toEqual(["memory"]); + }); + + it.each(["sessions", "all"] as const)( + "does not let ordinary corpus=%s broaden implicitly indexed recall transcripts", + async (corpus) => { + let seenSources: readonly string[] | undefined; + setMemorySearchImpl(async (opts) => { + seenSources = opts?.sources; + return [ + { + path: "sessions/private-group.jsonl", + startLine: 1, + endLine: 2, + score: 0.95, + snippet: "private transcript", + source: "sessions" as const, + }, + ]; + }); + const tool = createMemorySearchToolOrThrow({ + config: { + agents: { + defaults: { memorySearch: { rememberAcrossConversations: true } }, + list: [{ id: "main", default: true }], + }, + memory: { citations: "off" }, + tools: { sessions: { visibility: "all" } }, + }, + agentSessionKey: "agent:main:main", + }); + + const result = await tool.execute("ordinary-search", { query: "favorite food", corpus }); + const details = result.details as { results: Array<{ source: string }> }; + + expect(seenSources).toEqual(["memory"]); + expect(details.results).toEqual([]); + }, + ); + + it.each(["sessions", "all"] as const)( + "preserves explicitly configured transcript search for corpus=%s", + async (corpus) => { + let seenSources: readonly string[] | undefined; + setMemorySearchImpl(async (opts) => { + seenSources = opts?.sources; + return []; + }); + const tool = createMemorySearchToolOrThrow({ + config: { + agents: { + defaults: { + memorySearch: { + rememberAcrossConversations: true, + sources: ["sessions"], + }, + }, + list: [{ id: "main", default: true }], + }, + memory: { citations: "off" }, + tools: { sessions: { visibility: "all" } }, + }, + agentSessionKey: "agent:main:main", + }); + + await tool.execute("ordinary-search", { query: "favorite food", corpus }); + + expect(seenSources).toEqual(["sessions"]); + }, + ); + + it("forces trusted conversation recall onto its authorized transcript corpus", async () => { + let seenSources: readonly string[] | undefined; + setMemorySearchImpl(async (opts) => { + seenSources = opts?.sources; + return [ + { + path: "MEMORY.md", + startLine: 1, + endLine: 2, + score: 0.95, + snippet: "Shared memory note", + source: "memory" as const, + }, + { + path: "sessions/past-thread.jsonl", + startLine: 1, + endLine: 2, + score: 0.9, + snippet: "Prior private conversation", + source: "sessions" as const, + }, + ]; + }); + const tool = createMemorySearchToolOrThrow({ + config: { + agents: { list: [{ id: "main", default: true }] }, + memory: { citations: "off" }, + tools: { sessions: { visibility: "self" } }, + }, + agentSessionKey: "agent:main:main:active-memory:abcdef123456", + conversationRecall: { + anchorSessionKey: "agent:main:main", + scope: "same-agent-private", + corpus: "sessions", + }, + }); + + const result = await tool.execute("trusted-recall", { + query: "favorite food", + corpus: "memory", + }); + const details = result.details as { results: Array<{ corpus: string; path: string }> }; + + expect(seenSources).toEqual(["sessions"]); + expect(details.results).toEqual([ + expect.objectContaining({ + corpus: "sessions", + path: "sessions/past-thread.jsonl", + }), + ]); + }); + + it("adds private transcript sources to combined advanced and product recall", async () => { + let seenSources: readonly string[] | undefined; + setMemorySearchImpl(async (opts) => { + seenSources = opts?.sources; + return []; + }); + const tool = createMemorySearchToolOrThrow({ + config: { + agents: { + defaults: { memorySearch: { rememberAcrossConversations: true } }, + list: [{ id: "main", default: true }], + }, + memory: { citations: "off" }, + tools: { sessions: { visibility: "self" } }, + }, + agentSessionKey: "agent:main:main", + conversationRecall: { + anchorSessionKey: "agent:main:main", + scope: "same-agent-private", + corpus: "configured", + }, + }); + + await tool.execute("combined-recall", { query: "favorite food" }); + + expect(seenSources).toEqual(["memory", "sessions"]); + }); + + it("retains configured sources for advanced trusted recall", async () => { + let seenSources: readonly string[] | undefined; + setMemorySearchImpl(async (opts) => { + seenSources = opts?.sources; + return [ + { + path: "MEMORY.md", + startLine: 1, + endLine: 2, + score: 0.95, + snippet: "Shared memory note", + source: "memory" as const, + }, + { + path: "sessions/past-thread.jsonl", + startLine: 1, + endLine: 2, + score: 0.9, + snippet: "Prior private conversation", + source: "sessions" as const, + }, + ]; + }); + const tool = createMemorySearchToolOrThrow({ + config: { + agents: { list: [{ id: "main", default: true }] }, + memory: { citations: "off" }, + tools: { sessions: { visibility: "self" } }, + }, + agentSessionKey: "agent:main:main", + conversationRecall: { + anchorSessionKey: "agent:main:main", + scope: "same-agent-private", + corpus: "configured", + }, + }); + + const result = await tool.execute("advanced-recall", { + query: "favorite food", + corpus: "memory", + }); + const details = result.details as { results: Array<{ corpus: string; path: string }> }; + + expect(seenSources).toEqual(["memory"]); + expect(details.results).toEqual([ + expect.objectContaining({ corpus: "memory", path: "MEMORY.md" }), + ]); + }); + it("preserves source corpus labels for memory and session transcript hits", async () => { setMemorySearchImpl(async () => [ { @@ -1336,7 +1578,15 @@ describe("memory_search corpus labels", () => { const tool = createMemorySearchToolOrThrow({ config: { - agents: { list: [{ id: "main", default: true }] }, + agents: { + defaults: { + memorySearch: { + sources: ["memory", "sessions"], + experimental: { sessionMemory: true }, + }, + }, + list: [{ id: "main", default: true }], + }, memory: { citations: "off" }, tools: { sessions: { visibility: "all" } }, }, diff --git a/extensions/memory-core/src/tools.ts b/extensions/memory-core/src/tools.ts index deae592322ed..50c20d8d5aa9 100644 --- a/extensions/memory-core/src/tools.ts +++ b/extensions/memory-core/src/tools.ts @@ -11,6 +11,7 @@ import { readPositiveIntegerParam, readStringParam, resolveMemoryDreamingPluginConfig, + resolveMemorySearchConfig, type MemoryCorpusSearchResult, type OpenClawConfig, } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; @@ -22,6 +23,7 @@ import { resolveMemoryDreamingConfig, resolveMemoryDeepDreamingConfig, } from "openclaw/plugin-sdk/memory-core-host-status"; +import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry"; import type { PluginStateLeaseRunner } from "openclaw/plugin-sdk/plugin-state-runtime"; import { asRecord } from "./dreaming-shared.js"; import type { MemoryCoreAcquireLocalService } from "./memory/embedding-local-service.js"; @@ -67,6 +69,26 @@ const MEMORY_SEARCH_TOOL_COOLDOWN_MS = 60_000; const memorySearchToolCooldowns = new Map(); +/** + * Validate the model-authored corpus argument against the tool's closed enum. + * Provider tool schemas do not guarantee enum enforcement; an unknown corpus + * must fail closed instead of falling through to an unrestricted search that + * could surface recall-only indexed transcripts. + */ +function readCorpusParam( + rawParams: Record, + allowed: readonly T[], +): T | undefined { + const raw = readStringParam(rawParams, "corpus"); + if (raw === undefined) { + return undefined; + } + if ((allowed as readonly string[]).includes(raw)) { + return raw as T; + } + throw new Error(`corpus must be one of: ${allowed.join(", ")}`); +} + function mergeQmdRuntimeDebug( entries: readonly MemorySearchRuntimeDebug[], ): MemorySearchRuntimeDebug["qmd"] | undefined { @@ -433,6 +455,7 @@ export function createMemorySearchTool(options: { agentSessionKey?: string; sandboxed?: boolean; oneShotCliRun?: boolean; + conversationRecall?: OpenClawPluginToolContext["conversationRecall"]; acquireLocalService?: MemoryCoreAcquireLocalService; withLease?: PluginStateLeaseRunner; }) { @@ -453,12 +476,15 @@ export function createMemorySearchTool(options: { const query = readStringParam(rawParams, "query", { required: true }); const maxResults = readPositiveIntegerParam(rawParams, "maxResults"); const minScore = readFiniteNumberParam(rawParams, "minScore"); - const requestedCorpus = readStringParam(rawParams, "corpus") as - | "memory" - | "wiki" - | "all" - | "sessions" - | undefined; + const modelRequestedCorpus = readCorpusParam(rawParams, [ + "memory", + "wiki", + "all", + "sessions", + ]); + // The trusted runtime chooses the recall corpus; model-authored arguments cannot broaden it. + const requestedCorpus = + options.conversationRecall?.corpus === "sessions" ? "sessions" : modelRequestedCorpus; const cooldownKey = resolveMemorySearchToolCooldownKey({ agentId, agentSessionKey: options.agentSessionKey, @@ -593,12 +619,26 @@ export function createMemorySearchTool(options: { cfg, options.agentSessionKey, ); + const memorySearchConfig = resolveMemorySearchConfig(cfg, agentId); + const defaultSearchSources = memorySearchConfig?.searchSources; + const trustedConfiguredRecall = options.conversationRecall?.corpus === "configured"; + const effectiveSearchSources = trustedConfiguredRecall + ? memorySearchConfig?.sources + : defaultSearchSources; + const trustedTranscriptRecall = options.conversationRecall !== undefined; + const configuredSessionSearch = defaultSearchSources?.includes("sessions") === true; + // Product recall may index transcripts without adding them to ordinary model search. + // Only trusted recall or explicit configuration may search those indexed transcripts. const searchSources: MemorySource[] | undefined = requestedCorpus === "sessions" - ? (["sessions"] as MemorySource[]) + ? trustedTranscriptRecall || configuredSessionSearch + ? (["sessions"] as MemorySource[]) + : defaultSearchSources : requestedCorpus === "memory" ? (["memory"] as MemorySource[]) - : undefined; + : requestedCorpus == null || requestedCorpus === "all" + ? effectiveSearchSources + : undefined; const createSearchOptions = ( signal: AbortSignal, controlDeadline: (action: MemorySearchDeadlineAction) => void, @@ -684,8 +724,13 @@ export function createMemorySearchTool(options: { requesterSessionKey: options.agentSessionKey, sandboxed: options.sandboxed === true, hits: rawResults, + conversationRecall: options.conversationRecall, }), ); + if (searchSources) { + const allowedSources = new Set(searchSources); + rawResults = rawResults.filter((hit) => allowedSources.has(hit.source)); + } if (requestedCorpus === "sessions") { rawResults = rawResults.filter((hit) => hit.source === "sessions"); } else if (requestedCorpus === "memory") { @@ -836,11 +881,7 @@ export function createMemoryGetTool(options: { const relPath = readStringParam(rawParams, "path", { required: true }); const from = readPositiveIntegerParam(rawParams, "from"); const lines = readPositiveIntegerParam(rawParams, "lines"); - const requestedCorpus = readStringParam(rawParams, "corpus") as - | "memory" - | "wiki" - | "all" - | undefined; + const requestedCorpus = readCorpusParam(rawParams, ["memory", "wiki", "all"]); const { readAgentMemoryFile, resolveMemoryBackendConfig } = await loadMemoryToolRuntime(); if (requestedCorpus === "wiki") { const supplement = await getSupplementMemoryReadResult({ diff --git a/extensions/qa-lab/src/providers/mock-openai/mock-openai-assistant-text.ts b/extensions/qa-lab/src/providers/mock-openai/mock-openai-assistant-text.ts index 6d0c978652a3..ef52e80a7bfd 100644 --- a/extensions/qa-lab/src/providers/mock-openai/mock-openai-assistant-text.ts +++ b/extensions/qa-lab/src/providers/mock-openai/mock-openai-assistant-text.ts @@ -44,6 +44,7 @@ import { extractActiveMemorySummary, extractToolSearchTarget, extractSnackPreference, + isSnackRecallPrompt, } from "./mock-openai-tooling.js"; export function buildAssistantText( input: ResponsesInputItem[], @@ -168,10 +169,10 @@ export function buildAssistantText( if (/memory tools check/i.test(prompt) && orbitCode) { return `Protocol note: I checked memory and the project codename is ${orbitCode}.`; } - if (/silent snack recall check/i.test(prompt) && snackPreference) { + if (isSnackRecallPrompt(prompt) && snackPreference) { return `Protocol note: you usually want ${snackPreference} for QA movie night.`; } - if (/silent snack recall check/i.test(prompt)) { + if (isSnackRecallPrompt(prompt)) { return "Protocol note: I do not have enough context to say what you usually want for QA movie night."; } if (/qa private final reply warning check/i.test(prompt)) { diff --git a/extensions/qa-lab/src/providers/mock-openai/mock-openai-tooling.ts b/extensions/qa-lab/src/providers/mock-openai/mock-openai-tooling.ts index 81c2c2fadc2a..b837dc7fd4d7 100644 --- a/extensions/qa-lab/src/providers/mock-openai/mock-openai-tooling.ts +++ b/extensions/qa-lab/src/providers/mock-openai/mock-openai-tooling.ts @@ -236,6 +236,12 @@ export function isActiveMemorySubagentPrompt(text: string) { return text.includes("You are a memory search agent."); } +export function isSnackRecallPrompt(text: string) { + return ( + /silent snack recall check/i.test(text) || /remember across conversations qa check/i.test(text) + ); +} + export function extractSnackPreference(text: string) { const normalized = text.replace(/\s+/g, " ").trim(); const match = diff --git a/extensions/qa-lab/src/providers/mock-openai/server.test.ts b/extensions/qa-lab/src/providers/mock-openai/server.test.ts index 8170be4d448d..8d81134e81e8 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.test.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.test.ts @@ -3071,6 +3071,108 @@ describe("qa mock openai server", () => { expect(String(lastRequestPayload.instructions)).toContain(""); expect(String(lastRequestPayload.allInputText)).toContain(""); + const rememberSearch = await fetch(`${server.baseUrl}/v1/responses`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + stream: true, + input: [ + { + role: "user", + content: [ + { + type: "input_text", + text: [ + "You are a memory search agent.", + "Use only the available memory tools.", + "Latest user message:", + "Remember across conversations QA check: what snack do I usually want for QA movie night?", + ].join("\n"), + }, + ], + }, + ], + }), + }); + expect(rememberSearch.status).toBe(200); + const rememberSearchText = await rememberSearch.text(); + expect(rememberSearchText).toContain('"name":"memory_search"'); + expect(rememberSearchText).toContain("QA movie night snack lemon pepper wings blue cheese"); + expect(rememberSearchText).toContain('\\"maxResults\\":10'); + + const rememberSearchSummary = await fetch(`${server.baseUrl}/v1/responses`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + stream: true, + input: [ + { + role: "user", + content: [ + { + type: "input_text", + text: [ + "You are a memory search agent.", + "Use only the available memory tools.", + "Latest user message:", + "Remember across conversations QA check: what snack do I usually want for QA movie night?", + ].join("\n"), + }, + ], + }, + { + type: "function_call_output", + output: JSON.stringify({ + results: [ + { + path: "sessions/private-source.jsonl", + startLine: 2, + endLine: 3, + snippet: + "Stable QA movie night snack preference: lemon pepper wings with blue cheese.", + }, + ], + }), + }, + ], + }), + }); + expect(rememberSearchSummary.status).toBe(200); + const rememberSearchSummaryText = await rememberSearchSummary.text(); + expect(rememberSearchSummaryText).toContain("lemon pepper wings with blue cheese"); + expect(rememberSearchSummaryText).not.toContain('"name":"memory_get"'); + + const rememberInjectedMainReply = await fetch(`${server.baseUrl}/v1/responses`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + stream: false, + instructions: + "User usually wants lemon pepper wings with blue cheese for QA movie night.", + input: [ + { + role: "user", + content: [ + { + type: "input_text", + text: "Remember across conversations QA check: what snack do I usually want for QA movie night?", + }, + ], + }, + ], + }), + }); + expect(rememberInjectedMainReply.status).toBe(200); + expect(JSON.stringify(await rememberInjectedMainReply.json())).toContain( + "lemon pepper wings with blue cheese", + ); + const spawn = await fetch(`${server.baseUrl}/v1/responses`, { method: "POST", headers: { diff --git a/extensions/qa-lab/src/providers/mock-openai/server.ts b/extensions/qa-lab/src/providers/mock-openai/server.ts index 9c8cddaee50f..05bbb43365f7 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -139,6 +139,7 @@ import { extractToolSearchTarget, buildQaToolSearchArgs, isActiveMemorySubagentPrompt, + isSnackRecallPrompt, extractSnackPreference, } from "./mock-openai-tooling.js"; @@ -967,15 +968,12 @@ async function buildResponsesPayload( }); } } - if ( - isActiveMemorySubagentPrompt(allInputText) && - /silent snack recall check/i.test(allInputText) - ) { + if (isActiveMemorySubagentPrompt(allInputText) && isSnackRecallPrompt(allInputText)) { if (!toolOutput) { if (!hasDeclaredTool(body, "memory_recall")) { return buildToolCallEventsWithArgs("memory_search", { query: "QA movie night snack lemon pepper wings blue cheese", - maxResults: 3, + maxResults: /remember across conversations qa check/i.test(allInputText) ? 10 : 3, }); } return buildToolCallEventsWithArgs("memory_recall", { @@ -1007,7 +1005,7 @@ async function buildResponsesPayload( ? (toolJson.results as Array>) : []; const first = results[0]; - if (typeof first?.path === "string") { + if (typeof first?.path === "string" && hasDeclaredTool(body, "memory_get")) { const from = typeof first.startLine === "number" ? Math.max(1, first.startLine) diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index dd72d33991ac..54c3dab70b07 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -191,6 +191,7 @@ describe("qa scenario catalog", () => { "kitchen-sink-live-openai", "matrix-post-restart-room-continue", "matrix-restart-resume", + "remember-across-conversations", "slack-restart-resume", "subagent-stale-child-links", "telegram-repeated-command-authorization", @@ -907,6 +908,7 @@ describe("qa scenario catalog", () => { "goal-context-survives-compaction", "goal-followthrough-live", "active-memory-preprompt-recall", + "remember-across-conversations", "memory-recall", "session-memory-ranking", "thread-memory-isolation", @@ -952,6 +954,32 @@ describe("qa scenario catalog", () => { expect(scenario.execution.providerMode).toBe("mock-openai"); }); + it("keeps remember-across-conversations isolated and product-only", () => { + const scenario = requireFlowScenario(readQaScenarioById("remember-across-conversations")); + const config = readQaScenarioExecutionConfig("remember-across-conversations") as + | { requiredChannelDriver?: string } + | undefined; + + expect(scenario.execution.suiteIsolation).toBe("isolated"); + expect(config?.requiredChannelDriver).toBe("qa-channel"); + expect(scenario.gatewayConfigPatch).toMatchObject({ + session: { dmScope: "per-channel-peer" }, + agents: { + defaults: { + memorySearch: { rememberAcrossConversations: true }, + }, + }, + plugins: { + entries: { + "active-memory": { + enabled: true, + config: { enabled: true, agents: [] }, + }, + }, + }, + }); + }); + it("routes native command session targeting through Crabline Telegram", () => { const scenario = readQaScenarioById("native-command-session-target"); const config = readQaScenarioExecutionConfig("native-command-session-target") as diff --git a/packages/memory-host-sdk/src/host/backend-config.test.ts b/packages/memory-host-sdk/src/host/backend-config.test.ts index 351e7eb2ed38..b0e11a9183e5 100644 --- a/packages/memory-host-sdk/src/host/backend-config.test.ts +++ b/packages/memory-host-sdk/src/host/backend-config.test.ts @@ -132,6 +132,90 @@ describe("resolveMemoryBackendConfig", () => { expect(requireQmdCollection(resolved, "memory-root-main").pattern).toBe("MEMORY.md"); }); + it("keeps QMD session export off by default", () => { + const cfg = { + agents: { defaults: { workspace: "/tmp/memory-test" } }, + memory: { backend: "qmd", qmd: {} }, + } as OpenClawConfig; + + const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); + + expect(requireQmdConfig(resolved).sessions.enabled).toBe(false); + }); + + it("enables QMD session export when an agent remembers across conversations", () => { + const cfg = { + agents: { + defaults: { workspace: "/tmp/memory-test" }, + list: [ + { + id: "personal", + memorySearch: { rememberAcrossConversations: true }, + }, + ], + }, + memory: { backend: "qmd", qmd: {} }, + } as OpenClawConfig; + + const resolved = resolveMemoryBackendConfig({ cfg, agentId: "personal" }); + + expect(requireQmdConfig(resolved).sessions.enabled).toBe(true); + // Remember-only export stays search-only for trusted recall; ordinary + // memory_get must not read transcript exports the operator never opted into. + expect(requireQmdConfig(resolved).sessions.readable).toBe(false); + }); + + it("ignores a configured exportDir for remember-only implied session exports", () => { + const cfg = { + agents: { + defaults: { workspace: "/tmp/memory-test" }, + list: [ + { + id: "personal", + memorySearch: { rememberAcrossConversations: true }, + }, + ], + }, + // sessions.enabled is not set: exportDir was configured for an export + // feature the operator never turned on. Honoring it here could write + // transcripts into workspace memory/ and leak them into the ordinary + // memory corpus. + memory: { backend: "qmd", qmd: { sessions: { exportDir: "memory/session-exports" } } }, + } as OpenClawConfig; + + const resolved = resolveMemoryBackendConfig({ cfg, agentId: "personal" }); + + expect(requireQmdConfig(resolved).sessions.enabled).toBe(true); + expect(requireQmdConfig(resolved).sessions.readable).toBe(false); + expect(requireQmdConfig(resolved).sessions.exportDir).toBeUndefined(); + }); + + it("keeps a configured exportDir for explicitly enabled session exports", () => { + const cfg = { + agents: { defaults: { workspace: "/tmp/memory-test" } }, + memory: { + backend: "qmd", + qmd: { sessions: { enabled: true, exportDir: "session-exports" } }, + }, + } as OpenClawConfig; + + const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); + + expect(requireQmdConfig(resolved).sessions.exportDir).toBe("/tmp/memory-test/session-exports"); + }); + + it("keeps explicitly configured QMD session exports readable", () => { + const cfg = { + agents: { defaults: { workspace: "/tmp/memory-test" } }, + memory: { backend: "qmd", qmd: { sessions: { enabled: true } } }, + } as OpenClawConfig; + + const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); + + expect(requireQmdConfig(resolved).sessions.enabled).toBe(true); + expect(requireQmdConfig(resolved).sessions.readable).toBe(true); + }); + it("keeps uppercase MEMORY.md as the root pattern when only lowercase memory.md exists", () => { const workspaceDir = "/workspace/root"; withMemoryRootEntries([memoryFileEntry("memory.md")], () => { diff --git a/packages/memory-host-sdk/src/host/backend-config.ts b/packages/memory-host-sdk/src/host/backend-config.ts index 95b03a8f607c..a09f446d1e28 100644 --- a/packages/memory-host-sdk/src/host/backend-config.ts +++ b/packages/memory-host-sdk/src/host/backend-config.ts @@ -17,6 +17,7 @@ import { type MemoryQmdStartupMode, type OpenClawConfig, resolveMemoryHostAgentWorkspaceDir, + resolveMemoryHostSearchPathConfig, normalizeAgentId, resolveMemoryHostUserPath, type SessionSendPolicyConfig, @@ -92,6 +93,13 @@ export type ResolvedMemoryBackendConfig = { /** @public */ export type ResolvedQmdSessionConfig = { enabled: boolean; + /** + * Whether ordinary memory searches and memory_get may access exported + * session transcripts. Only explicit memory.qmd.sessions.enabled opts + * transcripts into the ordinary memory corpus; remember-across-conversations + * implies export for trusted recall search only. + */ + readable: boolean; exportDir?: string; retentionDays?: number; }; @@ -306,13 +314,20 @@ function resolveSearchTool(raw?: MemoryQmdConfig["searchTool"]): string | undefi function resolveSessionConfig( cfg: MemoryQmdConfig["sessions"], workspaceDir: string, + options: { explicit: boolean }, ): ResolvedQmdSessionConfig { const enabled = Boolean(cfg?.enabled); const exportDirRaw = cfg?.exportDir?.trim(); - const exportDir = exportDirRaw ? resolvePath(exportDirRaw, workspaceDir) : undefined; + // A configured exportDir belongs to explicit session export. When export is + // only implied by rememberAcrossConversations, honoring it could write + // transcripts into workspace memory/ and leak them into the ordinary memory + // corpus; implied exports always use the default private location. + const exportDir = + options.explicit && exportDirRaw ? resolvePath(exportDirRaw, workspaceDir) : undefined; const retentionDays = resolvePositiveIntegerConfig(cfg?.retentionDays); return { enabled, + readable: enabled && options.explicit, exportDir, retentionDays, }; @@ -431,6 +446,7 @@ export function resolveMemoryBackendConfig(params: { const workspaceDir = resolveMemoryHostAgentWorkspaceDir(params.cfg, normalizedAgentId); const qmdCfg = params.cfg.memory?.qmd; + const memorySearch = resolveMemoryHostSearchPathConfig(params.cfg, normalizedAgentId); const includeDefaultMemory = qmdCfg?.includeDefaultMemory !== false; const nameSet = new Set(); const agentEntry = params.cfg.agents?.list?.find( @@ -476,7 +492,17 @@ export function resolveMemoryBackendConfig(params: { searchTool: resolveSearchTool(qmdCfg?.searchTool), collections, includeDefaultMemory, - sessions: resolveSessionConfig(qmdCfg?.sessions, workspaceDir), + sessions: resolveSessionConfig( + { + ...qmdCfg?.sessions, + enabled: + qmdCfg?.sessions?.enabled === true || memorySearch?.rememberAcrossConversations === true, + }, + workspaceDir, + // Remember-only export is search-only for trusted recall; ordinary + // memory_get reads and sessions options require explicit sessions.enabled. + { explicit: qmdCfg?.sessions?.enabled === true }, + ), update: { intervalMs: resolveIntervalMs(qmdCfg?.update?.interval), debounceMs: resolveDebounceMs(qmdCfg?.update?.debounceMs), diff --git a/packages/memory-host-sdk/src/host/config-utils.ts b/packages/memory-host-sdk/src/host/config-utils.ts index 68da151e2bbf..1c4112d28b17 100644 --- a/packages/memory-host-sdk/src/host/config-utils.ts +++ b/packages/memory-host-sdk/src/host/config-utils.ts @@ -115,6 +115,7 @@ type MemoryConfig = { /** Per-agent memory search enablement and extra collection paths. */ type MemorySearchConfig = { enabled?: boolean; + rememberAcrossConversations?: boolean; extraPaths?: string[]; qmd?: { extraCollections?: MemoryQmdIndexPath[]; @@ -327,7 +328,7 @@ export function resolveMemoryHostAgentContextLimits( export function resolveMemoryHostSearchPathConfig( cfg: OpenClawConfig, agentId: string, -): { enabled: boolean; extraPaths: string[] } | null { +): { enabled: boolean; rememberAcrossConversations: boolean; extraPaths: string[] } | null { const defaults = cfg.agents?.defaults?.memorySearch; const overrides = resolveAgentConfig(cfg, agentId)?.memorySearch; const enabled = overrides?.enabled ?? defaults?.enabled ?? true; @@ -340,6 +341,8 @@ export function resolveMemoryHostSearchPathConfig( ]); return { enabled, + rememberAcrossConversations: + overrides?.rememberAcrossConversations ?? defaults?.rememberAcrossConversations ?? false, extraPaths: uniqueStrings(rawPaths), }; } diff --git a/qa/scenarios/memory/remember-across-conversations.yaml b/qa/scenarios/memory/remember-across-conversations.yaml new file mode 100644 index 000000000000..f5252ee7499b --- /dev/null +++ b/qa/scenarios/memory/remember-across-conversations.yaml @@ -0,0 +1,526 @@ +title: Remember across conversations + +scenario: + id: remember-across-conversations + surface: memory + risk: high + coverage: + primary: + - memory.active-recall + secondary: + - memory.recall + - channels.qa-channel + objective: Verify bounded private transcript recall across separate conversations without crossing shared, anchor, disabled, or paused conversation boundaries. + plugins: + - active-memory + gatewayConfigPatch: + session: + dmScope: per-channel-peer + agents: + defaults: + memorySearch: + rememberAcrossConversations: true + plugins: + entries: + active-memory: + enabled: true + config: + enabled: true + agents: [] + toolsAllow: + - memory_search + logging: true + persistTranscripts: true + transcriptDir: qa-remember-across-conversations + queryMode: message + maxSummaryChars: 220 + successCriteria: + - Two private conversations keep distinct session keys and transcript files. + - A private reply recalls the relevant fact from the other private conversation. + - Accepted memory-search evidence excludes the group transcript and anchor transcript. + - Group destinations, the disabled setting, and a session-scoped pause do not start product recall. + - The feature does not widen tools.sessions.visibility. + docsRefs: + - docs/concepts/active-memory.md + - docs/reference/memory-config.md + - docs/concepts/session.md + codeRefs: + - extensions/active-memory/index.ts + - extensions/memory-core/src/session-search-visibility.ts + - extensions/qa-lab/src/suite-runtime-flow.ts + - extensions/qa-lab/src/providers/mock-openai/server.ts + execution: + kind: flow + channel: qa-channel + suiteIsolation: isolated + isolationReason: Mutates transcript indexing, Active Memory session toggles, and Gateway config while comparing private and shared conversations. + summary: Prove private transcript recall, source filtering, setting-off behavior, and per-conversation pause behavior. + config: + requiredChannelDriver: qa-channel + sourceConversationId: remember-source + targetConversationId: remember-target + groupConversationId: remember-group + pausedConversationId: remember-paused + disabledConversationId: remember-disabled + freshConversationId: remember-fresh + privateFact: lemon pepper wings with blue cheese + groupFact: GROUP-ONLY loaded nachos with black olives + anchorFact: ANCHOR-ONLY pretzel bites test marker + sourceMarker: QA-REMEMBER-SOURCE-SEEDED + recallPrompt: "Remember across conversations QA check: what snack do I usually want for QA movie night? Reply in one short sentence." + expectedNeedle: lemon pepper wings with blue cheese + transcriptDir: qa-remember-across-conversations + +flow: + steps: + - name: recalls only eligible private transcript context + actions: + - call: waitForGatewayHealthy + args: + - ref: env + - 60000 + - call: waitForQaChannelReady + args: + - ref: env + - 60000 + - resetTransport: true + - call: fs.rm + args: + - expr: "path.join(env.gateway.workspaceDir, 'MEMORY.md')" + - force: true + - call: fs.rm + args: + - expr: "path.join(env.gateway.workspaceDir, 'memory', `${formatMemoryDreamingDay(Date.now())}.md`)" + - force: true + - call: readConfigSnapshot + saveAs: original + args: + - ref: env + - set: originalMemorySearch + value: + expr: "original.config.agents && typeof original.config.agents === 'object' && typeof original.config.agents.defaults === 'object' ? structuredClone(original.config.agents.defaults.memorySearch) : undefined" + - set: initialSessionsVisibility + value: + expr: "original.config.tools && typeof original.config.tools === 'object' && typeof original.config.tools.sessions === 'object' ? original.config.tools.sessions.visibility : undefined" + - set: sourceDelivery + value: + expr: "transport.buildAgentDelivery({ target: `dm:${config.sourceConversationId}` })" + - set: targetDelivery + value: + expr: "transport.buildAgentDelivery({ target: `dm:${config.targetConversationId}` })" + - set: groupDelivery + value: + expr: "transport.buildAgentDelivery({ target: `channel:${config.groupConversationId}` })" + - set: pausedDelivery + value: + expr: "transport.buildAgentDelivery({ target: `dm:${config.pausedConversationId}` })" + - set: sourceSessionKey + value: + expr: "buildAgentSessionKey({ agentId: 'qa', channel: sourceDelivery.channel, accountId: transport.accountId, peer: { kind: 'direct', id: sourceDelivery.replyTo }, dmScope: original.config.session?.dmScope, identityLinks: original.config.session?.identityLinks })" + - set: targetSessionKey + value: + expr: "buildAgentSessionKey({ agentId: 'qa', channel: targetDelivery.channel, accountId: transport.accountId, peer: { kind: 'direct', id: targetDelivery.replyTo }, dmScope: original.config.session?.dmScope, identityLinks: original.config.session?.identityLinks })" + - set: groupSessionKey + value: + expr: "buildAgentSessionKey({ agentId: 'qa', channel: groupDelivery.channel, accountId: transport.accountId, peer: { kind: 'channel', id: groupDelivery.replyTo } })" + - set: pausedSessionKey + value: + expr: "buildAgentSessionKey({ agentId: 'qa', channel: pausedDelivery.channel, accountId: transport.accountId, peer: { kind: 'direct', id: pausedDelivery.replyTo }, dmScope: original.config.session?.dmScope, identityLinks: original.config.session?.identityLinks })" + - set: transcriptRoot + value: + expr: "path.join(env.gateway.tempRoot, 'state', 'plugins', 'active-memory', 'transcripts', 'agents', 'qa', config.transcriptDir)" + - call: fs.rm + args: + - ref: transcriptRoot + - recursive: true + force: true + - try: + actions: + - sendInbound: + conversation: + id: + expr: config.sourceConversationId + kind: direct + senderId: + expr: config.sourceConversationId + senderName: Remember Source + text: + expr: "`Stable QA movie night usual favorite snack preference: ${config.privateFact}. Reply exactly: ${config.sourceMarker}.`" + - waitForOutbound: + conversation: + id: + expr: config.sourceConversationId + kind: direct + textIncludes: + expr: config.sourceMarker + timeoutMs: + expr: liveTurnTimeoutMs(env, 60000) + saveAs: sourceOutbound + - sendInbound: + conversation: + id: + expr: config.groupConversationId + kind: channel + title: Remember Group + senderId: remember-group-member + senderName: Remember Group Member + text: + expr: "`@openclaw Stable QA movie night usual favorite snack preference: ${config.groupFact}. This applies only inside this group. Acknowledge briefly.`" + - waitForOutbound: + conversation: + id: + expr: config.groupConversationId + kind: channel + timeoutMs: + expr: liveTurnTimeoutMs(env, 60000) + saveAs: groupOutbound + - sendInbound: + conversation: + id: + expr: config.targetConversationId + kind: direct + senderId: + expr: config.targetConversationId + senderName: Remember Target + text: + expr: "`QA movie night snack recall anchor fixture: ${config.anchorFact}. This is a test marker, not a preference. Acknowledge briefly.`" + - waitForOutbound: + conversation: + id: + expr: config.targetConversationId + kind: direct + timeoutMs: + expr: liveTurnTimeoutMs(env, 60000) + saveAs: anchorOutbound + - call: readRawQaSessionStore + saveAs: seededStore + args: + - ref: env + - set: sourceSession + value: + expr: seededStore[sourceSessionKey] + - set: targetSession + value: + expr: seededStore[targetSessionKey] + - set: groupSession + value: + expr: seededStore[groupSessionKey] + - assert: + expr: "Boolean(sourceSession?.sessionId && sourceSession?.sessionFile)" + message: + expr: "`private source session missing: key=${sourceSessionKey} storeKeys=${Object.keys(seededStore).join(',')}`" + - assert: + expr: "Boolean(targetSession?.sessionId && targetSession?.sessionFile)" + message: + expr: "`private target session missing: key=${targetSessionKey} storeKeys=${Object.keys(seededStore).join(',')}`" + - assert: + expr: "Boolean(groupSession?.sessionId && groupSession?.sessionFile)" + message: + expr: "`group source session missing: key=${groupSessionKey} storeKeys=${Object.keys(seededStore).join(',')}`" + - assert: + expr: "new Set([sourceSession.sessionId, targetSession.sessionId, groupSession.sessionId]).size === 3" + message: source, target, and group sessions unexpectedly share a transcript id + - assert: + expr: "new Set([sourceSession.sessionFile, targetSession.sessionFile, groupSession.sessionFile]).size === 3" + message: source, target, and group sessions unexpectedly share a transcript file + - call: runQaCli + args: + - ref: env + - - memory + - index + - --agent + - qa + - --force + - timeoutMs: + expr: liveTurnTimeoutMs(env, 60000) + - call: env.gateway.restartAfterStateMutation + args: + - lambda: + async: true + expr: await Promise.resolve() + - call: fs.rm + args: + - ref: transcriptRoot + - recursive: true + force: true + - set: requestCountBeforeRecall + value: + expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length : 0" + - set: targetStartIndex + value: + expr: state.getSnapshot().messages.length + - sendInbound: + conversation: + id: + expr: config.targetConversationId + kind: direct + senderId: + expr: config.targetConversationId + senderName: Remember Target + text: + expr: config.recallPrompt + - call: waitForOutboundMessage + saveAs: targetOutbound + args: + - ref: state + - lambda: + params: [candidate] + expr: "candidate.conversation.id === config.targetConversationId && candidate.direction === 'outbound'" + - expr: liveTurnTimeoutMs(env, 60000) + - sinceIndex: + ref: targetStartIndex + - call: waitForCondition + saveAs: helperTranscriptPath + args: + - lambda: + async: true + expr: "await (async () => { const entries = (await fs.readdir(transcriptRoot).catch(() => [])).filter((entry) => entry.endsWith('.jsonl')).toSorted(); return entries.length > 0 ? path.join(transcriptRoot, entries.at(-1)) : undefined; })()" + - expr: liveTurnTimeoutMs(env, 30000) + - 250 + - call: fs.readFile + saveAs: helperTranscriptText + args: + - ref: helperTranscriptPath + - utf8 + - set: recallRequests + value: + expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBeforeRecall) : []" + - set: recallRequestDebug + value: + expr: "recallRequests.map((request) => ({ plannedToolName: request.plannedToolName ?? null, plannedToolArgs: request.plannedToolArgs ?? null, toolOutput: String(request.toolOutput ?? '').slice(0, 1200), finalText: String(request.finalText ?? '').slice(0, 300), allInputText: String(request.allInputText ?? '').slice(-500) }))" + - assert: + expr: "normalizeLowercaseStringOrEmpty(targetOutbound.text).includes(normalizeLowercaseStringOrEmpty(config.expectedNeedle))" + message: + expr: "`private target missed recalled preference: reply=${targetOutbound.text}; sessions=${JSON.stringify({ sourceSession, targetSession, groupSession })}; helper=${helperTranscriptText}; requests=${JSON.stringify(recallRequestDebug)}`" + - assert: + expr: helperTranscriptText.includes('memory_search') + message: Remember across conversations helper transcript missing memory_search + - assert: + expr: helperTranscriptText.includes(sourceSession.sessionId) + message: + expr: "`accepted helper evidence missing private source transcript ${sourceSession.sessionId}: ${helperTranscriptText}`" + - assert: + expr: "!helperTranscriptText.includes(groupSession.sessionId)" + message: + expr: "`group transcript ${groupSession.sessionId} leaked into accepted helper evidence: ${helperTranscriptText}`" + - assert: + expr: "!helperTranscriptText.includes(targetSession.sessionId)" + message: + expr: "`anchor transcript ${targetSession.sessionId} leaked into accepted helper evidence: ${helperTranscriptText}`" + - call: readConfigSnapshot + saveAs: afterRecall + args: + - ref: env + - set: afterSessionsVisibility + value: + expr: "afterRecall.config.tools && typeof afterRecall.config.tools === 'object' && typeof afterRecall.config.tools.sessions === 'object' ? afterRecall.config.tools.sessions.visibility : undefined" + - assert: + expr: afterSessionsVisibility === initialSessionsVisibility + message: + expr: "`tools.sessions.visibility changed from ${initialSessionsVisibility} to ${afterSessionsVisibility}`" + - if: + expr: Boolean(env.mock) + then: + - assert: + expr: "recallRequests.some((request) => String(request.allInputText ?? '').includes('Remember across conversations QA check') && request.plannedToolName === 'memory_search')" + message: deterministic recall did not issue memory_search + - set: helperCountBeforeGroupDestination + value: + expr: "(await fs.readdir(transcriptRoot).catch(() => [])).filter((entry) => entry.endsWith('.jsonl')).length" + - set: groupRequestCountBefore + value: + expr: "(await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length" + - sendInbound: + conversation: + id: + expr: config.groupConversationId + kind: channel + title: Remember Group + senderId: remember-group-member + senderName: Remember Group Member + text: + expr: "`@openclaw ${config.recallPrompt}`" + - call: waitForCondition + saveAs: groupDestinationRequests + args: + - lambda: + async: true + expr: "await (async () => { const requests = await fetchJson(`${env.mock.baseUrl}/debug/requests`); return requests.length > groupRequestCountBefore ? requests.slice(groupRequestCountBefore) : undefined; })()" + - expr: liveTurnTimeoutMs(env, 60000) + - 100 + - set: helperCountAfterGroupDestination + value: + expr: "(await fs.readdir(transcriptRoot).catch(() => [])).filter((entry) => entry.endsWith('.jsonl')).length" + - assert: + expr: helperCountAfterGroupDestination === helperCountBeforeGroupDestination + message: group destination unexpectedly started Remember across conversations recall + - assert: + expr: "groupDestinationRequests.some((request) => String(request.allInputText ?? '').includes('Remember across conversations QA check'))" + message: group destination did not reach the main model request + - assert: + expr: "groupDestinationRequests.every((request) => !String(request.allInputText ?? '').includes(config.privateFact) && !String(request.allInputText ?? '').includes(''))" + message: + expr: "`group destination received private recall context: ${JSON.stringify(groupDestinationRequests)}`" + - set: pauseCommandStartIndex + value: + expr: state.getSnapshot().messages.length + - sendInbound: + conversation: + id: + expr: config.pausedConversationId + kind: direct + senderId: qa-operator + senderName: QA Operator + text: /active-memory off + - call: sleep + args: + - 2000 + - set: pauseCommandMessages + value: + expr: state.getSnapshot().messages.slice(pauseCommandStartIndex) + - set: pauseCommandOutbound + value: + expr: "pauseCommandMessages.find((candidate) => candidate.direction === 'outbound')" + - assert: + expr: Boolean(pauseCommandOutbound) + message: + expr: "`Active Memory command produced no outbound message: ${JSON.stringify(pauseCommandMessages)}`" + - assert: + expr: "pauseCommandOutbound.text.includes('Active Memory: off for this session.')" + message: + expr: "`unexpected Active Memory command response: ${JSON.stringify(pauseCommandOutbound)}`" + - set: helperCountBeforePaused + value: + expr: "(await fs.readdir(transcriptRoot).catch(() => [])).filter((entry) => entry.endsWith('.jsonl')).length" + - set: pausedRequestCountBefore + value: + expr: "(await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length" + - sendInbound: + conversation: + id: + expr: config.pausedConversationId + kind: direct + senderId: + expr: config.pausedConversationId + senderName: Remember Paused + text: + expr: config.recallPrompt + - call: waitForCondition + saveAs: pausedRequests + args: + - lambda: + async: true + expr: "await (async () => { const requests = await fetchJson(`${env.mock.baseUrl}/debug/requests`); return requests.length > pausedRequestCountBefore ? requests.slice(pausedRequestCountBefore) : undefined; })()" + - expr: liveTurnTimeoutMs(env, 60000) + - 100 + - set: helperCountAfterPaused + value: + expr: "(await fs.readdir(transcriptRoot).catch(() => [])).filter((entry) => entry.endsWith('.jsonl')).length" + - assert: + expr: helperCountAfterPaused === helperCountBeforePaused + message: session-scoped pause unexpectedly started recall + - assert: + expr: "pausedRequests.every((request) => !String(request.allInputText ?? '').includes(config.privateFact) && !String(request.allInputText ?? '').includes(''))" + message: + expr: "`paused conversation received private recall context: ${JSON.stringify(pausedRequests)}`" + - set: freshRequestCountBefore + value: + expr: "(await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length" + - sendInbound: + conversation: + id: + expr: config.freshConversationId + kind: direct + senderId: + expr: config.freshConversationId + senderName: Remember Fresh + text: + expr: config.recallPrompt + - call: waitForCondition + saveAs: freshRequests + args: + - lambda: + async: true + expr: "await (async () => { const requests = (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(freshRequestCountBefore); return requests.some((request) => String(request.allInputText ?? '').includes('')) ? requests : undefined; })()" + - expr: liveTurnTimeoutMs(env, 60000) + - 100 + - set: helperCountAfterFresh + value: + expr: "(await fs.readdir(transcriptRoot).catch(() => [])).filter((entry) => entry.endsWith('.jsonl')).length" + - assert: + expr: helperCountAfterFresh > helperCountAfterPaused + message: session-scoped pause incorrectly disabled recall for a fresh private conversation + - assert: + expr: "freshRequests.some((request) => String(request.allInputText ?? '').includes(''))" + message: + expr: "`fresh private conversation missed Active Memory context: ${JSON.stringify(freshRequests)}`" + - call: patchConfig + args: + - env: + ref: env + patch: + agents: + defaults: + memorySearch: + expr: "{ ...structuredClone(originalMemorySearch ?? {}), rememberAcrossConversations: false }" + - call: waitForGatewayHealthy + args: + - ref: env + - 60000 + - call: waitForQaChannelReady + args: + - ref: env + - 60000 + - set: helperCountBeforeDisabled + value: + expr: "(await fs.readdir(transcriptRoot).catch(() => [])).filter((entry) => entry.endsWith('.jsonl')).length" + - set: disabledRequestCountBefore + value: + expr: "(await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length" + - sendInbound: + conversation: + id: + expr: config.disabledConversationId + kind: direct + senderId: + expr: config.disabledConversationId + senderName: Remember Disabled + text: + expr: config.recallPrompt + - call: waitForCondition + saveAs: disabledRequests + args: + - lambda: + async: true + expr: "await (async () => { const requests = await fetchJson(`${env.mock.baseUrl}/debug/requests`); return requests.length > disabledRequestCountBefore ? requests.slice(disabledRequestCountBefore) : undefined; })()" + - expr: liveTurnTimeoutMs(env, 60000) + - 100 + - set: helperCountAfterDisabled + value: + expr: "(await fs.readdir(transcriptRoot).catch(() => [])).filter((entry) => entry.endsWith('.jsonl')).length" + - assert: + expr: helperCountAfterDisabled === helperCountBeforeDisabled + message: disabled setting unexpectedly started Remember across conversations recall + - assert: + expr: "disabledRequests.every((request) => !String(request.allInputText ?? '').includes(config.privateFact) && !String(request.allInputText ?? '').includes(''))" + message: + expr: "`disabled setting received private recall context: ${JSON.stringify(disabledRequests)}`" + finally: + - call: patchConfig + args: + - env: + ref: env + patch: + agents: + defaults: + memorySearch: + expr: "originalMemorySearch === undefined ? null : structuredClone(originalMemorySearch)" + - call: waitForGatewayHealthy + args: + - ref: env + - 60000 + - call: waitForQaChannelReady + args: + - ref: env + - 60000 + detailsExpr: "[`sourceSession=${sourceSessionKey}`, `targetSession=${targetSessionKey}`, `groupSession=${groupSessionKey}`, `reply=${targetOutbound.text}`, `helperTranscript=${helperTranscriptPath}`, `visibility=${String(afterSessionsVisibility)}`].join('\\n')" diff --git a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts index c6f3bd3b8d88..f6a823bc987f 100644 --- a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts @@ -756,6 +756,30 @@ describe("createOpenClawCodingTools", () => { expect(latestCreateOpenClawToolsOptions().disablePluginTools).toBe(true); }); + it("forwards trusted conversation recall to OpenClaw tool construction", () => { + const createOpenClawToolsMock = vi.mocked(createOpenClawTools); + createOpenClawToolsMock.mockClear(); + const conversationRecall = { + anchorSessionKey: "agent:main:telegram:direct:owner", + scope: "same-agent-private" as const, + corpus: "sessions" as const, + }; + + createOpenClawCodingTools({ + config: testConfig, + conversationRecall, + toolConstructionPlan: { + includeBaseCodingTools: false, + includeShellTools: false, + includeChannelTools: false, + includeOpenClawTools: true, + includePluginTools: true, + }, + }); + + expect(latestCreateOpenClawToolsOptions().conversationRecall).toEqual(conversationRecall); + }); + it("keeps plugin-only construction off the OpenClaw core factory", () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index f69e1a55987a..99e04f9fe7ee 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -65,6 +65,7 @@ import { resolveConversationCapabilityProfile, type ResolvedConversationCapabilityProfile, } from "./conversation-capability-profile.js"; +import type { ConversationRecallContext } from "./conversation-recall.types.js"; import type { OpenClawCodingToolConstructionPlan } from "./core-tool-factory-descriptors.js"; import { applyDelegationCapability, type DelegationCapability } from "./delegation-capability.js"; import { resolveImageSanitizationLimits } from "./image-sanitization.js"; @@ -283,6 +284,8 @@ type OpenClawCodingToolsOptions = { clientCaps?: string[]; /** Out-of-band plugin bindings attached by the run initiator. */ toolBindings?: Readonly>; + /** Trusted runtime-only authorization for one bounded cross-conversation recall pass. */ + conversationRecall?: ConversationRecallContext; /** Normalized conversation kind when the caller already has channel metadata. */ chatType?: ChatType; /** Specific ingress provider used only for transport tool availability. */ @@ -860,6 +863,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) requesterSenderId: options?.senderId, senderIsOwner: options?.senderIsOwner, sessionId: options?.sessionId, + conversationRecall: options?.conversationRecall, oneShotCliRun: options?.oneShotCliRun, sandboxBrowserBridgeUrl: sandbox?.browser?.bridgeUrl, allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true, @@ -1002,6 +1006,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) senderIsOwner: options?.senderIsOwner, authProfileStore: options?.authProfileStore, sessionId: options?.sessionId, + conversationRecall: options?.conversationRecall, oneShotCliRun: options?.oneShotCliRun, inheritedToolAllowlist, inheritedToolDenylist, diff --git a/src/agents/conversation-recall.types.ts b/src/agents/conversation-recall.types.ts new file mode 100644 index 000000000000..1ce0e4b0c595 --- /dev/null +++ b/src/agents/conversation-recall.types.ts @@ -0,0 +1,8 @@ +export type ConversationRecallContext = { + /** Private conversation that requested this bounded recall pass. */ + anchorSessionKey: string; + /** Only same-agent private transcript hits may pass. */ + scope: "same-agent-private"; + /** Product-only recall searches sessions; advanced recall keeps configured corpora. */ + corpus: "sessions" | "configured"; +}; diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts index cf9c11efa8ea..918b9326df3d 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts @@ -91,10 +91,16 @@ function makeForwardingCase(internalEvents: AgentInternalEvent[]) { // Forwarding cases prove request-scoped flags survive the overflow-compaction // route into the eventual embedded attempt. const onAgentToolResult = vi.fn(); + const conversationRecall = { + anchorSessionKey: "agent:main:telegram:direct:owner", + scope: "same-agent-private" as const, + corpus: "sessions" as const, + }; return { runId: "forward-attempt-params", params: { toolsAllow: ["exec", "read"], + conversationRecall, bootstrapContextMode: "lightweight", bootstrapContextRunKind: "cron", disableMessageTool: true, @@ -108,6 +114,7 @@ function makeForwardingCase(internalEvents: AgentInternalEvent[]) { }, expected: { toolsAllow: ["exec", "read"], + conversationRecall, bootstrapContextMode: "lightweight", bootstrapContextRunKind: "cron", disableMessageTool: true, diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts index 24968bcf6f38..52a4d3547cd7 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts @@ -238,6 +238,7 @@ export function prepareEmbeddedAttemptToolBase(params: { : undefined, sessionId: attempt.sessionId, runId: attempt.runId, + conversationRecall: attempt.conversationRecall, approvalReviewerDeviceId: attempt.approvalReviewerDeviceId, oneShotCliRun: attempt.oneShotCliRun, toolSearchCatalogRef, diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index 93559fd78878..acfd46ba079c 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -30,6 +30,7 @@ import type { import type { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.exec-types.js"; import type { BootstrapContextRunKind } from "../../bootstrap-mode.js"; import type { AgentStreamParams, ClientToolDefinition } from "../../command/shared-types.js"; +import type { ConversationRecallContext } from "../../conversation-recall.types.js"; import type { BlockReplyPayload } from "../../embedded-agent-payloads.js"; import type { BlockReplyChunking, @@ -249,6 +250,8 @@ export type RunEmbeddedAgentParams = { */ runTimeoutOverrideMs?: number; runId: string; + /** Trusted runtime-only authorization for one bounded cross-conversation recall pass. */ + conversationRecall?: ConversationRecallContext; abortSignal?: AbortSignal; onExecutionStarted?: (info?: { lifecycleGeneration?: string }) => void; onExecutionPhase?: (info: { diff --git a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts index a06438304c36..249d9723e118 100644 --- a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts +++ b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts @@ -163,6 +163,7 @@ export async function dispatchEmbeddedRunAttempt(input: { const rawAttempt = await runEmbeddedAttemptWithBackend({ sessionId: runtime.sessionId, sessionKey: runtime.sessionKey, + conversationRecall: params.conversationRecall, promptCacheKey: params.promptCacheKey, sandboxSessionKey: params.sandboxSessionKey, trigger: params.trigger, diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts index 47c1a59b2d5c..fe2a3fd549dd 100644 --- a/src/agents/memory-search.test.ts +++ b/src/agents/memory-search.test.ts @@ -234,6 +234,77 @@ describe("memory search config", () => { expect(resolved).toBeNull(); }); + it("keeps cross-conversation recall off by default", () => { + const resolved = resolveMemorySearchConfig(asConfig({}), "main"); + + expect(resolved?.rememberAcrossConversations).toBe(false); + expect(resolved?.experimental.sessionMemory).toBe(false); + expect(resolved?.sources).toEqual(["memory"]); + }); + + it("enables transcript indexing for an opted-in agent", () => { + const cfg = asConfig({ + agents: { + list: [ + { + id: "personal", + memorySearch: { rememberAcrossConversations: true }, + }, + ], + }, + }); + + const resolved = resolveMemorySearchConfig(cfg, "personal"); + + expect(resolved?.rememberAcrossConversations).toBe(true); + expect(resolved?.experimental.sessionMemory).toBe(true); + expect(resolved?.sources).toEqual(["memory", "sessions"]); + expect(resolved?.searchSources).toEqual(["memory"]); + }); + + it("preserves explicitly configured transcript search for an opted-in agent", () => { + const cfg = asConfig({ + agents: { + list: [ + { + id: "personal", + memorySearch: { + rememberAcrossConversations: true, + sources: ["sessions"], + }, + }, + ], + }, + }); + + const resolved = resolveMemorySearchConfig(cfg, "personal"); + + expect(resolved?.sources).toEqual(["sessions"]); + expect(resolved?.searchSources).toEqual(["sessions"]); + }); + + it("lets a per-agent false override a default true", () => { + const cfg = asConfig({ + agents: { + defaults: { + memorySearch: { rememberAcrossConversations: true }, + }, + list: [ + { + id: "shared", + memorySearch: { rememberAcrossConversations: false }, + }, + ], + }, + }); + + const resolved = resolveMemorySearchConfig(cfg, "shared"); + + expect(resolved?.rememberAcrossConversations).toBe(false); + expect(resolved?.experimental.sessionMemory).toBe(false); + expect(resolved?.sources).toEqual(["memory"]); + }); + it("defaults provider to openai when unspecified", () => { const cfg = asConfig({ agents: { diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index a10203528234..f4b380f0b62e 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -30,7 +30,11 @@ import { resolveAgentConfig } from "./agent-scope.js"; export type ResolvedMemorySearchConfig = { enabled: boolean; + rememberAcrossConversations: boolean; + /** Sources indexed by the manager. */ sources: Array<"memory" | "sessions">; + /** Sources searched when memory_search omits an explicit corpus. */ + searchSources: Array<"memory" | "sessions">; extraPaths: string[]; multimodal: MemoryMultimodalSettings; provider: string; @@ -220,8 +224,11 @@ function mergeConfig( agentId: string, ): ResolvedMemorySearchConfig { const enabled = overrides?.enabled ?? defaults?.enabled ?? true; - const sessionMemory = + const rememberAcrossConversations = + overrides?.rememberAcrossConversations ?? defaults?.rememberAcrossConversations ?? false; + const configuredSessionMemory = overrides?.experimental?.sessionMemory ?? defaults?.experimental?.sessionMemory ?? false; + const sessionMemory = rememberAcrossConversations || configuredSessionMemory; const rawProvider = overrides?.provider ?? defaults?.provider; const provider = rawProvider?.trim() === "auto" @@ -288,7 +295,16 @@ function mergeConfig( modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir, contextSize: overrides?.local?.contextSize ?? defaults?.local?.contextSize, }; - const sources = normalizeSources(overrides?.sources ?? defaults?.sources, sessionMemory); + const configuredSources = overrides?.sources ?? defaults?.sources; + const searchSources = normalizeSources( + configuredSources, + configuredSessionMemory || + (rememberAcrossConversations && configuredSources?.includes("sessions") === true), + ); + const sources = normalizeSources( + rememberAcrossConversations ? [...searchSources, "sessions"] : configuredSources, + sessionMemory, + ); const rawPaths = normalizeStringEntries([ ...(defaults?.extraPaths ?? []), ...(overrides?.extraPaths ?? []), @@ -386,7 +402,9 @@ function mergeConfig( const postCompactionForce = sync.sessions.postCompactionForce; return { enabled, + rememberAcrossConversations, sources, + searchSources, extraPaths, multimodal, provider, diff --git a/src/agents/openclaw-tools.plugin-context.test.ts b/src/agents/openclaw-tools.plugin-context.test.ts index 36528dd6d51b..8b36637e6bf1 100644 --- a/src/agents/openclaw-tools.plugin-context.test.ts +++ b/src/agents/openclaw-tools.plugin-context.test.ts @@ -92,6 +92,22 @@ describe("openclaw plugin tool context", () => { expect(result.context.sessionId).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); }); + it("forwards trusted private conversation recall context", () => { + const conversationRecall = { + anchorSessionKey: "agent:main:telegram:direct:owner", + scope: "same-agent-private" as const, + corpus: "sessions" as const, + }; + const result = resolveOpenClawPluginToolInputs({ + options: { + config: {} as never, + conversationRecall, + }, + }); + + expect(result.context.conversationRecall).toEqual(conversationRecall); + }); + it("forwards runtime-owned active model metadata", () => { const result = resolveOpenClawPluginToolInputs({ options: { diff --git a/src/agents/openclaw-tools.plugin-context.ts b/src/agents/openclaw-tools.plugin-context.ts index 98e093d7e681..c355e5ea23c4 100644 --- a/src/agents/openclaw-tools.plugin-context.ts +++ b/src/agents/openclaw-tools.plugin-context.ts @@ -11,6 +11,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.js"; import type { GatewayMessageChannel } from "../utils/message-channel.js"; import { resolveAgentWorkspaceDir, resolveSessionAgentIds } from "./agent-scope.js"; +import type { ConversationRecallContext } from "./conversation-recall.types.js"; import { modelKey } from "./model-ref-shared.js"; import type { ToolFsPolicy } from "./tool-fs-policy.js"; import { resolveWorkspaceRoot } from "./workspace-dir.js"; @@ -38,6 +39,7 @@ export type OpenClawPluginToolOptions = { conversationReadOrigin?: ConversationReadInvocationOrigin; requesterAgentIdOverride?: string; sessionId?: string; + conversationRecall?: ConversationRecallContext; /** * Explicit one-shot local CLI runs should not keep plugin-owned process * resources alive after emitting their result. @@ -99,6 +101,7 @@ export function resolveOpenClawPluginToolInputs(params: { sessionKey: options?.agentSessionKey, sessionId: options?.sessionId, toolBindings: options?.toolBindings, + conversationRecall: options?.conversationRecall, activeModel, browser: { sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl, diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index b355c0395075..d60801c58e13 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -25,6 +25,7 @@ import { wrapToolWithBeforeToolCallHook, } from "./agent-tools.before-tool-call.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; +import type { ConversationRecallContext } from "./conversation-recall.types.js"; import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js"; import { isToolExplicitlyAllowedByFactoryPolicy, @@ -207,6 +208,8 @@ export function createOpenClawTools( authProfileStore?: AuthProfileStore; /** Ephemeral session UUID — regenerated on /new and /reset. */ sessionId?: string; + /** Trusted runtime-only authorization for one bounded cross-conversation recall pass. */ + conversationRecall?: ConversationRecallContext; /** * Explicit one-shot local CLI runs should not keep plugin-owned process * resources alive after emitting their result. diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index 831f41292c68..d847a4dfef59 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -606,6 +606,196 @@ describe("noteMemorySearchHealth", () => { expect(firstNoteMessage()).toContain("No active memory plugin is registered"); }); + it("does not warn about conversation recall when the setting is off", async () => { + const qmdCfg = { + memory: { backend: "qmd", qmd: { command: "qmd" } }, + agents: { list: [{ id: "personal", memorySearch: { rememberAcrossConversations: false } }] }, + } as OpenClawConfig; + resolveMemorySearchConfig.mockReturnValue({ + provider: "auto", + local: {}, + remote: {}, + }); + + await noteMemorySearchHealth(qmdCfg, { skipQmdBinaryProbe: true }); + + expect(note).not.toHaveBeenCalled(); + }); + + it("does not warn when conversation recall and Active Memory are available", async () => { + const qmdCfg = { + memory: { backend: "qmd", qmd: { command: "qmd" } }, + agents: { list: [{ id: "personal", memorySearch: { rememberAcrossConversations: true } }] }, + plugins: { entries: { "active-memory": { enabled: true } } }, + } as OpenClawConfig; + resolveMemorySearchConfig.mockReturnValue({ + provider: "auto", + local: {}, + remote: {}, + rememberAcrossConversations: true, + sources: ["memory", "sessions"], + }); + + await noteMemorySearchHealth(qmdCfg, { skipQmdBinaryProbe: true }); + + expect(note).not.toHaveBeenCalled(); + }); + + it("does not treat Lossless Claw's context-engine slot as a memory-slot conflict", async () => { + const qmdCfg = { + memory: { backend: "qmd", qmd: { command: "qmd" } }, + agents: { list: [{ id: "personal", memorySearch: { rememberAcrossConversations: true } }] }, + plugins: { + slots: { contextEngine: "lossless-claw" }, + entries: { + "active-memory": { enabled: true }, + "lossless-claw": { enabled: true }, + }, + }, + } as OpenClawConfig; + resolveMemorySearchConfig.mockReturnValue({ + provider: "auto", + local: {}, + remote: {}, + rememberAcrossConversations: true, + sources: ["memory", "sessions"], + }); + + await noteMemorySearchHealth(qmdCfg, { skipQmdBinaryProbe: true }); + + expect(note).not.toHaveBeenCalled(); + }); + + it.each([ + { + memoryProvider: "memory-lancedb", + activeMemoryConfig: undefined, + }, + { + memoryProvider: "custom-memory", + activeMemoryConfig: { toolsAllow: ["memory_search"] }, + }, + ])( + "warns when the $memoryProvider provider lacks protected transcript recall", + async ({ memoryProvider, activeMemoryConfig }) => { + const qmdCfg = { + memory: { backend: "qmd", qmd: { command: "qmd" } }, + agents: { + list: [{ id: "personal", memorySearch: { rememberAcrossConversations: true } }], + }, + plugins: { + slots: { memory: memoryProvider }, + entries: { + "active-memory": { + enabled: true, + ...(activeMemoryConfig ? { config: activeMemoryConfig } : {}), + }, + }, + }, + } as OpenClawConfig; + resolveMemorySearchConfig.mockReturnValue({ + provider: "auto", + local: {}, + remote: {}, + rememberAcrossConversations: true, + sources: ["memory", "sessions"], + }); + + await noteMemorySearchHealth(qmdCfg, { skipQmdBinaryProbe: true }); + + expect(firstNoteMessage()).toBe( + 'Remember across conversations is enabled for agent "personal", but the current memory provider does not support protected private transcript recall. Turn off Remember across conversations or use that provider\'s own recall path; advanced Active Memory can still use its recall tools.', + ); + }, + ); + + it("warns when conversation recall is enabled but Active Memory is disabled", async () => { + const qmdCfg = { + memory: { backend: "qmd", qmd: { command: "qmd" } }, + agents: { list: [{ id: "personal", memorySearch: { rememberAcrossConversations: true } }] }, + plugins: { entries: { "active-memory": { enabled: false } } }, + } as OpenClawConfig; + resolveMemorySearchConfig.mockReturnValue({ + provider: "auto", + local: {}, + remote: {}, + rememberAcrossConversations: true, + sources: ["memory", "sessions"], + }); + + await noteMemorySearchHealth(qmdCfg, { skipQmdBinaryProbe: true }); + + expect(firstNoteMessage()).toBe( + 'Remember across conversations is enabled for agent "personal", but the Active Memory plugin is disabled. Enable the plugin or turn off Remember across conversations.', + ); + }); + + it("warns when conversation recall is enabled but Active Memory is paused in plugin config", async () => { + const qmdCfg = { + memory: { backend: "qmd", qmd: { command: "qmd" } }, + agents: { list: [{ id: "personal", memorySearch: { rememberAcrossConversations: true } }] }, + plugins: { + entries: { + "active-memory": { enabled: true, config: { enabled: false } }, + }, + }, + } as OpenClawConfig; + resolveMemorySearchConfig.mockReturnValue({ + provider: "auto", + local: {}, + remote: {}, + rememberAcrossConversations: true, + sources: ["memory", "sessions"], + }); + + await noteMemorySearchHealth(qmdCfg, { skipQmdBinaryProbe: true }); + + expect(firstNoteMessage()).toContain("Active Memory plugin is disabled"); + }); + + it("warns when Active Memory excludes memory_search for conversation recall", async () => { + const qmdCfg = { + memory: { backend: "qmd", qmd: { command: "qmd" } }, + agents: { list: [{ id: "personal", memorySearch: { rememberAcrossConversations: true } }] }, + plugins: { + entries: { + "active-memory": { enabled: true, config: { toolsAllow: ["memory_get"] } }, + }, + }, + } as OpenClawConfig; + resolveMemorySearchConfig.mockReturnValue({ + provider: "auto", + local: {}, + remote: {}, + rememberAcrossConversations: true, + sources: ["memory", "sessions"], + }); + + await noteMemorySearchHealth(qmdCfg, { skipQmdBinaryProbe: true }); + + expect(firstNoteMessage()).toBe( + 'Remember across conversations is enabled for agent "personal", but Active Memory does not allow memory_search. Add memory_search to the plugin toolsAllow list or turn off Remember across conversations.', + ); + }); + + it("warns when an opted-in agent has memory search disabled", async () => { + const qmdCfg = { + memory: { backend: "qmd", qmd: { command: "qmd" } }, + agents: { list: [{ id: "personal", memorySearch: { rememberAcrossConversations: true } }] }, + } as OpenClawConfig; + resolveMemorySearchConfig.mockImplementation((_cfg: OpenClawConfig, agentId: string) => + agentId === "personal" + ? undefined + : { provider: "auto", local: {}, remote: {}, sources: ["memory"] }, + ); + + await noteMemorySearchHealth(qmdCfg, { skipQmdBinaryProbe: true }); + + expect(firstNoteMessage()).toBe( + 'Remember across conversations is enabled for agent "personal", but memory search is disabled. Enable memory search or turn off Remember across conversations.', + ); + }); + it("does not warn when QMD backend is active", async () => { const qmdCfg = { memory: { backend: "qmd", qmd: { command: "qmd" } } } as OpenClawConfig; resolveMemorySearchConfig.mockReturnValue({ diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index 8543b73dd975..f232f5834457 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -440,6 +440,112 @@ function hasActiveAlternateMemoryPluginSlot(cfg: OpenClawConfig): boolean { return entry.enabled === true || entry.config !== undefined; } +function listRememberAcrossConversationAgentIds( + cfg: OpenClawConfig, + defaultAgentId: string, +): string[] { + const defaultsEnabled = cfg.agents?.defaults?.memorySearch?.rememberAcrossConversations === true; + const configuredAgents = cfg.agents?.list ?? []; + const enabledAgentIds = configuredAgents + .filter( + (agent) => + (agent.memorySearch?.rememberAcrossConversations ?? defaultsEnabled) && + Boolean(agent.id?.trim()), + ) + .map((agent) => agent.id.trim()); + const defaultEntry = configuredAgents.find( + (agent) => agent.id.trim().toLowerCase() === defaultAgentId.trim().toLowerCase(), + ); + if ( + (defaultEntry?.memorySearch?.rememberAcrossConversations ?? defaultsEnabled) && + !enabledAgentIds.some( + (agentId) => agentId.toLowerCase() === defaultAgentId.trim().toLowerCase(), + ) + ) { + enabledAgentIds.push(defaultAgentId); + } + return [...new Set(enabledAgentIds)]; +} + +function isActiveMemoryPluginAvailable(cfg: OpenClawConfig): boolean { + const plugins = normalizePluginsConfig(cfg.plugins); + if (!plugins.enabled || plugins.deny.includes("active-memory")) { + return false; + } + if (plugins.allow.length > 0 && !plugins.allow.includes("active-memory")) { + return false; + } + const entry = plugins.entries["active-memory"]; + if (entry?.enabled === false) { + return false; + } + const pluginConfig = isRecord(entry?.config) ? entry.config : undefined; + return pluginConfig?.enabled !== false; +} + +function resolveActiveMemoryConversationRecallSupport(cfg: OpenClawConfig): { + providerSupported: boolean; + memorySearchAllowed: boolean; +} { + const plugins = normalizePluginsConfig(cfg.plugins); + const providerSupported = plugins.slots.memory === defaultSlotIdForKey("memory"); + const entry = cfg.plugins?.entries?.["active-memory"]; + const config = isRecord(entry?.config) ? entry.config : undefined; + if (!Array.isArray(config?.toolsAllow)) { + return { providerSupported, memorySearchAllowed: true }; + } + return { + providerSupported, + memorySearchAllowed: config.toolsAllow.some( + (toolName) => + typeof toolName === "string" && toolName.trim().toLowerCase() === "memory_search", + ), + }; +} + +function noteRememberAcrossConversationsHealth(params: { + cfg: OpenClawConfig; + defaultAgentId: string; + noteFn: typeof note; +}): { defaultAgentEnabled: boolean } { + const agentIds = listRememberAcrossConversationAgentIds(params.cfg, params.defaultAgentId); + const activeMemoryAvailable = isActiveMemoryPluginAvailable(params.cfg); + const conversationRecallSupport = resolveActiveMemoryConversationRecallSupport(params.cfg); + for (const agentId of agentIds) { + if (!activeMemoryAvailable) { + params.noteFn( + `Remember across conversations is enabled for agent "${agentId}", but the Active Memory plugin is disabled. Enable the plugin or turn off Remember across conversations.`, + "Memory search", + ); + } + if (activeMemoryAvailable && !conversationRecallSupport.providerSupported) { + params.noteFn( + `Remember across conversations is enabled for agent "${agentId}", but the current memory provider does not support protected private transcript recall. Turn off Remember across conversations or use that provider's own recall path; advanced Active Memory can still use its recall tools.`, + "Memory search", + ); + } else if (activeMemoryAvailable && !conversationRecallSupport.memorySearchAllowed) { + params.noteFn( + `Remember across conversations is enabled for agent "${agentId}", but Active Memory does not allow memory_search. Add memory_search to the plugin toolsAllow list or turn off Remember across conversations.`, + "Memory search", + ); + } + if ( + agentId.trim().toLowerCase() !== params.defaultAgentId.trim().toLowerCase() && + !resolveMemorySearchConfig(params.cfg, agentId) + ) { + params.noteFn( + `Remember across conversations is enabled for agent "${agentId}", but memory search is disabled. Enable memory search or turn off Remember across conversations.`, + "Memory search", + ); + } + } + return { + defaultAgentEnabled: agentIds.some( + (agentId) => agentId.trim().toLowerCase() === params.defaultAgentId.trim().toLowerCase(), + ), + }; +} + /** * Check whether memory search has a usable embedding provider. * Runs as part of `openclaw doctor` — config-only checks where possible; @@ -469,11 +575,21 @@ export async function noteMemorySearchHealth( const agentId = resolveDefaultAgentId(cfg); const agentDir = resolveAgentDir(cfg, agentId); + const recallHealth = noteRememberAcrossConversationsHealth({ + cfg, + defaultAgentId: agentId, + noteFn, + }); const resolved = resolveMemorySearchConfig(cfg, agentId); const hasRemoteApiKey = hasConfiguredMemorySecretInput(resolved?.remote?.apiKey); if (!resolved) { - noteFn("Memory search is explicitly disabled (enabled: false).", "Memory search"); + noteFn( + recallHealth.defaultAgentEnabled + ? `Remember across conversations is enabled for agent "${agentId}", but memory search is disabled. Enable memory search or turn off Remember across conversations.` + : "Memory search is explicitly disabled (enabled: false).", + "Memory search", + ); return; } const provider = @@ -527,7 +643,11 @@ export async function noteMemorySearchHealth( ); } } - if (resolved.sources?.includes("sessions") && cfg.memory?.qmd?.sessions?.enabled !== true) { + if ( + resolved.sources?.includes("sessions") && + !resolved.rememberAcrossConversations && + cfg.memory?.qmd?.sessions?.enabled !== true + ) { noteFn( [ "QMD memory backend is configured and the default agent resolves memorySearch.sources with sessions,", diff --git a/src/config/schema.help.models.ts b/src/config/schema.help.models.ts index e3a6a7984318..7aaf4feceb37 100644 --- a/src/config/schema.help.models.ts +++ b/src/config/schema.help.models.ts @@ -224,6 +224,8 @@ export const MODEL_FIELD_HELP: Record = { "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", "agents.defaults.memorySearch.enabled": "Master toggle for memory search indexing and retrieval behavior on this agent profile. Keep enabled for semantic recall, and disable when you want fully stateless responses.", + "agents.defaults.memorySearch.rememberAcrossConversations": + "Use relevant context from this agent's other private conversations through protected transcript recall. Groups and channels stay separate. Enable this only for a personal or fully trusted agent.", "agents.defaults.memorySearch.sources": 'Chooses which sources are indexed: "memory" reads MEMORY.md + memory files, and "sessions" includes transcript history. Keep ["memory"] unless you need recall from prior chat transcripts.', "agents.defaults.memorySearch.extraPaths": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 43fd5a35e208..1f2b15646eae 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -492,6 +492,7 @@ export const FIELD_LABELS: Record = { "agents.defaults.envelopeElapsed": "Envelope Elapsed", "agents.defaults.memorySearch": "Memory Search", "agents.defaults.memorySearch.enabled": "Enable Memory Search", + "agents.defaults.memorySearch.rememberAcrossConversations": "Remember Across Conversations", "agents.defaults.memorySearch.sources": "Memory Search Sources", "agents.defaults.memorySearch.extraPaths": "Extra Memory Paths", "agents.defaults.memorySearch.qmd": "Memory Search QMD Collections", diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 1bc2e912c72f..b9d2bd8c3dee 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -436,6 +436,8 @@ export type AgentToolsConfig = { export type MemorySearchConfig = { /** Enable vector memory search (default: true). */ enabled?: boolean; + /** Use relevant context from this agent's other private conversations. */ + rememberAcrossConversations?: boolean; /** Sources to index and search (default: ["memory"]). */ sources?: Array<"memory" | "sessions">; /** Extra paths to include in memory search (directories or .md files). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 64730ec07b37..413f91950163 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -833,6 +833,7 @@ const AgentToolsSchema = z export const MemorySearchSchema = z .object({ enabled: z.boolean().optional(), + rememberAcrossConversations: z.boolean().optional(), sources: z.array(z.union([z.literal("memory"), z.literal("sessions")])).optional(), extraPaths: z.array(z.string()).optional(), qmd: z diff --git a/src/plugins/tool-types.ts b/src/plugins/tool-types.ts index 77d4a61d9e0e..9a0866bb1e4a 100644 --- a/src/plugins/tool-types.ts +++ b/src/plugins/tool-types.ts @@ -1,4 +1,5 @@ // Defines plugin tool metadata and filesystem policy types. +import type { ConversationRecallContext } from "../agents/conversation-recall.types.js"; import type { ToolFsPolicy } from "../agents/tool-fs-policy.types.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ConversationReadInvocationOrigin } from "../channels/plugins/conversation-read-origin.js"; @@ -29,6 +30,8 @@ export type OpenClawPluginToolContext = { sessionId?: string; /** Out-of-band plugin-owned bindings attached by the current run initiator. */ toolBindings?: Readonly>; + /** Trusted runtime-only authorization for one bounded cross-conversation recall pass. */ + conversationRecall?: ConversationRecallContext; /** * Runtime-supplied active model metadata for informational use, diagnostics, * and plugin-owned policy decisions. This is not a security boundary against