diff --git a/docs/concepts/active-memory.md b/docs/concepts/active-memory.md
new file mode 100644
index 0000000000..38565ba3ff
--- /dev/null
+++ b/docs/concepts/active-memory.md
@@ -0,0 +1,402 @@
+---
+title: "Active Memory"
+summary: "A plugin-owned sidecar memory pass 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 memory pass 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.
+
+## 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"],
+ model: "github-copilot/gpt-5.4-mini",
+ queryMode: "recent",
+ timeoutMs: 8000,
+ maxMemories: 2,
+ persistTranscripts: false,
+ logging: true,
+ },
+ },
+ },
+ },
+}
+```
+
+Then restart the gateway:
+
+```bash
+pnpm gateway: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
+- 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 `...` 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 2 mem`
+- a readable debug summary such as `Active Memory Debug: lemon pepper wings; 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 sidecar transcript for that pass 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 2 mem
+๐ Active Memory Debug: lemon pepper wings; 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
++
+eligible interactive persistent chat session
+=
+active memory runs
+```
+
+If any of those fail, active memory does not run.
+
+## 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 Sidecar"]
+ R -->|NONE or empty| M["Main Reply"]
+ R -->|relevant bullets| I["Append Hidden active_memory System Context"]
+ I --> M["Main Reply"]
+```
+
+The sidecar 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 sidecar sees.
+
+### `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 `8000` ms
+
+### `full`
+
+The full conversation is sent to the sidecar.
+
+```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 sidecar runs create a real `session.jsonl` transcript during the
+sidecar call.
+
+By default, that transcript is temporary:
+
+- it is written to a temp directory
+- it is used only for the sidecar run
+- it is deleted immediately after the run finishes
+
+If you want to keep those sidecar 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//sessions/active-memory/.jsonl
+```
+
+You can change the relative subdirectory with `config.transcriptDir`.
+
+Use this carefully:
+
+- sidecar 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` | Sidecar model ref |
+| `config.queryMode` | `"message" \| "recent" \| "full"` | Controls how much conversation the sidecar sees |
+| `config.timeoutMs` | `number` | Hard timeout for the sidecar |
+| `config.maxMemories` | `number` | Maximum recalled bullets to inject |
+| `config.logging` | `boolean` | Emits active memory logs while tuning |
+| `config.persistTranscripts` | `boolean` | Keeps sidecar transcripts on disk instead of deleting temp files |
+| `config.transcriptDir` | `string` | Relative sidecar transcript directory under the agent sessions folder |
+
+Useful tuning fields:
+
+| Key | Type | Meaning |
+| --------------------------------------------------- | --------- | ------------------------------------------------------------- |
+| `config.maxMemoryChars` | `number` | Maximum characters per memory bullet |
+| `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.requireConcreteRelevance` | `boolean` | Biases toward `NONE` on weak matches |
+| `config.dropGenericPreferencesOnNonPreferenceTurns` | `boolean` | Filters generic preference noise |
+| `config.cacheTtlMs` | `number` | Cache reuse for repeated identical queries |
+
+## Recommended setup
+
+Start with `recent`.
+
+```json5
+{
+ plugins: {
+ entries: {
+ "active-memory": {
+ enabled: true,
+ config: {
+ agents: ["main"],
+ model: "github-copilot/gpt-5.4-mini",
+ queryMode: "recent",
+ timeoutMs: 8000,
+ maxMemories: 2,
+ 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 sidecar
+
+## 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:
+
+- `maxMemories`
+- `requireConcreteRelevance`
+- `dropGenericPreferencesOnNonPreferenceTurns`
+
+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)
diff --git a/docs/concepts/memory-search.md b/docs/concepts/memory-search.md
index c769513a04..02d34f6c83 100644
--- a/docs/concepts/memory-search.md
+++ b/docs/concepts/memory-search.md
@@ -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
diff --git a/docs/reference/memory-config.md b/docs/reference/memory-config.md
index 93c3959cba..d882bc49a0 100644
--- a/docs/reference/memory-config.md
+++ b/docs/reference/memory-config.md
@@ -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
diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts
new file mode 100644
index 0000000000..d2af6c055c
--- /dev/null
+++ b/extensions/active-memory/index.test.ts
@@ -0,0 +1,378 @@
+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> = {
+ "agent:main:main": {
+ sessionId: "s-main",
+ updatedAt: 0,
+ },
+ };
+ return {
+ sessionStore,
+ updateSessionStore: vi.fn(
+ async (_storePath: string, updater: (store: Record) => void) => {
+ updater(sessionStore);
+ },
+ ),
+ };
+});
+
+vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
+ const actual = await vi.importActual(
+ "openclaw/plugin-sdk/config-runtime",
+ );
+ return {
+ ...actual,
+ updateSessionStore: hoisted.updateSessionStore,
+ };
+});
+
+describe("active-memory plugin", () => {
+ const hooks: Record = {};
+ 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();
+ 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 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("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({
+ appendSystemContext: expect.stringContaining(""),
+ });
+ expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
+ "lemon pepper wings",
+ );
+ });
+
+ 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>) => void)
+ | undefined;
+ const store = {
+ [sessionKey]: {
+ sessionId: "s-main",
+ updatedAt: 0,
+ },
+ } as Record>;
+ 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("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);
+ runEmbeddedPiAgent.mockImplementation(
+ async () => await new Promise((resolve) => setTimeout(() => resolve({ payloads: [] }), 300)),
+ );
+
+ 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);
+ const infoLines = vi.mocked(api.logger.info).mock.calls.map((call) => String(call[0]));
+ expect(infoLines.some((line) => line.includes(" cached "))).toBe(false);
+ });
+
+ 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>) => void)
+ | undefined;
+ const store = {
+ [sessionKey]: {
+ sessionId: "s-main",
+ updatedAt: 0,
+ pluginDebugEntries: [
+ { pluginId: "active-memory", lines: ["๐งฉ Active Memory: timeout 15s recent"] },
+ ],
+ },
+ } as Record>;
+ 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("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?", 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]+\.jsonl$/,
+ );
+ expect(rmSpy).not.toHaveBeenCalled();
+ expect(
+ vi
+ .mocked(api.logger.info)
+ .mock.calls.some((call) =>
+ String(call[0]).includes("transcript=/tmp/active-memory-sidecars/"),
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts
new file mode 100644
index 0000000000..9b4ec4dfc4
--- /dev/null
+++ b/extensions/active-memory/index.ts
@@ -0,0 +1,931 @@
+import crypto from "node:crypto";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import {
+ DEFAULT_PROVIDER,
+ parseModelRef,
+ resolveAgentDir,
+ resolveAgentEffectiveModelPrimary,
+ resolveAgentWorkspaceDir,
+} from "openclaw/plugin-sdk/agent-runtime";
+import { resolveSessionStoreEntry, updateSessionStore } from "openclaw/plugin-sdk/config-runtime";
+import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
+
+const DEFAULT_TIMEOUT_MS = 8000;
+const DEFAULT_MAX_MEMORIES = 2;
+const DEFAULT_MAX_MEMORY_CHARS = 180;
+const DEFAULT_RECENT_USER_TURNS = 2;
+const DEFAULT_RECENT_ASSISTANT_TURNS = 1;
+const DEFAULT_RECENT_USER_CHARS = 220;
+const DEFAULT_RECENT_ASSISTANT_CHARS = 180;
+const DEFAULT_REQUIRE_CONCRETE_RELEVANCE = true;
+const DEFAULT_DROP_GENERIC_PREFERENCES = true;
+const DEFAULT_CACHE_TTL_MS = 15_000;
+const DEFAULT_MODEL_REF = "github-copilot/gpt-5.4-mini";
+const DEFAULT_QUERY_MODE = "recent" as const;
+const DEFAULT_TRANSCRIPT_DIR = "active-memory";
+
+const NO_RECALL_VALUES = new Set([
+ "",
+ "none",
+ "no_reply",
+ "no reply",
+ "nothing useful",
+ "no relevant memory",
+ "no relevant memories",
+ "timeout",
+ "[]",
+ "{}",
+ "null",
+ "n/a",
+]);
+
+const STOPWORDS = new Set([
+ "a",
+ "an",
+ "and",
+ "are",
+ "as",
+ "at",
+ "be",
+ "but",
+ "by",
+ "do",
+ "for",
+ "from",
+ "get",
+ "got",
+ "had",
+ "has",
+ "have",
+ "i",
+ "if",
+ "im",
+ "in",
+ "into",
+ "is",
+ "it",
+ "its",
+ "just",
+ "me",
+ "my",
+ "not",
+ "of",
+ "on",
+ "or",
+ "so",
+ "that",
+ "the",
+ "their",
+ "them",
+ "then",
+ "they",
+ "this",
+ "to",
+ "up",
+ "was",
+ "we",
+ "what",
+ "when",
+ "with",
+ "would",
+ "you",
+ "your",
+]);
+
+type ActiveRecallPluginConfig = {
+ agents?: string[];
+ model?: string;
+ timeoutMs?: number;
+ queryMode?: "message" | "recent" | "full";
+ maxMemories?: number;
+ maxMemoryChars?: number;
+ recentUserTurns?: number;
+ recentAssistantTurns?: number;
+ recentUserChars?: number;
+ recentAssistantChars?: number;
+ logging?: boolean;
+ requireConcreteRelevance?: boolean;
+ dropGenericPreferencesOnNonPreferenceTurns?: boolean;
+ cacheTtlMs?: number;
+ persistTranscripts?: boolean;
+ transcriptDir?: string;
+};
+
+type ResolvedActiveRecallPluginConfig = {
+ agents: string[];
+ model: string;
+ timeoutMs: number;
+ queryMode: "message" | "recent" | "full";
+ maxMemories: number;
+ maxMemoryChars: number;
+ recentUserTurns: number;
+ recentAssistantTurns: number;
+ recentUserChars: number;
+ recentAssistantChars: number;
+ logging: boolean;
+ requireConcreteRelevance: boolean;
+ dropGenericPreferencesOnNonPreferenceTurns: boolean;
+ cacheTtlMs: number;
+ persistTranscripts: boolean;
+ transcriptDir: string;
+};
+
+type ActiveRecallCandidate = {
+ text: string;
+ path?: string;
+ score?: number;
+};
+
+type ActiveRecallRecentTurn = {
+ role: "user" | "assistant";
+ text: string;
+};
+
+type PluginDebugEntry = {
+ pluginId: string;
+ lines: string[];
+};
+
+type ActiveRecallResult =
+ | {
+ status: "empty" | "timeout" | "unavailable";
+ elapsedMs: number;
+ memories: ActiveRecallCandidate[];
+ }
+ | { status: "ok"; elapsedMs: number; rawReply: string; memories: ActiveRecallCandidate[] };
+
+type CachedActiveRecallResult = {
+ expiresAt: number;
+ result: ActiveRecallResult;
+};
+
+const ACTIVE_MEMORY_STATUS_PREFIX = "๐งฉ Active Memory:";
+const ACTIVE_MEMORY_DEBUG_PREFIX = "๐ Active Memory Debug:";
+
+const activeRecallCache = new Map();
+
+function parseOptionalPositiveInt(value: unknown, fallback: number): number {
+ const parsed =
+ typeof value === "number"
+ ? value
+ : typeof value === "string"
+ ? Number.parseInt(value, 10)
+ : Number.NaN;
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
+}
+
+function clampInt(value: number | undefined, fallback: number, min: number, max: number): number {
+ if (!Number.isFinite(value)) {
+ return fallback;
+ }
+ return Math.max(min, Math.min(max, Math.floor(value as number)));
+}
+
+function normalizeTranscriptDir(value: unknown): string {
+ const raw = typeof value === "string" ? value.trim() : "";
+ if (!raw) {
+ return DEFAULT_TRANSCRIPT_DIR;
+ }
+ const normalized = raw.replace(/\\/g, "/");
+ const parts = normalized.split("/").map((part) => part.trim());
+ const safeParts = parts.filter((part) => part.length > 0 && part !== "." && part !== "..");
+ return safeParts.length > 0 ? path.join(...safeParts) : DEFAULT_TRANSCRIPT_DIR;
+}
+
+function normalizePluginConfig(pluginConfig: unknown): ResolvedActiveRecallPluginConfig {
+ const raw = (
+ pluginConfig && typeof pluginConfig === "object" ? pluginConfig : {}
+ ) as ActiveRecallPluginConfig;
+ return {
+ agents: Array.isArray(raw.agents)
+ ? raw.agents.map((agentId) => String(agentId).trim()).filter(Boolean)
+ : [],
+ model: typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : DEFAULT_MODEL_REF,
+ timeoutMs: clampInt(
+ parseOptionalPositiveInt(raw.timeoutMs, DEFAULT_TIMEOUT_MS),
+ DEFAULT_TIMEOUT_MS,
+ 250,
+ 60_000,
+ ),
+ queryMode:
+ raw.queryMode === "message" || raw.queryMode === "recent" || raw.queryMode === "full"
+ ? raw.queryMode
+ : DEFAULT_QUERY_MODE,
+ maxMemories: clampInt(
+ parseOptionalPositiveInt(raw.maxMemories, DEFAULT_MAX_MEMORIES),
+ DEFAULT_MAX_MEMORIES,
+ 1,
+ 5,
+ ),
+ maxMemoryChars: clampInt(raw.maxMemoryChars, DEFAULT_MAX_MEMORY_CHARS, 40, 500),
+ recentUserTurns: clampInt(raw.recentUserTurns, DEFAULT_RECENT_USER_TURNS, 0, 4),
+ recentAssistantTurns: clampInt(raw.recentAssistantTurns, DEFAULT_RECENT_ASSISTANT_TURNS, 0, 3),
+ recentUserChars: clampInt(raw.recentUserChars, DEFAULT_RECENT_USER_CHARS, 40, 1000),
+ recentAssistantChars: clampInt(
+ raw.recentAssistantChars,
+ DEFAULT_RECENT_ASSISTANT_CHARS,
+ 40,
+ 1000,
+ ),
+ logging: raw.logging === true,
+ requireConcreteRelevance: raw.requireConcreteRelevance ?? DEFAULT_REQUIRE_CONCRETE_RELEVANCE,
+ dropGenericPreferencesOnNonPreferenceTurns:
+ raw.dropGenericPreferencesOnNonPreferenceTurns ?? DEFAULT_DROP_GENERIC_PREFERENCES,
+ cacheTtlMs: clampInt(raw.cacheTtlMs, DEFAULT_CACHE_TTL_MS, 1000, 120_000),
+ persistTranscripts: raw.persistTranscripts === true,
+ transcriptDir: normalizeTranscriptDir(raw.transcriptDir),
+ };
+}
+
+function isEnabledForAgent(
+ config: ResolvedActiveRecallPluginConfig,
+ agentId: string | undefined,
+): boolean {
+ if (!agentId) {
+ return false;
+ }
+ return config.agents.includes(agentId);
+}
+
+function isEligibleInteractiveSession(ctx: {
+ trigger?: string;
+ sessionKey?: string;
+ sessionId?: string;
+ messageProvider?: string;
+ channelId?: string;
+}): boolean {
+ if (ctx.trigger !== "user") {
+ return false;
+ }
+ if (!ctx.sessionKey && !ctx.sessionId) {
+ return false;
+ }
+ const provider = (ctx.messageProvider ?? "").trim().toLowerCase();
+ if (provider === "webchat") {
+ return true;
+ }
+ return Boolean(ctx.channelId && ctx.channelId.trim());
+}
+
+function buildCacheKey(params: { agentId: string; sessionKey?: string; query: string }): string {
+ const hash = crypto.createHash("sha1").update(params.query).digest("hex");
+ return `${params.agentId}:${params.sessionKey ?? "none"}:${hash}`;
+}
+
+function getCachedResult(cacheKey: string): ActiveRecallResult | undefined {
+ const cached = activeRecallCache.get(cacheKey);
+ if (!cached) {
+ return undefined;
+ }
+ if (cached.expiresAt <= Date.now()) {
+ activeRecallCache.delete(cacheKey);
+ return undefined;
+ }
+ return cached.result;
+}
+
+function setCachedResult(cacheKey: string, result: ActiveRecallResult, ttlMs: number): void {
+ activeRecallCache.set(cacheKey, {
+ expiresAt: Date.now() + ttlMs,
+ result,
+ });
+}
+
+function shouldCacheResult(result: ActiveRecallResult): boolean {
+ return result.status === "ok" || result.status === "empty";
+}
+
+function resolveStatusUpdateAgentId(
+ ctx: { agentId?: string; sessionKey?: string },
+): string {
+ const explicit = ctx.agentId?.trim();
+ if (explicit) {
+ return explicit;
+ }
+ const sessionKey = ctx.sessionKey?.trim();
+ if (!sessionKey) {
+ return "";
+ }
+ const match = /^agent:([^:]+):/i.exec(sessionKey);
+ return match?.[1]?.trim() ?? "";
+}
+
+function formatElapsedMsCompact(elapsedMs: number): string {
+ if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) {
+ return "0ms";
+ }
+ if (elapsedMs >= 1000) {
+ const seconds = elapsedMs / 1000;
+ return `${seconds % 1 === 0 ? seconds.toFixed(0) : seconds.toFixed(1)}s`;
+ }
+ return `${Math.round(elapsedMs)}ms`;
+}
+
+function buildPluginStatusLine(params: {
+ result: ActiveRecallResult;
+ config: ResolvedActiveRecallPluginConfig;
+}): string {
+ const parts = [
+ ACTIVE_MEMORY_STATUS_PREFIX,
+ params.result.status,
+ formatElapsedMsCompact(params.result.elapsedMs),
+ params.config.queryMode,
+ ];
+ if (params.result.status === "ok") {
+ parts.push(`${params.result.memories.length} mem`);
+ }
+ return parts.join(" ");
+}
+
+function buildPluginDebugLine(memories: ActiveRecallCandidate[]): string | null {
+ const cleaned = memories.map((memory) => memory.text.replace(/\s+/g, " ").trim()).filter(Boolean);
+ if (cleaned.length === 0) {
+ return null;
+ }
+ return `${ACTIVE_MEMORY_DEBUG_PREFIX} ${cleaned.join("; ")}`;
+}
+
+async function persistPluginStatusLines(params: {
+ api: OpenClawPluginApi;
+ agentId: string;
+ sessionKey?: string;
+ statusLine?: string;
+ debugMemories?: ActiveRecallCandidate[];
+}): Promise {
+ const sessionKey = params.sessionKey?.trim();
+ if (!sessionKey || !params.agentId.trim()) {
+ return;
+ }
+ try {
+ const storePath = params.api.runtime.agent.session.resolveStorePath(
+ params.api.config.session?.store,
+ {
+ agentId: params.agentId,
+ },
+ );
+ await updateSessionStore(storePath, (store) => {
+ const resolved = resolveSessionStoreEntry({ store, sessionKey });
+ const existing = resolved.existing;
+ if (!existing) {
+ return;
+ }
+ const previousEntries = Array.isArray(existing.pluginDebugEntries)
+ ? existing.pluginDebugEntries
+ : [];
+ const nextEntries = previousEntries.filter(
+ (entry): entry is PluginDebugEntry =>
+ Boolean(entry) &&
+ typeof entry === "object" &&
+ typeof entry.pluginId === "string" &&
+ entry.pluginId !== "active-memory",
+ );
+ const nextLines: string[] = [];
+ if (params.statusLine) {
+ nextLines.push(params.statusLine);
+ }
+ const debugLine = buildPluginDebugLine(params.debugMemories ?? []);
+ if (debugLine) {
+ nextLines.push(debugLine);
+ }
+ if (nextLines.length > 0) {
+ nextEntries.push({
+ pluginId: "active-memory",
+ lines: nextLines,
+ });
+ }
+ store[resolved.normalizedKey] = {
+ ...existing,
+ pluginDebugEntries: nextEntries.length > 0 ? nextEntries : undefined,
+ };
+ });
+ } catch (error) {
+ params.api.logger.debug?.(
+ `active-memory: failed to persist session status note (${error instanceof Error ? error.message : String(error)})`,
+ );
+ }
+}
+
+function escapeXml(str: string): string {
+ return str
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function normalizeNoRecallValue(value: string): boolean {
+ return NO_RECALL_VALUES.has(value.trim().toLowerCase());
+}
+
+function extractLatestUserMessage(query: string): string {
+ const marker = "Latest user message:";
+ const idx = query.lastIndexOf(marker);
+ if (idx >= 0) {
+ return query.slice(idx + marker.length).trim();
+ }
+ return query.trim();
+}
+
+function tokenizeMeaningful(text: string): string[] {
+ return text
+ .toLowerCase()
+ .replace(/[^a-z0-9\s]/g, " ")
+ .split(/\s+/)
+ .filter((token) => token.length >= 3 && !STOPWORDS.has(token));
+}
+
+function isPreferenceSeekingTurn(latestUserMessage: string): boolean {
+ const text = latestUserMessage.toLowerCase();
+ if (
+ /\b(what should|should i|which|pick|choose|order|get|grab|buy|listen|watch|drink|eat|want|sounds right|fits me|safe pick|usual|normally|probably|preference|prefer)\b/.test(
+ text,
+ )
+ ) {
+ return true;
+ }
+ return text.endsWith("?");
+}
+
+function isGenericPreferenceMemory(memory: string): boolean {
+ const text = memory.toLowerCase();
+ return /\b(prefers|usually|default|safe pick|comfort food|counts|better dip|default coffee order|likes?|dislikes?)\b/.test(
+ text,
+ );
+}
+
+function filterWeakRecallCandidates(params: {
+ query: string;
+ candidates: ActiveRecallCandidate[];
+ maxMemories: number;
+ maxMemoryChars: number;
+ requireConcreteRelevance: boolean;
+ dropGenericPreferencesOnNonPreferenceTurns: boolean;
+}): ActiveRecallCandidate[] {
+ const latestUserMessage = extractLatestUserMessage(params.query);
+ const latestTokens = new Set(tokenizeMeaningful(latestUserMessage));
+ const preferenceSeeking = isPreferenceSeekingTurn(latestUserMessage);
+ const filtered = params.candidates.filter((candidate) => {
+ const candidateTokens = tokenizeMeaningful(candidate.text);
+ const overlap = candidateTokens.filter((token) => latestTokens.has(token)).length;
+ if (overlap > 0) {
+ return true;
+ }
+ if (
+ params.dropGenericPreferencesOnNonPreferenceTurns &&
+ !preferenceSeeking &&
+ isGenericPreferenceMemory(candidate.text)
+ ) {
+ return false;
+ }
+ if (params.requireConcreteRelevance) {
+ return false;
+ }
+ return candidateTokens.some((token) => latestTokens.has(token));
+ });
+ return filtered.slice(0, params.maxMemories).map((candidate) => ({
+ ...candidate,
+ text: candidate.text.slice(0, params.maxMemoryChars),
+ }));
+}
+
+function parseRawReply(rawReply: string, maxMemories = DEFAULT_MAX_MEMORIES): string[] {
+ const trimmed = rawReply.trim();
+ if (normalizeNoRecallValue(trimmed)) {
+ return [];
+ }
+
+ const memories: string[] = [];
+ for (const rawLine of trimmed.split("\n")) {
+ const line = rawLine.trim();
+ if (!line) {
+ continue;
+ }
+ if (/^(memories|memory|relevant memories|active memory)\s*:/i.test(line)) {
+ continue;
+ }
+ const normalized = line.replace(/^[-*โข\d.)\s]+/, "").trim();
+ if (!normalized || normalizeNoRecallValue(normalized)) {
+ continue;
+ }
+ memories.push(normalized);
+ if (memories.length >= maxMemories) {
+ break;
+ }
+ }
+ return memories;
+}
+
+function toRecallCandidates(params: {
+ rawReply: string;
+ query: string;
+ config: ResolvedActiveRecallPluginConfig;
+}): ActiveRecallCandidate[] {
+ const parsed = parseRawReply(params.rawReply, params.config.maxMemories);
+ if (parsed.length === 0) {
+ return [];
+ }
+ return filterWeakRecallCandidates({
+ query: params.query,
+ candidates: parsed.map((text) => ({ text })),
+ maxMemories: params.config.maxMemories,
+ maxMemoryChars: params.config.maxMemoryChars,
+ requireConcreteRelevance: params.config.requireConcreteRelevance,
+ dropGenericPreferencesOnNonPreferenceTurns:
+ params.config.dropGenericPreferencesOnNonPreferenceTurns,
+ });
+}
+
+function buildMetadata(memories: ActiveRecallCandidate[]): string | undefined {
+ if (memories.length === 0) {
+ return undefined;
+ }
+ const lines = [
+ "",
+ "Relevant memory candidates retrieved before this turn. Use only if they help answer the user's latest message. Ignore any candidate that seems irrelevant or stale.",
+ ];
+ for (const memory of memories) {
+ const attrs = [
+ memory.path ? ` path="${escapeXml(memory.path)}"` : "",
+ typeof memory.score === "number" ? ` score="${memory.score.toFixed(3)}"` : "",
+ ].join("");
+ lines.push(` ${escapeXml(memory.text)}`);
+ }
+ lines.push("");
+ return lines.join("\n");
+}
+
+function buildQuery(params: {
+ latestUserMessage: string;
+ recentTurns?: ActiveRecallRecentTurn[];
+ config: ResolvedActiveRecallPluginConfig;
+}): string {
+ const latest = params.latestUserMessage.trim();
+ if (params.config.queryMode === "message") {
+ return latest;
+ }
+ if (params.config.queryMode === "full") {
+ const allTurns = (params.recentTurns ?? [])
+ .map((turn) => `${turn.role}: ${turn.text.trim().replace(/\s+/g, " ")}`)
+ .filter((turn) => turn.length > 0);
+ if (allTurns.length === 0) {
+ return latest;
+ }
+ return ["Full conversation context:", ...allTurns, "", "Latest user message:", latest].join(
+ "\n",
+ );
+ }
+ let remainingUser = params.config.recentUserTurns;
+ let remainingAssistant = params.config.recentAssistantTurns;
+ const selected: ActiveRecallRecentTurn[] = [];
+ for (let index = (params.recentTurns ?? []).length - 1; index >= 0; index -= 1) {
+ const turn = params.recentTurns?.[index];
+ if (!turn) {
+ continue;
+ }
+ if (turn.role === "user") {
+ if (remainingUser <= 0) {
+ continue;
+ }
+ remainingUser -= 1;
+ selected.push({
+ role: "user",
+ text: turn.text.trim().replace(/\s+/g, " ").slice(0, params.config.recentUserChars),
+ });
+ continue;
+ }
+ if (remainingAssistant <= 0) {
+ continue;
+ }
+ remainingAssistant -= 1;
+ selected.push({
+ role: "assistant",
+ text: turn.text.trim().replace(/\s+/g, " ").slice(0, params.config.recentAssistantChars),
+ });
+ }
+ const recentTurns = selected.toReversed().filter((turn) => turn.text.length > 0);
+ if (recentTurns.length === 0) {
+ return latest;
+ }
+ return [
+ "Recent conversation tail:",
+ ...recentTurns.map((turn) => `${turn.role}: ${turn.text}`),
+ "",
+ "Latest user message:",
+ latest,
+ ].join("\n");
+}
+
+function extractTextContent(content: unknown): string {
+ if (typeof content === "string") {
+ return content;
+ }
+ if (!Array.isArray(content)) {
+ return "";
+ }
+ const parts: string[] = [];
+ for (const item of content) {
+ if (typeof item === "string") {
+ parts.push(item);
+ continue;
+ }
+ if (!item || typeof item !== "object") {
+ continue;
+ }
+ const typed = item as { type?: unknown; text?: unknown; content?: unknown };
+ if (typeof typed.text === "string") {
+ parts.push(typed.text);
+ continue;
+ }
+ if (typed.type === "text" && typeof typed.content === "string") {
+ parts.push(typed.content);
+ }
+ }
+ return parts.join(" ").trim();
+}
+
+function extractRecentTurns(messages: unknown[]): ActiveRecallRecentTurn[] {
+ const turns: ActiveRecallRecentTurn[] = [];
+ for (const message of messages) {
+ if (!message || typeof message !== "object") {
+ continue;
+ }
+ const typed = message as { role?: unknown; content?: unknown };
+ const role = typed.role === "user" || typed.role === "assistant" ? typed.role : undefined;
+ if (!role) {
+ continue;
+ }
+ const text = extractTextContent(typed.content);
+ if (!text) {
+ continue;
+ }
+ turns.push({ role, text });
+ }
+ return turns;
+}
+
+function getModelRef(
+ api: OpenClawPluginApi,
+ agentId: string,
+ config: ResolvedActiveRecallPluginConfig,
+) {
+ const configured = config.model || resolveAgentEffectiveModelPrimary(api.config, agentId) || DEFAULT_MODEL_REF;
+ const parsed = parseModelRef(configured, DEFAULT_PROVIDER);
+ if (parsed) {
+ return parsed;
+ }
+ return (
+ parseModelRef(resolveAgentEffectiveModelPrimary(api.config, agentId), DEFAULT_PROVIDER) ?? {
+ provider: DEFAULT_PROVIDER,
+ model: configured,
+ }
+ );
+}
+
+async function runRecallSidecar(params: {
+ api: OpenClawPluginApi;
+ config: ResolvedActiveRecallPluginConfig;
+ agentId: string;
+ sessionKey?: string;
+ query: string;
+}): Promise<{ rawReply: string; transcriptPath?: string }> {
+ const workspaceDir = resolveAgentWorkspaceDir(params.api.config, params.agentId);
+ const agentDir = resolveAgentDir(params.api.config, params.agentId);
+ const modelRef = getModelRef(params.api, params.agentId, params.config);
+ const sidecarSessionId = `active-memory-${Date.now().toString(36)}`;
+ const sidecarSessionKey = `active-memory:${params.agentId}:${crypto
+ .createHash("sha1")
+ .update(`${params.sessionKey ?? "none"}:${params.query}`)
+ .digest("hex")
+ .slice(0, 12)}`;
+ const storePath = params.api.runtime.agent.session.resolveStorePath(
+ params.api.config.session?.store,
+ {
+ agentId: params.agentId,
+ },
+ );
+ const resolvedStorePath = storePath || path.join(os.tmpdir(), "openclaw-active-memory-sessions.json");
+ const baseSessionsDir = path.dirname(path.resolve(resolvedStorePath));
+ const tempDir = params.config.persistTranscripts
+ ? undefined
+ : await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-active-memory-"));
+ const persistedDir = params.config.persistTranscripts
+ ? path.join(baseSessionsDir, params.config.transcriptDir)
+ : undefined;
+ if (persistedDir) {
+ await fs.mkdir(persistedDir, { recursive: true });
+ }
+ const sessionFile = params.config.persistTranscripts
+ ? path.join(persistedDir!, `${sidecarSessionId}.jsonl`)
+ : path.join(tempDir!, "session.jsonl");
+ const prompt = [
+ "You are Active Memory, a fast sidecar memory model.",
+ "Use only memory_search and memory_get.",
+ "Search for memories relevant to the user's latest message.",
+ "Return memories only if they would concretely change or personalize the answer.",
+ "If the connection is weak, broad, or only vaguely related, reply with NONE.",
+ "Do not return generic lifestyle or food preferences unless the latest user message is clearly asking for a choice, recommendation, habit, or preference-sensitive answer.",
+ "If nothing seems strongly useful, reply with NONE.",
+ "If something is useful, reply with up to 3 short bullet points only.",
+ "Do not answer the user directly.",
+ "Do not explain your reasoning.",
+ "",
+ "Conversation context:",
+ params.query,
+ ].join("\n");
+
+ try {
+ const result = await params.api.runtime.agent.runEmbeddedPiAgent({
+ sessionId: sidecarSessionId,
+ sessionKey: sidecarSessionKey,
+ agentId: params.agentId,
+ sessionFile,
+ workspaceDir,
+ agentDir,
+ config: params.api.config,
+ prompt,
+ provider: modelRef.provider,
+ model: modelRef.model,
+ timeoutMs: params.config.timeoutMs,
+ runId: sidecarSessionId,
+ trigger: "manual",
+ toolsAllow: ["memory_search", "memory_get"],
+ disableMessageTool: true,
+ bootstrapContextMode: "lightweight",
+ verboseLevel: "off",
+ thinkLevel: "off",
+ reasoningLevel: "off",
+ silentExpected: true,
+ });
+ const rawReply = (result.payloads ?? [])
+ .map((payload) => payload.text?.trim() ?? "")
+ .filter(Boolean)
+ .join("\n")
+ .trim();
+ return {
+ rawReply: rawReply || "NONE",
+ transcriptPath: params.config.persistTranscripts ? sessionFile : undefined,
+ };
+ } finally {
+ if (tempDir) {
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
+ }
+ }
+}
+
+async function maybeResolveActiveRecall(params: {
+ api: OpenClawPluginApi;
+ config: ResolvedActiveRecallPluginConfig;
+ agentId: string;
+ sessionKey?: string;
+ query: string;
+}): Promise {
+ const startedAt = Date.now();
+ const cacheKey = buildCacheKey({
+ agentId: params.agentId,
+ sessionKey: params.sessionKey,
+ query: params.query,
+ });
+ const cached = getCachedResult(cacheKey);
+ const logPrefix = `active-memory: agent=${params.agentId} session=${params.sessionKey ?? "none"}`;
+ if (cached) {
+ await persistPluginStatusLines({
+ api: params.api,
+ agentId: params.agentId,
+ sessionKey: params.sessionKey,
+ statusLine: `${buildPluginStatusLine({ result: cached, config: params.config })} cached`,
+ debugMemories: cached.memories,
+ });
+ if (params.config.logging) {
+ params.api.logger.info?.(
+ `${logPrefix} cached status=${cached.status} memories=${String(cached.memories.length)} queryChars=${String(params.query.length)}`,
+ );
+ }
+ return cached;
+ }
+
+ if (params.config.logging) {
+ params.api.logger.info?.(
+ `${logPrefix} start timeoutMs=${String(params.config.timeoutMs)} queryChars=${String(params.query.length)}`,
+ );
+ }
+
+ try {
+ const recallPromise = runRecallSidecar(params).then(({ rawReply, transcriptPath }) => {
+ const memories = toRecallCandidates({
+ rawReply,
+ query: params.query,
+ config: params.config,
+ });
+ if (params.config.logging && transcriptPath) {
+ params.api.logger.info?.(`${logPrefix} transcript=${transcriptPath}`);
+ }
+ return {
+ status: memories.length > 0 ? ("ok" as const) : ("empty" as const),
+ elapsedMs: Date.now() - startedAt,
+ rawReply,
+ memories,
+ } satisfies ActiveRecallResult;
+ });
+
+ const result = await Promise.race([
+ recallPromise,
+ new Promise((resolve) =>
+ setTimeout(
+ () =>
+ resolve({
+ status: "timeout",
+ elapsedMs: Date.now() - startedAt,
+ memories: [],
+ }),
+ params.config.timeoutMs,
+ ),
+ ),
+ ]);
+ if (params.config.logging) {
+ params.api.logger.info?.(
+ `${logPrefix} done status=${result.status} elapsedMs=${String(result.elapsedMs)} memories=${String(result.memories.length)}`,
+ );
+ }
+ await persistPluginStatusLines({
+ api: params.api,
+ agentId: params.agentId,
+ sessionKey: params.sessionKey,
+ statusLine: buildPluginStatusLine({ result, config: params.config }),
+ debugMemories: result.memories,
+ });
+ if (shouldCacheResult(result)) {
+ setCachedResult(cacheKey, result, params.config.cacheTtlMs);
+ }
+ return result;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ if (params.config.logging) {
+ params.api.logger.warn?.(`${logPrefix} failed error=${message}`);
+ }
+ const result: ActiveRecallResult = {
+ status: "unavailable",
+ elapsedMs: Date.now() - startedAt,
+ memories: [],
+ };
+ await persistPluginStatusLines({
+ api: params.api,
+ agentId: params.agentId,
+ sessionKey: params.sessionKey,
+ statusLine: buildPluginStatusLine({ result, config: params.config }),
+ });
+ return result;
+ }
+}
+
+export default definePluginEntry({
+ id: "active-memory",
+ name: "Active Memory",
+ description: "Proactively surfaces relevant memory before eligible conversational replies.",
+ register(api: OpenClawPluginApi) {
+ const config = normalizePluginConfig(api.pluginConfig);
+ api.on("before_prompt_build", async (event, ctx) => {
+ const effectiveAgentId = resolveStatusUpdateAgentId(ctx);
+ if (!isEnabledForAgent(config, effectiveAgentId)) {
+ await persistPluginStatusLines({
+ api,
+ agentId: effectiveAgentId,
+ sessionKey: ctx.sessionKey,
+ });
+ return;
+ }
+ if (!isEligibleInteractiveSession(ctx)) {
+ await persistPluginStatusLines({
+ api,
+ agentId: effectiveAgentId,
+ sessionKey: ctx.sessionKey,
+ });
+ return;
+ }
+ const query = buildQuery({
+ latestUserMessage: event.prompt,
+ recentTurns: extractRecentTurns(event.messages),
+ config,
+ });
+ const result = await maybeResolveActiveRecall({
+ api,
+ config,
+ agentId: effectiveAgentId,
+ sessionKey: ctx.sessionKey,
+ query,
+ });
+ if (result.memories.length === 0) {
+ return;
+ }
+ const metadata = buildMetadata(result.memories);
+ if (!metadata) {
+ return;
+ }
+ return {
+ appendSystemContext: metadata,
+ };
+ });
+ },
+});
diff --git a/extensions/active-memory/openclaw.plugin.json b/extensions/active-memory/openclaw.plugin.json
new file mode 100644
index 0000000000..3897e4a67e
--- /dev/null
+++ b/extensions/active-memory/openclaw.plugin.json
@@ -0,0 +1,62 @@
+{
+ "id": "active-memory",
+ "name": "Active Memory",
+ "description": "Runs a bounded memory sidecar 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" },
+ "timeoutMs": { "type": "integer", "minimum": 250 },
+ "queryMode": {
+ "type": "string",
+ "enum": ["message", "recent", "full"]
+ },
+ "maxMemories": { "type": "integer", "minimum": 1, "maximum": 5 },
+ "maxMemoryChars": { "type": "integer", "minimum": 40, "maximum": 500 },
+ "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" },
+ "requireConcreteRelevance": { "type": "boolean" },
+ "dropGenericPreferencesOnNonPreferenceTurns": { "type": "boolean" },
+ "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 memory sidecar."
+ },
+ "timeoutMs": {
+ "label": "Timeout (ms)"
+ },
+ "queryMode": {
+ "label": "Query Mode",
+ "help": "Choose whether the sidecar sees only the latest user message, a small recent tail, or the full conversation."
+ },
+ "logging": {
+ "label": "Enable Logging",
+ "help": "Emit active memory timing and result logs."
+ },
+ "persistTranscripts": {
+ "label": "Persist Transcripts",
+ "help": "Keep sidecar 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."
+ }
+ }
+}
diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts
index 47c0de1d64..9526f7fdf8 100644
--- a/src/auto-reply/commands-registry.shared.ts
+++ b/src/auto-reply/commands-registry.shared.ts
@@ -832,7 +832,6 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
registerAlias(commands, "reasoning", "/reason");
registerAlias(commands, "elevated", "/elev");
registerAlias(commands, "steer", "/tell");
-
assertCommandRegistry(commands);
return commands;
}
diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts
index 0257554831..00a021d056 100644
--- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts
+++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts
@@ -989,6 +989,117 @@ 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 2 mem",
+ "๐ Active Memory Debug: lemon pepper wings; 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 2 mem\n๐ Active Memory Debug: lemon pepper wings; blue cheese",
+ ]);
+ });
+});
+
describe("runReplyAgent claude-cli routing", () => {
function createRun() {
const typing = createMockTypingController();
diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts
index dfa9247491..958de47a35 100644
--- a/src/auto-reply/reply/agent-runner.ts
+++ b/src/auto-reply/reply/agent-runner.ts
@@ -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;
+}): 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,13 @@ export async function runReplyAgent(params: {
}
}
+ activeSessionEntry = refreshSessionEntryFromStore({
+ storePath,
+ sessionKey,
+ fallbackEntry: activeSessionEntry,
+ activeSessionStore,
+ });
+
// If verbose is enabled, prepend operational run notices.
let finalPayloads = guardedReplyPayloads;
const verboseNotices: ReplyPayload[] = [];
@@ -822,6 +864,12 @@ export async function runReplyAgent(params: {
if (verboseNotices.length > 0) {
finalPayloads = [...verboseNotices, ...finalPayloads];
}
+ if (verboseEnabled) {
+ const pluginStatusPayload = buildInlinePluginStatusPayload(activeSessionEntry);
+ if (pluginStatusPayload) {
+ finalPayloads = [...finalPayloads, pluginStatusPayload];
+ }
+ }
if (responseUsageLine) {
finalPayloads = appendUsageLine(finalPayloads, responseUsageLine);
}
diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts
index 348a212c8d..f1bf2ae135 100644
--- a/src/auto-reply/status.test.ts
+++ b/src/auto-reply/status.test.ts
@@ -115,6 +115,46 @@ 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("shows fast mode when enabled", () => {
const text = buildStatusMessage({
agent: {
diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts
index 720f2ea89d..7a09e2ec9b 100644
--- a/src/auto-reply/status.ts
+++ b/src/auto-reply/status.ts
@@ -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,
]
diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts
index 140f8f05aa..d1b80e1f68 100644
--- a/src/config/sessions/types.ts
+++ b/src/config/sessions/types.ts
@@ -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,41 @@ 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 | 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,
+ )
+ : [],
+ )
+ : [];
+ if (structured.length > 0) {
+ return structured;
+ }
+ return Array.isArray(entry?.pluginStatusLines)
+ ? entry.pluginStatusLines.filter(
+ (line): line is string => typeof line === "string" && line.trim().length > 0,
+ )
+ : [];
+}
+
export function normalizeSessionRuntimeModelFields(entry: SessionEntry): SessionEntry {
const normalizedModel = normalizeOptionalString(entry.model);
const normalizedProvider = normalizeOptionalString(entry.modelProvider);