Refine plugin debug plumbing

This commit is contained in:
Tak Hoffman
2026-04-08 13:01:25 -05:00
parent cfe71e2e44
commit 8abf8a1bee
12 changed files with 2026 additions and 1 deletions
+402
View File
@@ -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 `<active_memory>...</active_memory>` 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/<agent>/sessions/active-memory/<sidecar-session-id>.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)
+1
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
+12
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
+378
View File
@@ -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<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();
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("<active_memory>"),
});
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<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("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<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("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);
});
});
+931
View File
@@ -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<string, CachedActiveRecallResult>();
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<void> {
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
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 = [
"<active_memory>",
"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(` <memory${attrs}>${escapeXml(memory.text)}</memory>`);
}
lines.push("</active_memory>");
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<ActiveRecallResult> {
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<ActiveRecallResult>((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,
};
});
},
});
@@ -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."
}
}
}
@@ -832,7 +832,6 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
registerAlias(commands, "reasoning", "/reason");
registerAlias(commands, "elevated", "/elev");
registerAlias(commands, "steer", "/tell");
assertCommandRegistry(commands);
return commands;
}
@@ -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();
+48
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,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);
}
+40
View File
@@ -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: {
+4
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,
]
+37
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,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<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,
)
: [],
)
: [];
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);