mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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 <steipete@gmail.com>
This commit is contained in:
co-authored by
Vincent Koc
Peter Steinberger
parent
64f3397874
commit
4b3ee5e7eb
@@ -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
|
||||
|
||||
+114
-64
@@ -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.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Embedding provider switched or stopped working">
|
||||
|
||||
@@ -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`:
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
@@ -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.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
## Quick start
|
||||
|
||||
```json5
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
</Warning>
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -180,6 +180,7 @@ describe("active-memory plugin", () => {
|
||||
agents: ["main"],
|
||||
logging: true,
|
||||
};
|
||||
let apiConfig: Record<string, unknown> = {};
|
||||
const syncRuntimePluginConfig = (nextPluginConfig: Record<string, unknown>) => {
|
||||
pluginConfig = nextPluginConfig;
|
||||
const plugins = configFile.plugins as Record<string, unknown> | undefined;
|
||||
@@ -220,7 +221,17 @@ describe("active-memory plugin", () => {
|
||||
set pluginConfig(nextPluginConfig: Record<string, unknown>) {
|
||||
syncRuntimePluginConfig(nextPluginConfig);
|
||||
},
|
||||
config: {},
|
||||
get config() {
|
||||
return apiConfig;
|
||||
},
|
||||
set config(nextConfig: Record<string, unknown>) {
|
||||
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<string, unknown>),
|
||||
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";
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
);
|
||||
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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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<RecallSubagentResult> {
|
||||
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,
|
||||
|
||||
@@ -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<ActiveRecallResult> {
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<OpenClawPluginToolContext["conversationRecall"]>;
|
||||
type ActiveMemoryPromptStyle =
|
||||
| "balanced"
|
||||
| "strict"
|
||||
@@ -402,6 +404,7 @@ export type {
|
||||
ActiveRecallResult,
|
||||
CachedActiveRecallResult,
|
||||
CircuitBreakerEntry,
|
||||
ConversationRecallContext,
|
||||
PluginDebugEntry,
|
||||
RecallSubagentResult,
|
||||
ResolvedActiveRecallPluginConfig,
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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<unknown[]>;
|
||||
export type MemoryReadParams = { relPath: string; from?: number; lines?: number };
|
||||
|
||||
@@ -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<NonNullable<TestCfg["models"]>["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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,6 +41,7 @@ export class QmdDocumentResolver {
|
||||
private readonly workspaceDir: string,
|
||||
private readonly collectionRoots: ReadonlyMap<string, QmdCollectionRoot>,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<void>((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) {
|
||||
|
||||
@@ -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<string, TestSessionEntry> = {
|
||||
"agent:peer:only": {
|
||||
sessionId: "w1",
|
||||
updatedAt: 1,
|
||||
sessionFile: "/tmp/sessions/w1.jsonl",
|
||||
},
|
||||
};
|
||||
let combinedSessionStore: Record<string, TestSessionEntry> = crossAgentStore;
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("openclaw/plugin-sdk/session-transcript-hit")>();
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, TestSessionEntry> = {
|
||||
"agent:peer:only": {
|
||||
sessionId: "w1",
|
||||
updatedAt: 1,
|
||||
sessionFile: "/tmp/sessions/w1.jsonl",
|
||||
},
|
||||
};
|
||||
let combinedSessionStore: Record<string, TestSessionEntry> = crossAgentStore;
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/session-transcript-hit", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("openclaw/plugin-sdk/session-transcript-hit")>();
|
||||
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<string> {
|
||||
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<typeof resolveQmdSessionArtifactIdentity>[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]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<OpenClawPluginToolContext["conversationRecall"]>;
|
||||
|
||||
type SessionStore = ReturnType<typeof loadCombinedSessionStoreForGateway>["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<typeof chatType> => 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<MemorySearchResult[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" } },
|
||||
},
|
||||
|
||||
@@ -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<string, { until: number; error: string }>();
|
||||
|
||||
/**
|
||||
* 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<T extends string>(
|
||||
rawParams: Record<string, unknown>,
|
||||
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<MemorySource>(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({
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -3071,6 +3071,108 @@ describe("qa mock openai server", () => {
|
||||
expect(String(lastRequestPayload.instructions)).toContain("<active_memory_plugin>");
|
||||
expect(String(lastRequestPayload.allInputText)).toContain("<active_memory_plugin>");
|
||||
|
||||
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:
|
||||
"<active_memory_plugin>User usually wants lemon pepper wings with blue cheese for QA movie night.</active_memory_plugin>",
|
||||
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: {
|
||||
|
||||
@@ -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<Record<string, unknown>>)
|
||||
: [];
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")], () => {
|
||||
|
||||
@@ -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<string>();
|
||||
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),
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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('<active_memory_plugin>'))"
|
||||
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('<active_memory_plugin>'))"
|
||||
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('<active_memory_plugin>')) ? 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('<active_memory_plugin>'))"
|
||||
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('<active_memory_plugin>'))"
|
||||
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')"
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
/** 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,
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,",
|
||||
|
||||
@@ -224,6 +224,8 @@ export const MODEL_FIELD_HELP: Record<string, string> = {
|
||||
"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":
|
||||
|
||||
@@ -492,6 +492,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
/** 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
|
||||
|
||||
Reference in New Issue
Block a user