Refine plugin debug plumbing
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, "&")
|
||||
.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 = [
|
||||
"<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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user