Compare commits

...

19 Commits

Author SHA1 Message Date
Tak Hoffman
bd842b0574 Refine active memory plugin summary flow 2026-04-08 19:56:33 -05:00
Tak Hoffman
433348672d Let active memory subagent decide relevance 2026-04-08 18:07:17 -05:00
Tak Hoffman
46ab4ffe01 Reuse canonical session keys for active memory 2026-04-08 17:40:20 -05:00
Tak Hoffman
b958bccecf Preserve numeric active memory bullets 2026-04-08 17:35:01 -05:00
Tak Hoffman
c13989d08e Match legacy active memory status prefixes 2026-04-08 17:24:57 -05:00
Tak Hoffman
208c0ef783 Clear stale active memory status lines 2026-04-08 17:17:29 -05:00
Tak Hoffman
64bc1a8e4c Keep usage footer on primary reply 2026-04-08 16:22:13 -05:00
Tak Hoffman
dd7999047b Raise active memory timeout default 2026-04-08 16:00:07 -05:00
Tak Hoffman
7ffb2ceaac Add active memory policy config 2026-04-08 15:52:24 -05:00
Tak Hoffman
5e23a26098 Harden active memory debug and transcript handling 2026-04-08 15:32:26 -05:00
Tak Hoffman
19f3740910 Add active memory changelog entry 2026-04-08 15:12:17 -05:00
Tak Hoffman
b224c12ae1 Sanitize recalled context before retrieval 2026-04-08 15:06:19 -05:00
Tak Hoffman
32bcb93527 Preserve active memory session scope 2026-04-08 14:48:48 -05:00
Tak Hoffman
cbb2697215 Fix active memory cache and recall selection 2026-04-08 14:26:06 -05:00
Tak Hoffman
dae966a9b6 Rename active memory blocking subagent wording 2026-04-08 14:12:27 -05:00
Tak Hoffman
b8540b46b5 Abort active memory sidecar on timeout 2026-04-08 14:07:37 -05:00
Tak Hoffman
8f64587a1e Reduce active memory overhead 2026-04-08 13:46:15 -05:00
Tak Hoffman
8f0698d148 Tighten plugin debug handling 2026-04-08 13:32:40 -05:00
Tak Hoffman
8abf8a1bee Refine plugin debug plumbing 2026-04-08 13:01:25 -05:00
13 changed files with 2866 additions and 1 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Memory/Active Memory: add a new optional Active Memory plugin that gives OpenClaw a dedicated memory subagent right before the main reply, so ongoing chats can automatically pull in relevant preferences, context, and past details without making users remember to manually say "remember this" or "search memory" first. Includes configurable message/recent/full context modes, live `/verbose` inspection, and opt-in transcript persistence for debugging.
- CLI/infer: add a first-class `openclaw infer ...` hub for provider-backed inference workflows across model, media, web, and embedding tasks. Thanks @Takhoffman.
- Plugins/webhooks: add a bundled webhook ingress plugin so external automation can create and drive bound TaskFlows through per-route shared-secret endpoints. (#61892) Thanks @mbelinky.
- Tools/media generation: preserve intent across auth-backed image, music, and video provider fallback, remap size, aspect ratio, resolution, and duration hints to the closest supported option, and surface explicit provider capabilities plus mode-aware video-to-video support.

View File

@@ -0,0 +1,504 @@
---
title: "Active Memory"
summary: "A plugin-owned blocking memory subagent that injects relevant memory into interactive chat sessions"
read_when:
- You want to understand what active memory is for
- You want to turn active memory on for a conversational agent
- You want to tune active memory behavior without enabling it everywhere
---
# Active Memory
Active memory is an optional plugin-owned blocking memory subagent that runs
before the main reply for eligible conversational sessions.
It exists because most memory systems are capable but reactive. They rely on
the main agent to decide when to search memory, or on the user to say things
like "remember this" or "search memory." By then, the moment where memory would
have made the reply feel natural has already passed.
Active memory gives the system one bounded chance to surface relevant memory
before the main reply is generated.
## Paste This Into Your Agent
Paste this into your agent if you want it to enable Active Memory with a
self-contained, safe-default setup:
```json5
{
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
agents: ["main"],
allowedChatTypes: ["direct"],
modelFallbackPolicy: "default-remote",
queryMode: "recent",
timeoutMs: 15000,
maxSummaryChars: 220,
persistTranscripts: false,
logging: true,
},
},
},
},
}
```
This turns the plugin on for the `main` agent, keeps it limited to direct-message
style sessions by default, lets it inherit the current session model first, and
still allows the built-in remote fallback if no explicit or inherited model is
available.
After that, restart the gateway:
```bash
node scripts/run-node.mjs gateway --profile dev
```
To inspect it live in a conversation:
```text
/verbose on
```
## Turn active memory on
The safest setup is:
1. enable the plugin
2. target one conversational agent
3. keep logging on only while tuning
Start with this in `openclaw.json`:
```json5
{
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
agents: ["main"],
allowedChatTypes: ["direct"],
modelFallbackPolicy: "default-remote",
queryMode: "recent",
timeoutMs: 15000,
maxSummaryChars: 220,
persistTranscripts: false,
logging: true,
},
},
},
},
}
```
Then restart the gateway:
```bash
node scripts/run-node.mjs gateway --profile dev
```
What this means:
- `plugins.entries.active-memory.enabled: true` turns the plugin on
- `config.agents: ["main"]` opts only the `main` agent into active memory
- `config.allowedChatTypes: ["direct"]` keeps active memory on for direct-message style sessions only by default
- if `config.model` is unset, active memory inherits the current session model first
- `config.modelFallbackPolicy: "default-remote"` keeps the built-in remote fallback as the default when no explicit or inherited model is available
- active memory still runs only on eligible interactive persistent chat sessions
## How to see it
Active memory injects hidden system context for the model. It does not expose
raw `<active_memory_plugin>...</active_memory_plugin>` tags to the client.
If you want to see what active memory is doing in a live session, turn verbose
mode on for that session:
```text
/verbose on
```
With verbose enabled, OpenClaw can show:
- an active memory status line such as `Active Memory: ok 842ms recent 34 chars`
- a readable debug summary such as `Active Memory Debug: Lemon pepper wings with blue cheese.`
Those lines are derived from the same active memory pass that feeds the hidden
system context, but they are formatted for humans instead of exposing raw prompt
markup.
By default, the blocking memory subagent transcript is temporary and deleted
after the run completes.
Example flow:
```text
/verbose on
what wings should i order?
```
Expected visible reply shape:
```text
...normal assistant reply...
🧩 Active Memory: ok 842ms recent 34 chars
🔎 Active Memory Debug: Lemon pepper wings with blue cheese.
```
## When it runs
Active memory uses two gates:
1. **Config opt-in**
The plugin must be enabled, and the current agent id must appear in
`plugins.entries.active-memory.config.agents`.
2. **Strict runtime eligibility**
Even when enabled and targeted, active memory only runs for eligible
interactive persistent chat sessions.
The actual rule is:
```text
plugin enabled
+
agent id targeted
+
allowed chat type
+
eligible interactive persistent chat session
=
active memory runs
```
If any of those fail, active memory does not run.
## Session types
`config.allowedChatTypes` controls which kinds of conversations may run Active
Memory at all.
The default is:
```json5
allowedChatTypes: ["direct"]
```
That means Active Memory runs by default in direct-message style sessions, but
not in group or channel sessions unless you opt them in explicitly.
Examples:
```json5
allowedChatTypes: ["direct"]
```
```json5
allowedChatTypes: ["direct", "group"]
```
```json5
allowedChatTypes: ["direct", "group", "channel"]
```
## Where it runs
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 |
| Subagent/internal helper execution | No |
## Why use it
Use active memory when:
- the session is persistent and user-facing
- the agent has meaningful long-term memory to search
- continuity and personalization matter more than raw prompt determinism
It works especially well for:
- stable preferences
- recurring habits
- long-term user context that should surface naturally
It is a poor fit for:
- automation
- internal workers
- one-shot API tasks
- places where hidden personalization would be surprising
## How it works
The runtime shape is:
```mermaid
flowchart LR
U["User Message"] --> Q["Build Memory Query"]
Q --> R["Active Memory Blocking Memory Subagent"]
R -->|NONE or empty| M["Main Reply"]
R -->|relevant summary| I["Append Hidden active_memory_plugin System Context"]
I --> M["Main Reply"]
```
The blocking memory subagent can use only:
- `memory_search`
- `memory_get`
If the connection is weak, it should return `NONE`.
## Query modes
`config.queryMode` controls how much conversation the blocking memory subagent sees.
## Model fallback policy
If `config.model` is unset, Active Memory tries to resolve a model in this order:
```text
explicit plugin model
-> current session model
-> agent primary model
-> optional built-in remote fallback
```
`config.modelFallbackPolicy` controls the last step.
Default:
```json5
modelFallbackPolicy: "default-remote"
```
Other option:
```json5
modelFallbackPolicy: "resolved-only"
```
Use `resolved-only` if you want Active Memory to skip recall instead of falling
back to the built-in remote default when no explicit or inherited model is
available.
### `message`
Only the latest user message is sent.
```text
Latest user message only
```
Use this when:
- you want the fastest behavior
- you want the strongest bias toward stable preference recall
- follow-up turns do not need conversational context
Recommended timeout:
- start around `3000` to `5000` ms
### `recent`
The latest user message plus a small recent conversational tail is sent.
```text
Recent conversation tail:
user: ...
assistant: ...
user: ...
Latest user message:
...
```
Use this when:
- you want a better balance of speed and conversational grounding
- follow-up questions often depend on the last few turns
Recommended timeout:
- start around `15000` ms
### `full`
The full conversation is sent to the blocking memory subagent.
```text
Full conversation context:
user: ...
assistant: ...
user: ...
...
```
Use this when:
- the strongest recall quality matters more than latency
- the conversation contains important setup far back in the thread
Recommended timeout:
- increase it substantially compared with `message` or `recent`
- start around `15000` ms or higher depending on thread size
In general, timeout should increase with context size:
```text
message < recent < full
```
## Transcript persistence
Active memory blocking memory subagent runs create a real `session.jsonl`
transcript during the blocking memory subagent call.
By default, that transcript is temporary:
- it is written to a temp directory
- it is used only for the blocking memory subagent run
- it is deleted immediately after the run finishes
If you want to keep those blocking memory subagent transcripts on disk for debugging or
inspection, turn persistence on explicitly:
```json5
{
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
agents: ["main"],
persistTranscripts: true,
transcriptDir: "active-memory",
},
},
},
},
}
```
When enabled, active memory stores transcripts in a separate directory under the
target agent's sessions folder, not in the main user conversation transcript
path.
The default layout is conceptually:
```text
agents/<agent>/sessions/active-memory/<blocking-memory-subagent-session-id>.jsonl
```
You can change the relative subdirectory with `config.transcriptDir`.
Use this carefully:
- blocking memory subagent transcripts can accumulate quickly on busy sessions
- `full` query mode can duplicate a lot of conversation context
- these transcripts contain hidden prompt context and recalled memories
## Configuration
All active memory configuration lives under:
```text
plugins.entries.active-memory
```
The most important fields are:
| Key | Type | Meaning |
| --------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `enabled` | `boolean` | Enables the plugin itself |
| `config.agents` | `string[]` | Agent ids that may use active memory |
| `config.model` | `string` | Optional blocking memory subagent model ref; when unset, active memory uses the current session model |
| `config.queryMode` | `"message" \| "recent" \| "full"` | Controls how much conversation the blocking memory subagent sees |
| `config.timeoutMs` | `number` | Hard timeout for the blocking memory subagent |
| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary |
| `config.logging` | `boolean` | Emits active memory logs while tuning |
| `config.persistTranscripts` | `boolean` | Keeps blocking memory subagent transcripts on disk instead of deleting temp files |
| `config.transcriptDir` | `string` | Relative blocking memory subagent transcript directory under the agent sessions folder |
Useful tuning fields:
| Key | Type | Meaning |
| ----------------------------- | -------- | ------------------------------------------------------------- |
| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary |
| `config.recentUserTurns` | `number` | Prior user turns to include when `queryMode` is `recent` |
| `config.recentAssistantTurns` | `number` | Prior assistant turns to include when `queryMode` is `recent` |
| `config.recentUserChars` | `number` | Max chars per recent user turn |
| `config.recentAssistantChars` | `number` | Max chars per recent assistant turn |
| `config.cacheTtlMs` | `number` | Cache reuse for repeated identical queries |
## Recommended setup
Start with `recent`.
```json5
{
plugins: {
entries: {
"active-memory": {
enabled: true,
config: {
agents: ["main"],
queryMode: "recent",
timeoutMs: 15000,
maxSummaryChars: 220,
logging: true,
},
},
},
},
}
```
If you want to inspect live behavior while tuning, use `/verbose on` in the
session instead of looking for a separate active-memory debug command.
Then move to:
- `message` if you want lower latency
- `full` if you decide extra context is worth the slower blocking memory subagent
## Debugging
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 memory status --deep`.
If memory hits are noisy, tighten:
- `maxSummaryChars`
If active memory is too slow:
- lower `queryMode`
- lower `timeoutMs`
- reduce recent turn counts
- reduce per-turn char caps
## Related pages
- [Memory Search](/concepts/memory-search)
- [Memory configuration reference](/reference/memory-config)
- [Plugin SDK setup](/plugins/sdk-setup)

View File

@@ -138,5 +138,6 @@ earlier conversations. This is opt-in via
## Further reading
- [Active Memory](/concepts/active-memory) -- sidecar memory for interactive chat sessions
- [Memory](/concepts/memory) -- file layout, backends, tools
- [Memory configuration reference](/reference/memory-config) -- all config knobs

View File

@@ -17,10 +17,22 @@ conceptual overviews, see:
- [Builtin Engine](/concepts/memory-builtin) -- default SQLite backend
- [QMD Engine](/concepts/memory-qmd) -- local-first sidecar
- [Memory Search](/concepts/memory-search) -- search pipeline and tuning
- [Active Memory](/concepts/active-memory) -- enabling the memory sidecar for interactive sessions
All memory search settings live under `agents.defaults.memorySearch` in
`openclaw.json` unless noted otherwise.
If you are looking for the **active memory** feature toggle and sidecar config,
that lives under `plugins.entries.active-memory` instead of `memorySearch`.
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.
---
## Provider selection

View File

@@ -0,0 +1,862 @@
import fs from "node:fs/promises";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import plugin from "./index.js";
const hoisted = vi.hoisted(() => {
const sessionStore: Record<string, Record<string, unknown>> = {
"agent:main:main": {
sessionId: "s-main",
updatedAt: 0,
},
};
return {
sessionStore,
updateSessionStore: vi.fn(
async (_storePath: string, updater: (store: Record<string, unknown>) => void) => {
updater(sessionStore);
},
),
};
});
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
"openclaw/plugin-sdk/config-runtime",
);
return {
...actual,
updateSessionStore: hoisted.updateSessionStore,
};
});
describe("active-memory plugin", () => {
const hooks: Record<string, Function> = {};
const runEmbeddedPiAgent = vi.fn();
const api: any = {
pluginConfig: {
agents: ["main"],
logging: true,
},
config: {},
id: "active-memory",
name: "Active Memory",
logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
runtime: {
agent: {
runEmbeddedPiAgent,
session: {
resolveStorePath: vi.fn(() => "/tmp/openclaw-session-store.json"),
loadSessionStore: vi.fn(() => hoisted.sessionStore),
saveSessionStore: vi.fn(async () => {}),
},
},
},
on: vi.fn((hookName: string, handler: Function) => {
hooks[hookName] = handler;
}),
};
beforeEach(() => {
vi.clearAllMocks();
api.pluginConfig = {
agents: ["main"],
logging: true,
};
hoisted.sessionStore["agent:main:main"] = {
sessionId: "s-main",
updatedAt: 0,
};
for (const key of Object.keys(hooks)) {
delete hooks[key];
}
runEmbeddedPiAgent.mockResolvedValue({
payloads: [{ text: "- lemon pepper wings\n- blue cheese" }],
});
plugin.register(api as unknown as OpenClawPluginApi);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("registers a before_prompt_build hook", () => {
expect(api.on).toHaveBeenCalledWith("before_prompt_build", expect.any(Function));
});
it("does not run for agents that are not explicitly targeted", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "support",
trigger: "user",
sessionKey: "agent:support:main",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("does not rewrite session state for skipped turns with no active-memory entry to clear", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "support",
trigger: "user",
sessionKey: "agent:support:main",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(hoisted.updateSessionStore).not.toHaveBeenCalled();
});
it("does not run for non-interactive contexts", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "main",
trigger: "heartbeat",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("defaults to direct-style sessions only", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should we order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:telegram:group:-100123",
messageProvider: "telegram",
channelId: "telegram",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("runs for group sessions when group chat types are explicitly allowed", async () => {
api.pluginConfig = {
agents: ["main"],
allowedChatTypes: ["direct", "group"],
};
plugin.register(api as unknown as OpenClawPluginApi);
const result = await hooks.before_prompt_build(
{ prompt: "what wings should we order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:telegram:group:-100123",
messageProvider: "telegram",
channelId: "telegram",
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
});
});
it("injects system context on a successful recall hit", async () => {
const result = await hooks.before_prompt_build(
{
prompt: "what wings should i order?",
messages: [
{ role: "user", content: "i want something greasy tonight" },
{ role: "assistant", content: "let's narrow it down" },
],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
});
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
"lemon pepper wings",
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
provider: "github-copilot",
model: "gpt-5.4-mini",
sessionKey: expect.stringMatching(/^agent:main:main:active-memory:[a-f0-9]{12}$/),
});
});
it("preserves leading digits in recalled memory bullets", async () => {
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "- 2024 trip to tokyo\n- 2% milk" }],
});
const result = await hooks.before_prompt_build(
{
prompt: "what should i remember from my 2024 trip and should i buy 2% milk?",
messages: [],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
});
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
"2024 trip to tokyo",
);
expect((result as { appendSystemContext: string }).appendSystemContext).toContain("2% milk");
});
it("preserves canonical parent session scope in the blocking memory subagent session key", async () => {
await hooks.before_prompt_build(
{ prompt: "what should i grab on the way?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:telegram:direct:12345:thread:99",
messageProvider: "telegram",
channelId: "telegram",
},
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionKey).toMatch(
/^agent:main:telegram:direct:12345:thread:99:active-memory:[a-f0-9]{12}$/,
);
});
it("falls back to the current session model when no plugin model is configured", async () => {
api.pluginConfig = {
agents: ["main"],
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? temp transcript", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
modelProviderId: "qwen",
modelId: "glm-5",
},
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
provider: "qwen",
model: "glm-5",
});
});
it("can disable default remote model fallback", async () => {
api.pluginConfig = {
agents: ["main"],
modelFallbackPolicy: "resolved-only",
};
plugin.register(api as unknown as OpenClawPluginApi);
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order? no fallback", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:resolved-only",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
it("persists a readable debug summary alongside the status line", async () => {
const sessionKey = "agent:main:debug";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-main",
updatedAt: 0,
};
await hooks.before_prompt_build(
{
prompt: "what wings should i order?",
messages: [],
},
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
expect(hoisted.updateSessionStore).toHaveBeenCalled();
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
| ((store: Record<string, Record<string, unknown>>) => void)
| undefined;
const store = {
[sessionKey]: {
sessionId: "s-main",
updatedAt: 0,
},
} as Record<string, Record<string, unknown>>;
updater?.(store);
expect(store[sessionKey]?.pluginDebugEntries).toEqual([
{
pluginId: "active-memory",
lines: expect.arrayContaining([
expect.stringContaining("🧩 Active Memory: ok"),
expect.stringContaining("🔎 Active Memory Debug: lemon pepper wings"),
]),
},
]);
});
it("replaces stale legacy active-memory lines on a later empty run", async () => {
const sessionKey = "agent:main:legacy-active-memory-lines";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-main",
updatedAt: 0,
pluginStatusLines: [
"Active Memory: ok 13.4s recent 1 mem",
"Active Memory Debug: Favorite desk snack: roasted almonds or cashews.",
"Other Plugin: keep me",
],
};
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "NONE" }],
});
await hooks.before_prompt_build(
{ prompt: "what's up with you?", messages: [] },
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
| ((store: Record<string, Record<string, unknown>>) => void)
| undefined;
const store = {
[sessionKey]: {
sessionId: "s-main",
updatedAt: 0,
pluginStatusLines: [
"Active Memory: ok 13.4s recent 1 mem",
"Active Memory Debug: Favorite desk snack: roasted almonds or cashews.",
"Other Plugin: keep me",
],
},
} as Record<string, Record<string, unknown>>;
updater?.(store);
expect(store[sessionKey]?.pluginDebugEntries).toEqual([
{
pluginId: "active-memory",
lines: [expect.stringContaining("🧩 Active Memory: empty")],
},
]);
expect(store[sessionKey]?.pluginStatusLines).toEqual(["Other Plugin: keep me"]);
});
it("returns nothing when the sidecar says none", async () => {
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "NONE" }],
});
const result = await hooks.before_prompt_build(
{ prompt: "fair, okay gonna do them by throwing them in the garbage", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
});
it("does not cache timeout results", async () => {
api.pluginConfig = {
agents: ["main"],
timeoutMs: 250,
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
let lastAbortSignal: AbortSignal | undefined;
runEmbeddedPiAgent.mockImplementation(async (params: { abortSignal?: AbortSignal }) => {
lastAbortSignal = params.abortSignal;
return await new Promise((resolve, reject) => {
const abortHandler = () => reject(new Error("aborted"));
params.abortSignal?.addEventListener("abort", abortHandler, { once: true });
setTimeout(() => {
params.abortSignal?.removeEventListener("abort", abortHandler);
resolve({ payloads: [] });
}, 2_000);
});
});
await hooks.before_prompt_build(
{ prompt: "what wings should i order? timeout test", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:timeout-test",
messageProvider: "webchat",
},
);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? timeout test", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:timeout-test",
messageProvider: "webchat",
},
);
expect(hoisted.updateSessionStore).toHaveBeenCalledTimes(2);
expect(lastAbortSignal?.aborted).toBe(true);
const infoLines = vi
.mocked(api.logger.info)
.mock.calls.map((call: unknown[]) => String(call[0]));
expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false);
});
it("does not share cached recall results across session-id-only contexts", async () => {
api.pluginConfig = {
agents: ["main"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? session id cache", messages: [] },
{
agentId: "main",
trigger: "user",
sessionId: "session-a",
messageProvider: "webchat",
},
);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? session id cache", messages: [] },
{
agentId: "main",
trigger: "user",
sessionId: "session-b",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2);
const infoLines = vi
.mocked(api.logger.info)
.mock.calls.map((call: unknown[]) => String(call[0]));
expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false);
});
it("uses a canonical agent session key when only sessionId is available", async () => {
hoisted.sessionStore["agent:main:telegram:direct:12345"] = {
sessionId: "session-a",
updatedAt: 25,
};
await hooks.before_prompt_build(
{ prompt: "what wings should i order? session id only", messages: [] },
{
agentId: "main",
trigger: "user",
sessionId: "session-a",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionKey).toMatch(
/^agent:main:telegram:direct:12345:active-memory:[a-f0-9]{12}$/,
);
});
it("clears stale status on skipped non-interactive turns even when agentId is missing", async () => {
const sessionKey = "agent:main:missing-agent";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-main",
updatedAt: 0,
pluginDebugEntries: [
{ pluginId: "active-memory", lines: ["🧩 Active Memory: timeout 15s recent"] },
],
};
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{ trigger: "heartbeat", sessionKey, messageProvider: "webchat" },
);
expect(result).toBeUndefined();
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
| ((store: Record<string, Record<string, unknown>>) => void)
| undefined;
const store = {
[sessionKey]: {
sessionId: "s-main",
updatedAt: 0,
pluginDebugEntries: [
{ pluginId: "active-memory", lines: ["🧩 Active Memory: timeout 15s recent"] },
],
},
} as Record<string, Record<string, unknown>>;
updater?.(store);
expect(store[sessionKey]?.pluginDebugEntries).toBeUndefined();
});
it("supports message mode by sending only the latest user message", async () => {
api.pluginConfig = {
agents: ["main"],
queryMode: "message",
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "what should i grab on the way?",
messages: [
{ role: "user", content: "i have a flight tomorrow" },
{ role: "assistant", content: "got it" },
],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
expect(prompt).toContain("Conversation context:\nwhat should i grab on the way?");
expect(prompt).not.toContain("Recent conversation tail:");
});
it("supports full mode by sending the whole conversation", async () => {
api.pluginConfig = {
agents: ["main"],
queryMode: "full",
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "what should i grab on the way?",
messages: [
{ role: "user", content: "i have a flight tomorrow" },
{ role: "assistant", content: "got it" },
{ role: "user", content: "packing is annoying" },
],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
expect(prompt).toContain("Full conversation context:");
expect(prompt).toContain("user: i have a flight tomorrow");
expect(prompt).toContain("assistant: got it");
expect(prompt).toContain("user: packing is annoying");
});
it("strips prior memory/debug traces from assistant context before retrieval", async () => {
api.pluginConfig = {
agents: ["main"],
queryMode: "recent",
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: "what should i grab on the way?",
messages: [
{ role: "user", content: "i have a flight tomorrow" },
{
role: "assistant",
content:
"🧠 Memory Search: favorite food comfort food tacos sushi ramen\n🧩 Active Memory: ok 842ms recent 2 mem\n🔎 Active Memory Debug: spicy ramen; tacos\nSounds like you want something easy before the airport.",
},
],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
expect(prompt).toContain(
"ignore that text and do not search for those same surfaced memories again",
);
expect(prompt).toContain("assistant: Sounds like you want something easy before the airport.");
expect(prompt).not.toContain("Memory Search:");
expect(prompt).not.toContain("Active Memory:");
expect(prompt).not.toContain("Active Memory Debug:");
expect(prompt).not.toContain("spicy ramen; tacos");
});
it("trusts the subagent's relevance decision for explicit preference recall prompts", async () => {
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "- aisle seat\n- extra buffer on connections" }],
});
const result = await hooks.before_prompt_build(
{ prompt: "u remember my flight preferences", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("aisle seat"),
});
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
"extra buffer on connections",
);
});
it("applies total summary truncation after normalizing the sidecar reply", async () => {
api.pluginConfig = {
agents: ["main"],
maxSummaryChars: 40,
};
plugin.register(api as unknown as OpenClawPluginApi);
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [
{
text: "- lemon pepper wings with extra crisp skin\n- blue cheese dressing on the side",
},
],
});
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(result).toEqual({
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
appendSystemContext: expect.stringContaining("lemon pepper wings"),
});
expect((result as { appendSystemContext: string }).appendSystemContext).not.toContain(
"dressing on the side",
);
});
it("uses the configured maxSummaryChars value in the sidecar prompt", async () => {
api.pluginConfig = {
agents: ["main"],
maxSummaryChars: 90,
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? prompt-count-check", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:prompt-count-check",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt).toContain(
"If something is useful, reply with one compact active-memory summary under 90 characters total.",
);
});
it("keeps sidecar transcripts off disk by default by using a temp session file", async () => {
const mkdtempSpy = vi
.spyOn(fs, "mkdtemp")
.mockResolvedValue("/tmp/openclaw-active-memory-temp");
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? temp transcript path", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
expect(mkdtempSpy).toHaveBeenCalled();
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toBe(
"/tmp/openclaw-active-memory-temp/session.jsonl",
);
expect(rmSpy).toHaveBeenCalledWith("/tmp/openclaw-active-memory-temp", {
recursive: true,
force: true,
});
});
it("persists sidecar transcripts in a separate directory when enabled", async () => {
api.pluginConfig = {
agents: ["main"],
persistTranscripts: true,
transcriptDir: "active-memory-sidecars",
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
const mkdtempSpy = vi.spyOn(fs, "mkdtemp");
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
const sessionKey = "agent:main:persist-transcript";
await hooks.before_prompt_build(
{ prompt: "what wings should i order? persist transcript", messages: [] },
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
expect(mkdirSpy).toHaveBeenCalledWith("/tmp/active-memory-sidecars", { recursive: true });
expect(mkdtempSpy).not.toHaveBeenCalled();
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toMatch(
/^\/tmp\/active-memory-sidecars\/active-memory-[a-z0-9]+-[a-f0-9]{8}\.jsonl$/,
);
expect(rmSpy).not.toHaveBeenCalled();
expect(
vi
.mocked(api.logger.info)
.mock.calls.some((call: unknown[]) =>
String(call[0]).includes("transcript=/tmp/active-memory-sidecars/"),
),
).toBe(true);
});
it("falls back to the default transcript directory when transcriptDir is unsafe", async () => {
api.pluginConfig = {
agents: ["main"],
persistTranscripts: true,
transcriptDir: "C:/temp/escape",
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? unsafe transcript dir", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:unsafe-transcript",
messageProvider: "webchat",
},
);
expect(mkdirSpy).toHaveBeenCalledWith("/tmp/active-memory", { recursive: true });
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toMatch(
/^\/tmp\/active-memory\/active-memory-[a-z0-9]+-[a-f0-9]{8}\.jsonl$/,
);
});
it("sanitizes control characters out of debug lines", async () => {
const sessionKey = "agent:main:debug-sanitize";
hoisted.sessionStore[sessionKey] = {
sessionId: "s-main",
updatedAt: 0,
};
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [{ text: "- spicy ramen\u001b[31m\n- fries\r\n- blue cheese\t" }],
});
await hooks.before_prompt_build(
{ prompt: "what should i order?", messages: [] },
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
);
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
| ((store: Record<string, Record<string, unknown>>) => void)
| undefined;
const store = {
[sessionKey]: {
sessionId: "s-main",
updatedAt: 0,
},
} as Record<string, Record<string, unknown>>;
updater?.(store);
const lines =
(store[sessionKey]?.pluginDebugEntries as Array<{ lines?: string[] }> | undefined)?.[0]
?.lines ?? [];
expect(lines.some((line) => line.includes("\u001b"))).toBe(false);
expect(lines.some((line) => line.includes("\r"))).toBe(false);
});
it("caps the active-memory cache size and evicts the oldest entries", async () => {
api.pluginConfig = {
agents: ["main"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
for (let index = 0; index <= 1000; index += 1) {
await hooks.before_prompt_build(
{ prompt: `cache pressure prompt ${index}`, messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:cache-cap",
messageProvider: "webchat",
},
);
}
const callsBeforeReplay = runEmbeddedPiAgent.mock.calls.length;
await hooks.before_prompt_build(
{ prompt: "cache pressure prompt 0", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:cache-cap",
messageProvider: "webchat",
},
);
expect(runEmbeddedPiAgent.mock.calls.length).toBe(callsBeforeReplay + 1);
const infoLines = vi
.mocked(api.logger.info)
.mock.calls.map((call: unknown[]) => String(call[0]));
expect(
infoLines.some(
(line: string) => line.includes("cached status=ok") && line.includes("prompt 0"),
),
).toBe(false);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
{
"id": "active-memory",
"name": "Active Memory",
"description": "Runs a bounded blocking memory subagent before eligible conversational replies and injects relevant memory into prompt context.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"agents": {
"type": "array",
"items": { "type": "string" }
},
"model": { "type": "string" },
"modelFallbackPolicy": {
"type": "string",
"enum": ["default-remote", "resolved-only"]
},
"allowedChatTypes": {
"type": "array",
"items": {
"type": "string",
"enum": ["direct", "group", "channel"]
}
},
"timeoutMs": { "type": "integer", "minimum": 250 },
"queryMode": {
"type": "string",
"enum": ["message", "recent", "full"]
},
"maxSummaryChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
"recentUserTurns": { "type": "integer", "minimum": 0, "maximum": 4 },
"recentAssistantTurns": { "type": "integer", "minimum": 0, "maximum": 3 },
"recentUserChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
"recentAssistantChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
"logging": { "type": "boolean" },
"persistTranscripts": { "type": "boolean" },
"transcriptDir": { "type": "string" },
"cacheTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 }
}
},
"uiHints": {
"agents": {
"label": "Target Agents",
"help": "Explicit agent ids that may use active memory."
},
"model": {
"label": "Memory Model",
"help": "Provider/model used for the blocking memory subagent."
},
"modelFallbackPolicy": {
"label": "Model Fallback Policy",
"help": "Choose whether Active Memory falls back to the built-in remote default model when no explicit or inherited model is available."
},
"allowedChatTypes": {
"label": "Allowed Chat Types",
"help": "Choose which session types may run Active Memory. Defaults to direct-message style sessions only."
},
"timeoutMs": {
"label": "Timeout (ms)"
},
"queryMode": {
"label": "Query Mode",
"help": "Choose whether the blocking memory subagent sees only the latest user message, a small recent tail, or the full conversation."
},
"maxSummaryChars": {
"label": "Max Summary Characters",
"help": "Maximum total characters allowed in the active-memory summary."
},
"logging": {
"label": "Enable Logging",
"help": "Emit active memory timing and result logs."
},
"persistTranscripts": {
"label": "Persist Transcripts",
"help": "Keep blocking memory subagent session transcripts on disk in a separate plugin-owned directory."
},
"transcriptDir": {
"label": "Transcript Directory",
"help": "Relative directory under the agent sessions folder used when transcript persistence is enabled."
}
}
}

View File

@@ -832,7 +832,6 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
registerAlias(commands, "reasoning", "/reason");
registerAlias(commands, "elevated", "/elev");
registerAlias(commands, "steer", "/tell");
assertCommandRegistry(commands);
return commands;
}

View File

@@ -8,6 +8,7 @@ import {
abortEmbeddedPiRun,
isEmbeddedPiRunActive,
} from "../../agents/pi-embedded-runner/runs.js";
import * as sessionTypesModule from "../../config/sessions.js";
import type { SessionEntry } from "../../config/sessions.js";
import { loadSessionStore, saveSessionStore } from "../../config/sessions.js";
import { onAgentEvent } from "../../infra/agent-events.js";
@@ -989,6 +990,208 @@ describe("runReplyAgent block streaming", () => {
});
});
describe("runReplyAgent Active Memory inline debug", () => {
it("appends inline Active Memory debug payload when verbose is enabled", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-active-memory-inline-"));
const storePath = path.join(tmp, "sessions.json");
const sessionKey = "main";
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: sessionEntry,
},
null,
2,
),
"utf-8",
);
runEmbeddedPiAgentMock.mockImplementationOnce(async () => {
const latest = loadSessionStore(storePath, { skipCache: true });
latest[sessionKey] = {
...latest[sessionKey],
pluginDebugEntries: [
{
pluginId: "active-memory",
lines: [
"🧩 Active Memory: ok 842ms recent 34 chars",
"🔎 Active Memory Debug: Lemon pepper wings with blue cheese.",
],
},
],
};
await saveSessionStore(storePath, latest);
return {
payloads: [{ text: "Normal reply" }],
meta: {},
};
});
const typing = createMockTypingController();
const sessionCtx = {
Provider: "telegram",
OriginatingTo: "chat:1",
AccountId: "primary",
MessageSid: "msg",
} as unknown as TemplateContext;
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
const followupRun = {
prompt: "hello",
summaryLine: "hello",
enqueuedAt: Date.now(),
run: {
agentId: "main",
sessionId: "session",
sessionKey,
messageProvider: "telegram",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},
skillsSnapshot: {},
provider: "anthropic",
model: "claude",
thinkLevel: "low",
verboseLevel: "on",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
timeoutMs: 1_000,
blockReplyBreak: "message_end",
},
} as unknown as FollowupRun;
const result = await runReplyAgent({
commandBody: "hello",
followupRun,
queueKey: sessionKey,
resolvedQueue,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
typing,
sessionCtx,
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
sessionKey,
storePath,
defaultModel: "anthropic/claude-opus-4-6",
resolvedVerboseLevel: "on",
isNewSession: false,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
typingMode: "instant",
});
expect(Array.isArray(result)).toBe(true);
expect((result as { text?: string }[]).map((payload) => payload.text)).toEqual([
"Normal reply",
"🧩 Active Memory: ok 842ms recent 34 chars\n🔎 Active Memory Debug: Lemon pepper wings with blue cheese.",
]);
});
it("does not reload the session store when verbose is disabled", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-active-memory-inline-"));
const storePath = path.join(tmp, "sessions.json");
const sessionKey = "main";
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: sessionEntry,
},
null,
2,
),
"utf-8",
);
const loadSessionStoreSpy = vi.spyOn(sessionTypesModule, "loadSessionStore");
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "Normal reply" }],
meta: {},
});
const typing = createMockTypingController();
const sessionCtx = {
Provider: "telegram",
OriginatingTo: "chat:1",
AccountId: "primary",
MessageSid: "msg",
} as unknown as TemplateContext;
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
const followupRun = {
prompt: "hello",
summaryLine: "hello",
enqueuedAt: Date.now(),
run: {
agentId: "main",
sessionId: "session",
sessionKey,
messageProvider: "telegram",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},
skillsSnapshot: {},
provider: "anthropic",
model: "claude",
thinkLevel: "low",
verboseLevel: "off",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
timeoutMs: 1_000,
blockReplyBreak: "message_end",
},
} as unknown as FollowupRun;
const result = await runReplyAgent({
commandBody: "hello",
followupRun,
queueKey: sessionKey,
resolvedQueue,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
typing,
sessionCtx,
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
sessionKey,
storePath,
defaultModel: "anthropic/claude-opus-4-6",
resolvedVerboseLevel: "off",
isNewSession: false,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
typingMode: "instant",
});
expect(loadSessionStoreSpy).not.toHaveBeenCalledWith(storePath, { skipCache: true });
expect(result).toMatchObject({ text: "Normal reply" });
});
});
describe("runReplyAgent claude-cli routing", () => {
function createRun() {
const typing = createMockTypingController();

View File

@@ -6,10 +6,12 @@ import { isCliProvider } from "../../agents/model-selection.js";
import { queueEmbeddedPiMessage } from "../../agents/pi-embedded.js";
import { hasNonzeroUsage } from "../../agents/usage.js";
import {
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveSessionFilePath,
resolveSessionFilePathOptions,
resolveSessionTranscriptPath,
resolveSessionPluginDebugLines,
type SessionEntry,
updateSessionStore,
updateSessionStoreEntry,
@@ -74,6 +76,39 @@ import type { TypingController } from "./typing.js";
const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000;
function buildInlinePluginStatusPayload(entry: SessionEntry | undefined): ReplyPayload | undefined {
const lines = resolveSessionPluginDebugLines(entry);
if (lines.length === 0) {
return undefined;
}
return { text: lines.join("\n") };
}
function refreshSessionEntryFromStore(params: {
storePath?: string;
sessionKey?: string;
fallbackEntry?: SessionEntry;
activeSessionStore?: Record<string, SessionEntry>;
}): SessionEntry | undefined {
const { storePath, sessionKey, fallbackEntry, activeSessionStore } = params;
if (!storePath || !sessionKey) {
return fallbackEntry;
}
try {
const latestStore = loadSessionStore(storePath, { skipCache: true });
const latestEntry = latestStore?.[sessionKey];
if (!latestEntry) {
return fallbackEntry;
}
if (activeSessionStore) {
activeSessionStore[sessionKey] = latestEntry;
}
return latestEntry;
} catch {
return fallbackEntry;
}
}
export async function runReplyAgent(params: {
commandBody: string;
followupRun: FollowupRun;
@@ -713,6 +748,15 @@ export async function runReplyAgent(params: {
}
}
if (verboseEnabled) {
activeSessionEntry = refreshSessionEntryFromStore({
storePath,
sessionKey,
fallbackEntry: activeSessionEntry,
activeSessionStore,
});
}
// If verbose is enabled, prepend operational run notices.
let finalPayloads = guardedReplyPayloads;
const verboseNotices: ReplyPayload[] = [];
@@ -825,6 +869,12 @@ export async function runReplyAgent(params: {
if (responseUsageLine) {
finalPayloads = appendUsageLine(finalPayloads, responseUsageLine);
}
if (verboseEnabled) {
const pluginStatusPayload = buildInlinePluginStatusPayload(activeSessionEntry);
if (pluginStatusPayload) {
finalPayloads = [...finalPayloads, pluginStatusPayload];
}
}
return finalizeWithFollowup(
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,

View File

@@ -115,6 +115,70 @@ describe("buildStatusMessage", () => {
expect(normalized).toContain("Reasoning: on");
});
it("shows plugin status lines only when verbose is enabled", () => {
const visible = normalizeTestText(
buildStatusMessage({
agent: {
model: "anthropic/pi:opus",
},
sessionEntry: {
sessionId: "abc",
updatedAt: 0,
verboseLevel: "on",
pluginDebugEntries: [
{ pluginId: "active-memory", lines: ["🧩 Active Memory: timeout 15s recent"] },
],
},
sessionKey: "agent:main:main",
queue: { mode: "collect", depth: 0 },
}),
);
const hidden = normalizeTestText(
buildStatusMessage({
agent: {
model: "anthropic/pi:opus",
},
sessionEntry: {
sessionId: "abc",
updatedAt: 0,
verboseLevel: "off",
pluginDebugEntries: [
{ pluginId: "active-memory", lines: ["🧩 Active Memory: timeout 15s recent"] },
],
},
sessionKey: "agent:main:main",
queue: { mode: "collect", depth: 0 },
}),
);
expect(visible).toContain("Active Memory: timeout 15s recent");
expect(hidden).not.toContain("Active Memory: timeout 15s recent");
});
it("merges legacy and structured plugin debug lines in verbose status", () => {
const visible = normalizeTestText(
buildStatusMessage({
agent: {
model: "anthropic/pi:opus",
},
sessionEntry: {
sessionId: "abc",
updatedAt: 0,
verboseLevel: "on",
pluginDebugEntries: [
{ pluginId: "active-memory", lines: ["🧩 Active Memory: ok 842ms recent 34 chars"] },
],
pluginStatusLines: ["Legacy Plugin: warmed cache"],
},
sessionKey: "agent:main:main",
queue: { mode: "collect", depth: 0 },
}),
);
expect(visible).toContain("Active Memory: ok 842ms recent 34 chars");
expect(visible).toContain("Legacy Plugin: warmed cache");
});
it("shows fast mode when enabled", () => {
const text = buildStatusMessage({
agent: {

View File

@@ -20,6 +20,7 @@ import { isCommandFlagEnabled } from "../config/commands.js";
import type { OpenClawConfig } from "../config/config.js";
import {
resolveMainSessionKey,
resolveSessionPluginDebugLines,
resolveSessionFilePath,
resolveSessionFilePathOptions,
type SessionEntry,
@@ -674,6 +675,8 @@ export function buildStatusMessage(args: StatusArgs): string {
const queueDetails = formatQueueDetails(args.queue);
const verboseLabel =
verboseLevel === "full" ? "verbose:full" : verboseLevel === "on" ? "verbose" : null;
const pluginDebugLines = verboseLevel !== "off" ? resolveSessionPluginDebugLines(entry) : [];
const pluginStatusLine = pluginDebugLines.length > 0 ? pluginDebugLines.join(" · ") : null;
const elevatedLabel =
elevatedLevel && elevatedLevel !== "off"
? elevatedLevel === "on"
@@ -817,6 +820,7 @@ export function buildStatusMessage(args: StatusArgs): string {
args.subagentsLine,
args.taskLine,
`⚙️ ${optionsLine}`,
pluginStatusLine ? `🧩 ${pluginStatusLine}` : null,
voiceLine,
activationLine,
]

View File

@@ -103,6 +103,11 @@ export type SessionCompactionCheckpoint = {
postCompaction: SessionCompactionTranscriptReference;
};
export type SessionPluginDebugEntry = {
pluginId: string;
lines: string[];
};
export type SessionEntry = {
/**
* Last delivered heartbeat payload (used to suppress duplicate heartbeat notifications).
@@ -232,9 +237,39 @@ export type SessionEntry = {
lastThreadId?: string | number;
skillsSnapshot?: SessionSkillSnapshot;
systemPromptReport?: SessionSystemPromptReport;
/**
* Generic plugin-owned runtime debug entries shown in verbose status surfaces.
* Each plugin owns and may overwrite only its own entry between turns.
*/
pluginDebugEntries?: SessionPluginDebugEntry[];
/**
* Legacy flat plugin debug lines.
* Prefer `pluginDebugEntries` for new writes.
*/
pluginStatusLines?: string[];
acp?: SessionAcpMeta;
};
export function resolveSessionPluginDebugLines(
entry: Pick<SessionEntry, "pluginDebugEntries" | "pluginStatusLines"> | undefined,
): string[] {
const structured = Array.isArray(entry?.pluginDebugEntries)
? entry.pluginDebugEntries.flatMap((pluginEntry) =>
Array.isArray(pluginEntry?.lines)
? pluginEntry.lines.filter(
(line): line is string => typeof line === "string" && line.trim().length > 0,
)
: [],
)
: [];
const legacy = Array.isArray(entry?.pluginStatusLines)
? entry.pluginStatusLines.filter(
(line): line is string => typeof line === "string" && line.trim().length > 0,
)
: [];
return [...structured, ...legacy];
}
export function normalizeSessionRuntimeModelFields(entry: SessionEntry): SessionEntry {
const normalizedModel = normalizeOptionalString(entry.model);
const normalizedProvider = normalizeOptionalString(entry.modelProvider);