diff --git a/docs/concepts/active-memory.md b/docs/concepts/active-memory.md new file mode 100644 index 0000000000..38565ba3ff --- /dev/null +++ b/docs/concepts/active-memory.md @@ -0,0 +1,402 @@ +--- +title: "Active Memory" +summary: "A plugin-owned sidecar memory pass that injects relevant memory into interactive chat sessions" +read_when: + - You want to understand what active memory is for + - You want to turn active memory on for a conversational agent + - You want to tune active memory behavior without enabling it everywhere +--- + +# Active Memory + +Active memory is an optional plugin-owned memory pass that runs before the main +reply for eligible conversational sessions. + +It exists because most memory systems are capable but reactive. They rely on +the main agent to decide when to search memory, or on the user to say things +like "remember this" or "search memory." By then, the moment where memory would +have made the reply feel natural has already passed. + +Active memory gives the system one bounded chance to surface relevant memory +before the main reply is generated. + +## Turn active memory on + +The safest setup is: + +1. enable the plugin +2. target one conversational agent +3. keep logging on only while tuning + +Start with this in `openclaw.json`: + +```json5 +{ + plugins: { + entries: { + "active-memory": { + enabled: true, + config: { + agents: ["main"], + model: "github-copilot/gpt-5.4-mini", + queryMode: "recent", + timeoutMs: 8000, + maxMemories: 2, + persistTranscripts: false, + logging: true, + }, + }, + }, + }, +} +``` + +Then restart the gateway: + +```bash +pnpm gateway:dev +``` + +What this means: + +- `plugins.entries.active-memory.enabled: true` turns the plugin on +- `config.agents: ["main"]` opts only the `main` agent into active memory +- active memory still runs only on eligible interactive persistent chat sessions + +## How to see it + +Active memory injects hidden system context for the model. It does not expose +raw `...` tags to the client. + +If you want to see what active memory is doing in a live session, turn verbose +mode on for that session: + +```text +/verbose on +``` + +With verbose enabled, OpenClaw can show: + +- an active memory status line such as `Active Memory: ok 842ms recent 2 mem` +- a readable debug summary such as `Active Memory Debug: lemon pepper wings; blue cheese` + +Those lines are derived from the same active memory pass that feeds the hidden +system context, but they are formatted for humans instead of exposing raw prompt +markup. + +By default, the sidecar transcript for that pass is temporary and deleted after +the run completes. + +Example flow: + +```text +/verbose on +what wings should i order? +``` + +Expected visible reply shape: + +```text +...normal assistant reply... + +๐Ÿงฉ Active Memory: ok 842ms recent 2 mem +๐Ÿ”Ž Active Memory Debug: lemon pepper wings; blue cheese +``` + +## When it runs + +Active memory uses two gates: + +1. **Config opt-in** + The plugin must be enabled, and the current agent id must appear in + `plugins.entries.active-memory.config.agents`. +2. **Strict runtime eligibility** + Even when enabled and targeted, active memory only runs for eligible + interactive persistent chat sessions. + +The actual rule is: + +```text +plugin enabled ++ +agent id targeted ++ +eligible interactive persistent chat session += +active memory runs +``` + +If any of those fail, active memory does not run. + +## Where it runs + +Active memory is a conversational enrichment feature, not a platform-wide +inference feature. + +| Surface | Runs active memory? | +| ------------------------------------------------------------------- | ------------------------------------------------------- | +| Control UI / web chat persistent sessions | Yes, if the plugin is enabled and the agent is targeted | +| Other interactive channel sessions on the same persistent chat path | Yes, if the plugin is enabled and the agent is targeted | +| Headless one-shot runs | No | +| Heartbeat/background runs | No | +| Generic internal `agent-command` paths | No | +| Subagent/internal helper execution | No | + +## Why use it + +Use active memory when: + +- the session is persistent and user-facing +- the agent has meaningful long-term memory to search +- continuity and personalization matter more than raw prompt determinism + +It works especially well for: + +- stable preferences +- recurring habits +- long-term user context that should surface naturally + +It is a poor fit for: + +- automation +- internal workers +- one-shot API tasks +- places where hidden personalization would be surprising + +## How it works + +The runtime shape is: + +```mermaid +flowchart LR + U["User Message"] --> Q["Build Memory Query"] + Q --> R["Active Memory Sidecar"] + R -->|NONE or empty| M["Main Reply"] + R -->|relevant bullets| I["Append Hidden active_memory System Context"] + I --> M["Main Reply"] +``` + +The sidecar can use only: + +- `memory_search` +- `memory_get` + +If the connection is weak, it should return `NONE`. + +## Query modes + +`config.queryMode` controls how much conversation the sidecar sees. + +### `message` + +Only the latest user message is sent. + +```text +Latest user message only +``` + +Use this when: + +- you want the fastest behavior +- you want the strongest bias toward stable preference recall +- follow-up turns do not need conversational context + +Recommended timeout: + +- start around `3000` to `5000` ms + +### `recent` + +The latest user message plus a small recent conversational tail is sent. + +```text +Recent conversation tail: +user: ... +assistant: ... +user: ... + +Latest user message: +... +``` + +Use this when: + +- you want a better balance of speed and conversational grounding +- follow-up questions often depend on the last few turns + +Recommended timeout: + +- start around `8000` ms + +### `full` + +The full conversation is sent to the sidecar. + +```text +Full conversation context: +user: ... +assistant: ... +user: ... +... +``` + +Use this when: + +- the strongest recall quality matters more than latency +- the conversation contains important setup far back in the thread + +Recommended timeout: + +- increase it substantially compared with `message` or `recent` +- start around `15000` ms or higher depending on thread size + +In general, timeout should increase with context size: + +```text +message < recent < full +``` + +## Transcript persistence + +Active memory sidecar runs create a real `session.jsonl` transcript during the +sidecar call. + +By default, that transcript is temporary: + +- it is written to a temp directory +- it is used only for the sidecar run +- it is deleted immediately after the run finishes + +If you want to keep those sidecar transcripts on disk for debugging or +inspection, turn persistence on explicitly: + +```json5 +{ + plugins: { + entries: { + "active-memory": { + enabled: true, + config: { + agents: ["main"], + persistTranscripts: true, + transcriptDir: "active-memory", + }, + }, + }, + }, +} +``` + +When enabled, active memory stores transcripts in a separate directory under the +target agent's sessions folder, not in the main user conversation transcript +path. + +The default layout is conceptually: + +```text +agents//sessions/active-memory/.jsonl +``` + +You can change the relative subdirectory with `config.transcriptDir`. + +Use this carefully: + +- sidecar transcripts can accumulate quickly on busy sessions +- `full` query mode can duplicate a lot of conversation context +- these transcripts contain hidden prompt context and recalled memories + +## Configuration + +All active memory configuration lives under: + +```text +plugins.entries.active-memory +``` + +The most important fields are: + +| Key | Type | Meaning | +| --------------------------- | --------------------------------- | --------------------------------------------------------------------- | +| `enabled` | `boolean` | Enables the plugin itself | +| `config.agents` | `string[]` | Agent ids that may use active memory | +| `config.model` | `string` | Sidecar model ref | +| `config.queryMode` | `"message" \| "recent" \| "full"` | Controls how much conversation the sidecar sees | +| `config.timeoutMs` | `number` | Hard timeout for the sidecar | +| `config.maxMemories` | `number` | Maximum recalled bullets to inject | +| `config.logging` | `boolean` | Emits active memory logs while tuning | +| `config.persistTranscripts` | `boolean` | Keeps sidecar transcripts on disk instead of deleting temp files | +| `config.transcriptDir` | `string` | Relative sidecar transcript directory under the agent sessions folder | + +Useful tuning fields: + +| Key | Type | Meaning | +| --------------------------------------------------- | --------- | ------------------------------------------------------------- | +| `config.maxMemoryChars` | `number` | Maximum characters per memory bullet | +| `config.recentUserTurns` | `number` | Prior user turns to include when `queryMode` is `recent` | +| `config.recentAssistantTurns` | `number` | Prior assistant turns to include when `queryMode` is `recent` | +| `config.recentUserChars` | `number` | Max chars per recent user turn | +| `config.recentAssistantChars` | `number` | Max chars per recent assistant turn | +| `config.requireConcreteRelevance` | `boolean` | Biases toward `NONE` on weak matches | +| `config.dropGenericPreferencesOnNonPreferenceTurns` | `boolean` | Filters generic preference noise | +| `config.cacheTtlMs` | `number` | Cache reuse for repeated identical queries | + +## Recommended setup + +Start with `recent`. + +```json5 +{ + plugins: { + entries: { + "active-memory": { + enabled: true, + config: { + agents: ["main"], + model: "github-copilot/gpt-5.4-mini", + queryMode: "recent", + timeoutMs: 8000, + maxMemories: 2, + logging: true, + }, + }, + }, + }, +} +``` + +If you want to inspect live behavior while tuning, use `/verbose on` in the +session instead of looking for a separate active-memory debug command. + +Then move to: + +- `message` if you want lower latency +- `full` if you decide extra context is worth the slower sidecar + +## Debugging + +If active memory is not showing up where you expect: + +1. Confirm the plugin is enabled under `plugins.entries.active-memory.enabled`. +2. Confirm the current agent id is listed in `config.agents`. +3. Confirm you are testing through an interactive persistent chat session. +4. Turn on `config.logging: true` and watch the gateway logs. +5. Verify memory search itself works with `openclaw memory status --deep`. + +If memory hits are noisy, tighten: + +- `maxMemories` +- `requireConcreteRelevance` +- `dropGenericPreferencesOnNonPreferenceTurns` + +If active memory is too slow: + +- lower `queryMode` +- lower `timeoutMs` +- reduce recent turn counts +- reduce per-turn char caps + +## Related pages + +- [Memory Search](/concepts/memory-search) +- [Memory configuration reference](/reference/memory-config) +- [Plugin SDK setup](/plugins/sdk-setup) diff --git a/docs/concepts/memory-search.md b/docs/concepts/memory-search.md index c769513a04..02d34f6c83 100644 --- a/docs/concepts/memory-search.md +++ b/docs/concepts/memory-search.md @@ -138,5 +138,6 @@ earlier conversations. This is opt-in via ## Further reading +- [Active Memory](/concepts/active-memory) -- sidecar memory for interactive chat sessions - [Memory](/concepts/memory) -- file layout, backends, tools - [Memory configuration reference](/reference/memory-config) -- all config knobs diff --git a/docs/reference/memory-config.md b/docs/reference/memory-config.md index 93c3959cba..d882bc49a0 100644 --- a/docs/reference/memory-config.md +++ b/docs/reference/memory-config.md @@ -17,10 +17,22 @@ conceptual overviews, see: - [Builtin Engine](/concepts/memory-builtin) -- default SQLite backend - [QMD Engine](/concepts/memory-qmd) -- local-first sidecar - [Memory Search](/concepts/memory-search) -- search pipeline and tuning +- [Active Memory](/concepts/active-memory) -- enabling the memory sidecar for interactive sessions All memory search settings live under `agents.defaults.memorySearch` in `openclaw.json` unless noted otherwise. +If you are looking for the **active memory** feature toggle and sidecar config, +that lives under `plugins.entries.active-memory` instead of `memorySearch`. + +Active memory uses a two-gate model: + +1. the plugin must be enabled and target the current agent id +2. the request must be an eligible interactive persistent chat session + +See [Active Memory](/concepts/active-memory) for the activation model, +plugin-owned config, transcript persistence, and safe rollout pattern. + --- ## Provider selection diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts new file mode 100644 index 0000000000..d2af6c055c --- /dev/null +++ b/extensions/active-memory/index.test.ts @@ -0,0 +1,378 @@ +import fs from "node:fs/promises"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import plugin from "./index.js"; + +const hoisted = vi.hoisted(() => { + const sessionStore: Record> = { + "agent:main:main": { + sessionId: "s-main", + updatedAt: 0, + }, + }; + return { + sessionStore, + updateSessionStore: vi.fn( + async (_storePath: string, updater: (store: Record) => void) => { + updater(sessionStore); + }, + ), + }; +}); + +vi.mock("openclaw/plugin-sdk/config-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/config-runtime", + ); + return { + ...actual, + updateSessionStore: hoisted.updateSessionStore, + }; +}); + +describe("active-memory plugin", () => { + const hooks: Record = {}; + const runEmbeddedPiAgent = vi.fn(); + const api: any = { + pluginConfig: { + agents: ["main"], + logging: true, + }, + config: {}, + id: "active-memory", + name: "Active Memory", + logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }, + runtime: { + agent: { + runEmbeddedPiAgent, + session: { + resolveStorePath: vi.fn(() => "/tmp/openclaw-session-store.json"), + loadSessionStore: vi.fn(() => hoisted.sessionStore), + saveSessionStore: vi.fn(async () => {}), + }, + }, + }, + on: vi.fn((hookName: string, handler: Function) => { + hooks[hookName] = handler; + }), + }; + + beforeEach(() => { + vi.clearAllMocks(); + hoisted.sessionStore["agent:main:main"] = { + sessionId: "s-main", + updatedAt: 0, + }; + for (const key of Object.keys(hooks)) { + delete hooks[key]; + } + runEmbeddedPiAgent.mockResolvedValue({ + payloads: [{ text: "- lemon pepper wings\n- blue cheese" }], + }); + plugin.register(api as unknown as OpenClawPluginApi); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("registers a before_prompt_build hook", () => { + expect(api.on).toHaveBeenCalledWith("before_prompt_build", expect.any(Function)); + }); + + it("does not run for agents that are not explicitly targeted", async () => { + const result = await hooks.before_prompt_build( + { prompt: "what wings should i order?", messages: [] }, + { + agentId: "support", + trigger: "user", + sessionKey: "agent:support:main", + messageProvider: "webchat", + }, + ); + + expect(result).toBeUndefined(); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + + it("does not run for non-interactive contexts", async () => { + const result = await hooks.before_prompt_build( + { prompt: "what wings should i order?", messages: [] }, + { + agentId: "main", + trigger: "heartbeat", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + expect(result).toBeUndefined(); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + + it("injects system context on a successful recall hit", async () => { + const result = await hooks.before_prompt_build( + { + prompt: "what wings should i order?", + messages: [ + { role: "user", content: "i want something greasy tonight" }, + { role: "assistant", content: "let's narrow it down" }, + ], + }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + appendSystemContext: expect.stringContaining(""), + }); + expect((result as { appendSystemContext: string }).appendSystemContext).toContain( + "lemon pepper wings", + ); + }); + + it("persists a readable debug summary alongside the status line", async () => { + const sessionKey = "agent:main:debug"; + hoisted.sessionStore[sessionKey] = { + sessionId: "s-main", + updatedAt: 0, + }; + + await hooks.before_prompt_build( + { + prompt: "what wings should i order?", + messages: [], + }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); + + expect(hoisted.updateSessionStore).toHaveBeenCalled(); + const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as + | ((store: Record>) => void) + | undefined; + const store = { + [sessionKey]: { + sessionId: "s-main", + updatedAt: 0, + }, + } as Record>; + updater?.(store); + expect(store[sessionKey]?.pluginDebugEntries).toEqual([ + { + pluginId: "active-memory", + lines: expect.arrayContaining([ + expect.stringContaining("๐Ÿงฉ Active Memory: ok"), + expect.stringContaining("๐Ÿ”Ž Active Memory Debug: lemon pepper wings"), + ]), + }, + ]); + }); + + it("returns nothing when the sidecar says none", async () => { + runEmbeddedPiAgent.mockResolvedValueOnce({ + payloads: [{ text: "NONE" }], + }); + + const result = await hooks.before_prompt_build( + { prompt: "fair, okay gonna do them by throwing them in the garbage", messages: [] }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + expect(result).toBeUndefined(); + }); + + it("does not cache timeout results", async () => { + api.pluginConfig = { + agents: ["main"], + timeoutMs: 250, + logging: true, + }; + plugin.register(api as unknown as OpenClawPluginApi); + runEmbeddedPiAgent.mockImplementation( + async () => await new Promise((resolve) => setTimeout(() => resolve({ payloads: [] }), 300)), + ); + + await hooks.before_prompt_build( + { prompt: "what wings should i order? timeout test", messages: [] }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:timeout-test", + messageProvider: "webchat", + }, + ); + await hooks.before_prompt_build( + { prompt: "what wings should i order? timeout test", messages: [] }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:timeout-test", + messageProvider: "webchat", + }, + ); + + expect(hoisted.updateSessionStore).toHaveBeenCalledTimes(2); + const infoLines = vi.mocked(api.logger.info).mock.calls.map((call) => String(call[0])); + expect(infoLines.some((line) => line.includes(" cached "))).toBe(false); + }); + + it("clears stale status on skipped non-interactive turns even when agentId is missing", async () => { + const sessionKey = "agent:main:missing-agent"; + hoisted.sessionStore[sessionKey] = { + sessionId: "s-main", + updatedAt: 0, + pluginDebugEntries: [ + { pluginId: "active-memory", lines: ["๐Ÿงฉ Active Memory: timeout 15s recent"] }, + ], + }; + + const result = await hooks.before_prompt_build( + { prompt: "what wings should i order?", messages: [] }, + { trigger: "heartbeat", sessionKey, messageProvider: "webchat" }, + ); + + expect(result).toBeUndefined(); + const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as + | ((store: Record>) => void) + | undefined; + const store = { + [sessionKey]: { + sessionId: "s-main", + updatedAt: 0, + pluginDebugEntries: [ + { pluginId: "active-memory", lines: ["๐Ÿงฉ Active Memory: timeout 15s recent"] }, + ], + }, + } as Record>; + updater?.(store); + expect(store[sessionKey]?.pluginDebugEntries).toBeUndefined(); + }); + + it("supports message mode by sending only the latest user message", async () => { + api.pluginConfig = { + agents: ["main"], + queryMode: "message", + }; + plugin.register(api as unknown as OpenClawPluginApi); + + await hooks.before_prompt_build( + { + prompt: "what should i grab on the way?", + messages: [ + { role: "user", content: "i have a flight tomorrow" }, + { role: "assistant", content: "got it" }, + ], + }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt; + expect(prompt).toContain("Conversation context:\nwhat should i grab on the way?"); + expect(prompt).not.toContain("Recent conversation tail:"); + }); + + it("supports full mode by sending the whole conversation", async () => { + api.pluginConfig = { + agents: ["main"], + queryMode: "full", + }; + plugin.register(api as unknown as OpenClawPluginApi); + + await hooks.before_prompt_build( + { + prompt: "what should i grab on the way?", + messages: [ + { role: "user", content: "i have a flight tomorrow" }, + { role: "assistant", content: "got it" }, + { role: "user", content: "packing is annoying" }, + ], + }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt; + expect(prompt).toContain("Full conversation context:"); + expect(prompt).toContain("user: i have a flight tomorrow"); + expect(prompt).toContain("assistant: got it"); + expect(prompt).toContain("user: packing is annoying"); + }); + + it("keeps sidecar transcripts off disk by default by using a temp session file", async () => { + const mkdtempSpy = vi + .spyOn(fs, "mkdtemp") + .mockResolvedValue("/tmp/openclaw-active-memory-temp"); + const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined); + + await hooks.before_prompt_build( + { prompt: "what wings should i order?", messages: [] }, + { + agentId: "main", + trigger: "user", + sessionKey: "agent:main:main", + messageProvider: "webchat", + }, + ); + + expect(mkdtempSpy).toHaveBeenCalled(); + expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toBe( + "/tmp/openclaw-active-memory-temp/session.jsonl", + ); + expect(rmSpy).toHaveBeenCalledWith("/tmp/openclaw-active-memory-temp", { + recursive: true, + force: true, + }); + }); + + it("persists sidecar transcripts in a separate directory when enabled", async () => { + api.pluginConfig = { + agents: ["main"], + persistTranscripts: true, + transcriptDir: "active-memory-sidecars", + logging: true, + }; + plugin.register(api as unknown as OpenClawPluginApi); + const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined); + const mkdtempSpy = vi.spyOn(fs, "mkdtemp"); + const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined); + + const sessionKey = "agent:main:persist-transcript"; + await hooks.before_prompt_build( + { prompt: "what wings should i order? persist transcript", messages: [] }, + { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, + ); + + expect(mkdirSpy).toHaveBeenCalledWith("/tmp/active-memory-sidecars", { recursive: true }); + expect(mkdtempSpy).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toMatch( + /^\/tmp\/active-memory-sidecars\/active-memory-[a-z0-9]+\.jsonl$/, + ); + expect(rmSpy).not.toHaveBeenCalled(); + expect( + vi + .mocked(api.logger.info) + .mock.calls.some((call) => + String(call[0]).includes("transcript=/tmp/active-memory-sidecars/"), + ), + ).toBe(true); + }); +}); diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts new file mode 100644 index 0000000000..9b4ec4dfc4 --- /dev/null +++ b/extensions/active-memory/index.ts @@ -0,0 +1,931 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + DEFAULT_PROVIDER, + parseModelRef, + resolveAgentDir, + resolveAgentEffectiveModelPrimary, + resolveAgentWorkspaceDir, +} from "openclaw/plugin-sdk/agent-runtime"; +import { resolveSessionStoreEntry, updateSessionStore } from "openclaw/plugin-sdk/config-runtime"; +import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; + +const DEFAULT_TIMEOUT_MS = 8000; +const DEFAULT_MAX_MEMORIES = 2; +const DEFAULT_MAX_MEMORY_CHARS = 180; +const DEFAULT_RECENT_USER_TURNS = 2; +const DEFAULT_RECENT_ASSISTANT_TURNS = 1; +const DEFAULT_RECENT_USER_CHARS = 220; +const DEFAULT_RECENT_ASSISTANT_CHARS = 180; +const DEFAULT_REQUIRE_CONCRETE_RELEVANCE = true; +const DEFAULT_DROP_GENERIC_PREFERENCES = true; +const DEFAULT_CACHE_TTL_MS = 15_000; +const DEFAULT_MODEL_REF = "github-copilot/gpt-5.4-mini"; +const DEFAULT_QUERY_MODE = "recent" as const; +const DEFAULT_TRANSCRIPT_DIR = "active-memory"; + +const NO_RECALL_VALUES = new Set([ + "", + "none", + "no_reply", + "no reply", + "nothing useful", + "no relevant memory", + "no relevant memories", + "timeout", + "[]", + "{}", + "null", + "n/a", +]); + +const STOPWORDS = new Set([ + "a", + "an", + "and", + "are", + "as", + "at", + "be", + "but", + "by", + "do", + "for", + "from", + "get", + "got", + "had", + "has", + "have", + "i", + "if", + "im", + "in", + "into", + "is", + "it", + "its", + "just", + "me", + "my", + "not", + "of", + "on", + "or", + "so", + "that", + "the", + "their", + "them", + "then", + "they", + "this", + "to", + "up", + "was", + "we", + "what", + "when", + "with", + "would", + "you", + "your", +]); + +type ActiveRecallPluginConfig = { + agents?: string[]; + model?: string; + timeoutMs?: number; + queryMode?: "message" | "recent" | "full"; + maxMemories?: number; + maxMemoryChars?: number; + recentUserTurns?: number; + recentAssistantTurns?: number; + recentUserChars?: number; + recentAssistantChars?: number; + logging?: boolean; + requireConcreteRelevance?: boolean; + dropGenericPreferencesOnNonPreferenceTurns?: boolean; + cacheTtlMs?: number; + persistTranscripts?: boolean; + transcriptDir?: string; +}; + +type ResolvedActiveRecallPluginConfig = { + agents: string[]; + model: string; + timeoutMs: number; + queryMode: "message" | "recent" | "full"; + maxMemories: number; + maxMemoryChars: number; + recentUserTurns: number; + recentAssistantTurns: number; + recentUserChars: number; + recentAssistantChars: number; + logging: boolean; + requireConcreteRelevance: boolean; + dropGenericPreferencesOnNonPreferenceTurns: boolean; + cacheTtlMs: number; + persistTranscripts: boolean; + transcriptDir: string; +}; + +type ActiveRecallCandidate = { + text: string; + path?: string; + score?: number; +}; + +type ActiveRecallRecentTurn = { + role: "user" | "assistant"; + text: string; +}; + +type PluginDebugEntry = { + pluginId: string; + lines: string[]; +}; + +type ActiveRecallResult = + | { + status: "empty" | "timeout" | "unavailable"; + elapsedMs: number; + memories: ActiveRecallCandidate[]; + } + | { status: "ok"; elapsedMs: number; rawReply: string; memories: ActiveRecallCandidate[] }; + +type CachedActiveRecallResult = { + expiresAt: number; + result: ActiveRecallResult; +}; + +const ACTIVE_MEMORY_STATUS_PREFIX = "๐Ÿงฉ Active Memory:"; +const ACTIVE_MEMORY_DEBUG_PREFIX = "๐Ÿ”Ž Active Memory Debug:"; + +const activeRecallCache = new Map(); + +function parseOptionalPositiveInt(value: unknown, fallback: number): number { + const parsed = + typeof value === "number" + ? value + : typeof value === "string" + ? Number.parseInt(value, 10) + : Number.NaN; + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function clampInt(value: number | undefined, fallback: number, min: number, max: number): number { + if (!Number.isFinite(value)) { + return fallback; + } + return Math.max(min, Math.min(max, Math.floor(value as number))); +} + +function normalizeTranscriptDir(value: unknown): string { + const raw = typeof value === "string" ? value.trim() : ""; + if (!raw) { + return DEFAULT_TRANSCRIPT_DIR; + } + const normalized = raw.replace(/\\/g, "/"); + const parts = normalized.split("/").map((part) => part.trim()); + const safeParts = parts.filter((part) => part.length > 0 && part !== "." && part !== ".."); + return safeParts.length > 0 ? path.join(...safeParts) : DEFAULT_TRANSCRIPT_DIR; +} + +function normalizePluginConfig(pluginConfig: unknown): ResolvedActiveRecallPluginConfig { + const raw = ( + pluginConfig && typeof pluginConfig === "object" ? pluginConfig : {} + ) as ActiveRecallPluginConfig; + return { + agents: Array.isArray(raw.agents) + ? raw.agents.map((agentId) => String(agentId).trim()).filter(Boolean) + : [], + model: typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : DEFAULT_MODEL_REF, + timeoutMs: clampInt( + parseOptionalPositiveInt(raw.timeoutMs, DEFAULT_TIMEOUT_MS), + DEFAULT_TIMEOUT_MS, + 250, + 60_000, + ), + queryMode: + raw.queryMode === "message" || raw.queryMode === "recent" || raw.queryMode === "full" + ? raw.queryMode + : DEFAULT_QUERY_MODE, + maxMemories: clampInt( + parseOptionalPositiveInt(raw.maxMemories, DEFAULT_MAX_MEMORIES), + DEFAULT_MAX_MEMORIES, + 1, + 5, + ), + maxMemoryChars: clampInt(raw.maxMemoryChars, DEFAULT_MAX_MEMORY_CHARS, 40, 500), + recentUserTurns: clampInt(raw.recentUserTurns, DEFAULT_RECENT_USER_TURNS, 0, 4), + recentAssistantTurns: clampInt(raw.recentAssistantTurns, DEFAULT_RECENT_ASSISTANT_TURNS, 0, 3), + recentUserChars: clampInt(raw.recentUserChars, DEFAULT_RECENT_USER_CHARS, 40, 1000), + recentAssistantChars: clampInt( + raw.recentAssistantChars, + DEFAULT_RECENT_ASSISTANT_CHARS, + 40, + 1000, + ), + logging: raw.logging === true, + requireConcreteRelevance: raw.requireConcreteRelevance ?? DEFAULT_REQUIRE_CONCRETE_RELEVANCE, + dropGenericPreferencesOnNonPreferenceTurns: + raw.dropGenericPreferencesOnNonPreferenceTurns ?? DEFAULT_DROP_GENERIC_PREFERENCES, + cacheTtlMs: clampInt(raw.cacheTtlMs, DEFAULT_CACHE_TTL_MS, 1000, 120_000), + persistTranscripts: raw.persistTranscripts === true, + transcriptDir: normalizeTranscriptDir(raw.transcriptDir), + }; +} + +function isEnabledForAgent( + config: ResolvedActiveRecallPluginConfig, + agentId: string | undefined, +): boolean { + if (!agentId) { + return false; + } + return config.agents.includes(agentId); +} + +function isEligibleInteractiveSession(ctx: { + trigger?: string; + sessionKey?: string; + sessionId?: string; + messageProvider?: string; + channelId?: string; +}): boolean { + if (ctx.trigger !== "user") { + return false; + } + if (!ctx.sessionKey && !ctx.sessionId) { + return false; + } + const provider = (ctx.messageProvider ?? "").trim().toLowerCase(); + if (provider === "webchat") { + return true; + } + return Boolean(ctx.channelId && ctx.channelId.trim()); +} + +function buildCacheKey(params: { agentId: string; sessionKey?: string; query: string }): string { + const hash = crypto.createHash("sha1").update(params.query).digest("hex"); + return `${params.agentId}:${params.sessionKey ?? "none"}:${hash}`; +} + +function getCachedResult(cacheKey: string): ActiveRecallResult | undefined { + const cached = activeRecallCache.get(cacheKey); + if (!cached) { + return undefined; + } + if (cached.expiresAt <= Date.now()) { + activeRecallCache.delete(cacheKey); + return undefined; + } + return cached.result; +} + +function setCachedResult(cacheKey: string, result: ActiveRecallResult, ttlMs: number): void { + activeRecallCache.set(cacheKey, { + expiresAt: Date.now() + ttlMs, + result, + }); +} + +function shouldCacheResult(result: ActiveRecallResult): boolean { + return result.status === "ok" || result.status === "empty"; +} + +function resolveStatusUpdateAgentId( + ctx: { agentId?: string; sessionKey?: string }, +): string { + const explicit = ctx.agentId?.trim(); + if (explicit) { + return explicit; + } + const sessionKey = ctx.sessionKey?.trim(); + if (!sessionKey) { + return ""; + } + const match = /^agent:([^:]+):/i.exec(sessionKey); + return match?.[1]?.trim() ?? ""; +} + +function formatElapsedMsCompact(elapsedMs: number): string { + if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) { + return "0ms"; + } + if (elapsedMs >= 1000) { + const seconds = elapsedMs / 1000; + return `${seconds % 1 === 0 ? seconds.toFixed(0) : seconds.toFixed(1)}s`; + } + return `${Math.round(elapsedMs)}ms`; +} + +function buildPluginStatusLine(params: { + result: ActiveRecallResult; + config: ResolvedActiveRecallPluginConfig; +}): string { + const parts = [ + ACTIVE_MEMORY_STATUS_PREFIX, + params.result.status, + formatElapsedMsCompact(params.result.elapsedMs), + params.config.queryMode, + ]; + if (params.result.status === "ok") { + parts.push(`${params.result.memories.length} mem`); + } + return parts.join(" "); +} + +function buildPluginDebugLine(memories: ActiveRecallCandidate[]): string | null { + const cleaned = memories.map((memory) => memory.text.replace(/\s+/g, " ").trim()).filter(Boolean); + if (cleaned.length === 0) { + return null; + } + return `${ACTIVE_MEMORY_DEBUG_PREFIX} ${cleaned.join("; ")}`; +} + +async function persistPluginStatusLines(params: { + api: OpenClawPluginApi; + agentId: string; + sessionKey?: string; + statusLine?: string; + debugMemories?: ActiveRecallCandidate[]; +}): Promise { + const sessionKey = params.sessionKey?.trim(); + if (!sessionKey || !params.agentId.trim()) { + return; + } + try { + const storePath = params.api.runtime.agent.session.resolveStorePath( + params.api.config.session?.store, + { + agentId: params.agentId, + }, + ); + await updateSessionStore(storePath, (store) => { + const resolved = resolveSessionStoreEntry({ store, sessionKey }); + const existing = resolved.existing; + if (!existing) { + return; + } + const previousEntries = Array.isArray(existing.pluginDebugEntries) + ? existing.pluginDebugEntries + : []; + const nextEntries = previousEntries.filter( + (entry): entry is PluginDebugEntry => + Boolean(entry) && + typeof entry === "object" && + typeof entry.pluginId === "string" && + entry.pluginId !== "active-memory", + ); + const nextLines: string[] = []; + if (params.statusLine) { + nextLines.push(params.statusLine); + } + const debugLine = buildPluginDebugLine(params.debugMemories ?? []); + if (debugLine) { + nextLines.push(debugLine); + } + if (nextLines.length > 0) { + nextEntries.push({ + pluginId: "active-memory", + lines: nextLines, + }); + } + store[resolved.normalizedKey] = { + ...existing, + pluginDebugEntries: nextEntries.length > 0 ? nextEntries : undefined, + }; + }); + } catch (error) { + params.api.logger.debug?.( + `active-memory: failed to persist session status note (${error instanceof Error ? error.message : String(error)})`, + ); + } +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function normalizeNoRecallValue(value: string): boolean { + return NO_RECALL_VALUES.has(value.trim().toLowerCase()); +} + +function extractLatestUserMessage(query: string): string { + const marker = "Latest user message:"; + const idx = query.lastIndexOf(marker); + if (idx >= 0) { + return query.slice(idx + marker.length).trim(); + } + return query.trim(); +} + +function tokenizeMeaningful(text: string): string[] { + return text + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .filter((token) => token.length >= 3 && !STOPWORDS.has(token)); +} + +function isPreferenceSeekingTurn(latestUserMessage: string): boolean { + const text = latestUserMessage.toLowerCase(); + if ( + /\b(what should|should i|which|pick|choose|order|get|grab|buy|listen|watch|drink|eat|want|sounds right|fits me|safe pick|usual|normally|probably|preference|prefer)\b/.test( + text, + ) + ) { + return true; + } + return text.endsWith("?"); +} + +function isGenericPreferenceMemory(memory: string): boolean { + const text = memory.toLowerCase(); + return /\b(prefers|usually|default|safe pick|comfort food|counts|better dip|default coffee order|likes?|dislikes?)\b/.test( + text, + ); +} + +function filterWeakRecallCandidates(params: { + query: string; + candidates: ActiveRecallCandidate[]; + maxMemories: number; + maxMemoryChars: number; + requireConcreteRelevance: boolean; + dropGenericPreferencesOnNonPreferenceTurns: boolean; +}): ActiveRecallCandidate[] { + const latestUserMessage = extractLatestUserMessage(params.query); + const latestTokens = new Set(tokenizeMeaningful(latestUserMessage)); + const preferenceSeeking = isPreferenceSeekingTurn(latestUserMessage); + const filtered = params.candidates.filter((candidate) => { + const candidateTokens = tokenizeMeaningful(candidate.text); + const overlap = candidateTokens.filter((token) => latestTokens.has(token)).length; + if (overlap > 0) { + return true; + } + if ( + params.dropGenericPreferencesOnNonPreferenceTurns && + !preferenceSeeking && + isGenericPreferenceMemory(candidate.text) + ) { + return false; + } + if (params.requireConcreteRelevance) { + return false; + } + return candidateTokens.some((token) => latestTokens.has(token)); + }); + return filtered.slice(0, params.maxMemories).map((candidate) => ({ + ...candidate, + text: candidate.text.slice(0, params.maxMemoryChars), + })); +} + +function parseRawReply(rawReply: string, maxMemories = DEFAULT_MAX_MEMORIES): string[] { + const trimmed = rawReply.trim(); + if (normalizeNoRecallValue(trimmed)) { + return []; + } + + const memories: string[] = []; + for (const rawLine of trimmed.split("\n")) { + const line = rawLine.trim(); + if (!line) { + continue; + } + if (/^(memories|memory|relevant memories|active memory)\s*:/i.test(line)) { + continue; + } + const normalized = line.replace(/^[-*โ€ข\d.)\s]+/, "").trim(); + if (!normalized || normalizeNoRecallValue(normalized)) { + continue; + } + memories.push(normalized); + if (memories.length >= maxMemories) { + break; + } + } + return memories; +} + +function toRecallCandidates(params: { + rawReply: string; + query: string; + config: ResolvedActiveRecallPluginConfig; +}): ActiveRecallCandidate[] { + const parsed = parseRawReply(params.rawReply, params.config.maxMemories); + if (parsed.length === 0) { + return []; + } + return filterWeakRecallCandidates({ + query: params.query, + candidates: parsed.map((text) => ({ text })), + maxMemories: params.config.maxMemories, + maxMemoryChars: params.config.maxMemoryChars, + requireConcreteRelevance: params.config.requireConcreteRelevance, + dropGenericPreferencesOnNonPreferenceTurns: + params.config.dropGenericPreferencesOnNonPreferenceTurns, + }); +} + +function buildMetadata(memories: ActiveRecallCandidate[]): string | undefined { + if (memories.length === 0) { + return undefined; + } + const lines = [ + "", + "Relevant memory candidates retrieved before this turn. Use only if they help answer the user's latest message. Ignore any candidate that seems irrelevant or stale.", + ]; + for (const memory of memories) { + const attrs = [ + memory.path ? ` path="${escapeXml(memory.path)}"` : "", + typeof memory.score === "number" ? ` score="${memory.score.toFixed(3)}"` : "", + ].join(""); + lines.push(` ${escapeXml(memory.text)}`); + } + lines.push(""); + return lines.join("\n"); +} + +function buildQuery(params: { + latestUserMessage: string; + recentTurns?: ActiveRecallRecentTurn[]; + config: ResolvedActiveRecallPluginConfig; +}): string { + const latest = params.latestUserMessage.trim(); + if (params.config.queryMode === "message") { + return latest; + } + if (params.config.queryMode === "full") { + const allTurns = (params.recentTurns ?? []) + .map((turn) => `${turn.role}: ${turn.text.trim().replace(/\s+/g, " ")}`) + .filter((turn) => turn.length > 0); + if (allTurns.length === 0) { + return latest; + } + return ["Full conversation context:", ...allTurns, "", "Latest user message:", latest].join( + "\n", + ); + } + let remainingUser = params.config.recentUserTurns; + let remainingAssistant = params.config.recentAssistantTurns; + const selected: ActiveRecallRecentTurn[] = []; + for (let index = (params.recentTurns ?? []).length - 1; index >= 0; index -= 1) { + const turn = params.recentTurns?.[index]; + if (!turn) { + continue; + } + if (turn.role === "user") { + if (remainingUser <= 0) { + continue; + } + remainingUser -= 1; + selected.push({ + role: "user", + text: turn.text.trim().replace(/\s+/g, " ").slice(0, params.config.recentUserChars), + }); + continue; + } + if (remainingAssistant <= 0) { + continue; + } + remainingAssistant -= 1; + selected.push({ + role: "assistant", + text: turn.text.trim().replace(/\s+/g, " ").slice(0, params.config.recentAssistantChars), + }); + } + const recentTurns = selected.toReversed().filter((turn) => turn.text.length > 0); + if (recentTurns.length === 0) { + return latest; + } + return [ + "Recent conversation tail:", + ...recentTurns.map((turn) => `${turn.role}: ${turn.text}`), + "", + "Latest user message:", + latest, + ].join("\n"); +} + +function extractTextContent(content: unknown): string { + if (typeof content === "string") { + return content; + } + if (!Array.isArray(content)) { + return ""; + } + const parts: string[] = []; + for (const item of content) { + if (typeof item === "string") { + parts.push(item); + continue; + } + if (!item || typeof item !== "object") { + continue; + } + const typed = item as { type?: unknown; text?: unknown; content?: unknown }; + if (typeof typed.text === "string") { + parts.push(typed.text); + continue; + } + if (typed.type === "text" && typeof typed.content === "string") { + parts.push(typed.content); + } + } + return parts.join(" ").trim(); +} + +function extractRecentTurns(messages: unknown[]): ActiveRecallRecentTurn[] { + const turns: ActiveRecallRecentTurn[] = []; + for (const message of messages) { + if (!message || typeof message !== "object") { + continue; + } + const typed = message as { role?: unknown; content?: unknown }; + const role = typed.role === "user" || typed.role === "assistant" ? typed.role : undefined; + if (!role) { + continue; + } + const text = extractTextContent(typed.content); + if (!text) { + continue; + } + turns.push({ role, text }); + } + return turns; +} + +function getModelRef( + api: OpenClawPluginApi, + agentId: string, + config: ResolvedActiveRecallPluginConfig, +) { + const configured = config.model || resolveAgentEffectiveModelPrimary(api.config, agentId) || DEFAULT_MODEL_REF; + const parsed = parseModelRef(configured, DEFAULT_PROVIDER); + if (parsed) { + return parsed; + } + return ( + parseModelRef(resolveAgentEffectiveModelPrimary(api.config, agentId), DEFAULT_PROVIDER) ?? { + provider: DEFAULT_PROVIDER, + model: configured, + } + ); +} + +async function runRecallSidecar(params: { + api: OpenClawPluginApi; + config: ResolvedActiveRecallPluginConfig; + agentId: string; + sessionKey?: string; + query: string; +}): Promise<{ rawReply: string; transcriptPath?: string }> { + const workspaceDir = resolveAgentWorkspaceDir(params.api.config, params.agentId); + const agentDir = resolveAgentDir(params.api.config, params.agentId); + const modelRef = getModelRef(params.api, params.agentId, params.config); + const sidecarSessionId = `active-memory-${Date.now().toString(36)}`; + const sidecarSessionKey = `active-memory:${params.agentId}:${crypto + .createHash("sha1") + .update(`${params.sessionKey ?? "none"}:${params.query}`) + .digest("hex") + .slice(0, 12)}`; + const storePath = params.api.runtime.agent.session.resolveStorePath( + params.api.config.session?.store, + { + agentId: params.agentId, + }, + ); + const resolvedStorePath = storePath || path.join(os.tmpdir(), "openclaw-active-memory-sessions.json"); + const baseSessionsDir = path.dirname(path.resolve(resolvedStorePath)); + const tempDir = params.config.persistTranscripts + ? undefined + : await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-active-memory-")); + const persistedDir = params.config.persistTranscripts + ? path.join(baseSessionsDir, params.config.transcriptDir) + : undefined; + if (persistedDir) { + await fs.mkdir(persistedDir, { recursive: true }); + } + const sessionFile = params.config.persistTranscripts + ? path.join(persistedDir!, `${sidecarSessionId}.jsonl`) + : path.join(tempDir!, "session.jsonl"); + const prompt = [ + "You are Active Memory, a fast sidecar memory model.", + "Use only memory_search and memory_get.", + "Search for memories relevant to the user's latest message.", + "Return memories only if they would concretely change or personalize the answer.", + "If the connection is weak, broad, or only vaguely related, reply with NONE.", + "Do not return generic lifestyle or food preferences unless the latest user message is clearly asking for a choice, recommendation, habit, or preference-sensitive answer.", + "If nothing seems strongly useful, reply with NONE.", + "If something is useful, reply with up to 3 short bullet points only.", + "Do not answer the user directly.", + "Do not explain your reasoning.", + "", + "Conversation context:", + params.query, + ].join("\n"); + + try { + const result = await params.api.runtime.agent.runEmbeddedPiAgent({ + sessionId: sidecarSessionId, + sessionKey: sidecarSessionKey, + agentId: params.agentId, + sessionFile, + workspaceDir, + agentDir, + config: params.api.config, + prompt, + provider: modelRef.provider, + model: modelRef.model, + timeoutMs: params.config.timeoutMs, + runId: sidecarSessionId, + trigger: "manual", + toolsAllow: ["memory_search", "memory_get"], + disableMessageTool: true, + bootstrapContextMode: "lightweight", + verboseLevel: "off", + thinkLevel: "off", + reasoningLevel: "off", + silentExpected: true, + }); + const rawReply = (result.payloads ?? []) + .map((payload) => payload.text?.trim() ?? "") + .filter(Boolean) + .join("\n") + .trim(); + return { + rawReply: rawReply || "NONE", + transcriptPath: params.config.persistTranscripts ? sessionFile : undefined, + }; + } finally { + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } + } +} + +async function maybeResolveActiveRecall(params: { + api: OpenClawPluginApi; + config: ResolvedActiveRecallPluginConfig; + agentId: string; + sessionKey?: string; + query: string; +}): Promise { + const startedAt = Date.now(); + const cacheKey = buildCacheKey({ + agentId: params.agentId, + sessionKey: params.sessionKey, + query: params.query, + }); + const cached = getCachedResult(cacheKey); + const logPrefix = `active-memory: agent=${params.agentId} session=${params.sessionKey ?? "none"}`; + if (cached) { + await persistPluginStatusLines({ + api: params.api, + agentId: params.agentId, + sessionKey: params.sessionKey, + statusLine: `${buildPluginStatusLine({ result: cached, config: params.config })} cached`, + debugMemories: cached.memories, + }); + if (params.config.logging) { + params.api.logger.info?.( + `${logPrefix} cached status=${cached.status} memories=${String(cached.memories.length)} queryChars=${String(params.query.length)}`, + ); + } + return cached; + } + + if (params.config.logging) { + params.api.logger.info?.( + `${logPrefix} start timeoutMs=${String(params.config.timeoutMs)} queryChars=${String(params.query.length)}`, + ); + } + + try { + const recallPromise = runRecallSidecar(params).then(({ rawReply, transcriptPath }) => { + const memories = toRecallCandidates({ + rawReply, + query: params.query, + config: params.config, + }); + if (params.config.logging && transcriptPath) { + params.api.logger.info?.(`${logPrefix} transcript=${transcriptPath}`); + } + return { + status: memories.length > 0 ? ("ok" as const) : ("empty" as const), + elapsedMs: Date.now() - startedAt, + rawReply, + memories, + } satisfies ActiveRecallResult; + }); + + const result = await Promise.race([ + recallPromise, + new Promise((resolve) => + setTimeout( + () => + resolve({ + status: "timeout", + elapsedMs: Date.now() - startedAt, + memories: [], + }), + params.config.timeoutMs, + ), + ), + ]); + if (params.config.logging) { + params.api.logger.info?.( + `${logPrefix} done status=${result.status} elapsedMs=${String(result.elapsedMs)} memories=${String(result.memories.length)}`, + ); + } + await persistPluginStatusLines({ + api: params.api, + agentId: params.agentId, + sessionKey: params.sessionKey, + statusLine: buildPluginStatusLine({ result, config: params.config }), + debugMemories: result.memories, + }); + if (shouldCacheResult(result)) { + setCachedResult(cacheKey, result, params.config.cacheTtlMs); + } + return result; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (params.config.logging) { + params.api.logger.warn?.(`${logPrefix} failed error=${message}`); + } + const result: ActiveRecallResult = { + status: "unavailable", + elapsedMs: Date.now() - startedAt, + memories: [], + }; + await persistPluginStatusLines({ + api: params.api, + agentId: params.agentId, + sessionKey: params.sessionKey, + statusLine: buildPluginStatusLine({ result, config: params.config }), + }); + return result; + } +} + +export default definePluginEntry({ + id: "active-memory", + name: "Active Memory", + description: "Proactively surfaces relevant memory before eligible conversational replies.", + register(api: OpenClawPluginApi) { + const config = normalizePluginConfig(api.pluginConfig); + api.on("before_prompt_build", async (event, ctx) => { + const effectiveAgentId = resolveStatusUpdateAgentId(ctx); + if (!isEnabledForAgent(config, effectiveAgentId)) { + await persistPluginStatusLines({ + api, + agentId: effectiveAgentId, + sessionKey: ctx.sessionKey, + }); + return; + } + if (!isEligibleInteractiveSession(ctx)) { + await persistPluginStatusLines({ + api, + agentId: effectiveAgentId, + sessionKey: ctx.sessionKey, + }); + return; + } + const query = buildQuery({ + latestUserMessage: event.prompt, + recentTurns: extractRecentTurns(event.messages), + config, + }); + const result = await maybeResolveActiveRecall({ + api, + config, + agentId: effectiveAgentId, + sessionKey: ctx.sessionKey, + query, + }); + if (result.memories.length === 0) { + return; + } + const metadata = buildMetadata(result.memories); + if (!metadata) { + return; + } + return { + appendSystemContext: metadata, + }; + }); + }, +}); diff --git a/extensions/active-memory/openclaw.plugin.json b/extensions/active-memory/openclaw.plugin.json new file mode 100644 index 0000000000..3897e4a67e --- /dev/null +++ b/extensions/active-memory/openclaw.plugin.json @@ -0,0 +1,62 @@ +{ + "id": "active-memory", + "name": "Active Memory", + "description": "Runs a bounded memory sidecar before eligible conversational replies and injects relevant memory into prompt context.", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "agents": { + "type": "array", + "items": { "type": "string" } + }, + "model": { "type": "string" }, + "timeoutMs": { "type": "integer", "minimum": 250 }, + "queryMode": { + "type": "string", + "enum": ["message", "recent", "full"] + }, + "maxMemories": { "type": "integer", "minimum": 1, "maximum": 5 }, + "maxMemoryChars": { "type": "integer", "minimum": 40, "maximum": 500 }, + "recentUserTurns": { "type": "integer", "minimum": 0, "maximum": 4 }, + "recentAssistantTurns": { "type": "integer", "minimum": 0, "maximum": 3 }, + "recentUserChars": { "type": "integer", "minimum": 40, "maximum": 1000 }, + "recentAssistantChars": { "type": "integer", "minimum": 40, "maximum": 1000 }, + "logging": { "type": "boolean" }, + "persistTranscripts": { "type": "boolean" }, + "transcriptDir": { "type": "string" }, + "requireConcreteRelevance": { "type": "boolean" }, + "dropGenericPreferencesOnNonPreferenceTurns": { "type": "boolean" }, + "cacheTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 } + } + }, + "uiHints": { + "agents": { + "label": "Target Agents", + "help": "Explicit agent ids that may use active memory." + }, + "model": { + "label": "Memory Model", + "help": "Provider/model used for the memory sidecar." + }, + "timeoutMs": { + "label": "Timeout (ms)" + }, + "queryMode": { + "label": "Query Mode", + "help": "Choose whether the sidecar sees only the latest user message, a small recent tail, or the full conversation." + }, + "logging": { + "label": "Enable Logging", + "help": "Emit active memory timing and result logs." + }, + "persistTranscripts": { + "label": "Persist Transcripts", + "help": "Keep sidecar session transcripts on disk in a separate plugin-owned directory." + }, + "transcriptDir": { + "label": "Transcript Directory", + "help": "Relative directory under the agent sessions folder used when transcript persistence is enabled." + } + } +} diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts index 47c0de1d64..9526f7fdf8 100644 --- a/src/auto-reply/commands-registry.shared.ts +++ b/src/auto-reply/commands-registry.shared.ts @@ -832,7 +832,6 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { registerAlias(commands, "reasoning", "/reason"); registerAlias(commands, "elevated", "/elev"); registerAlias(commands, "steer", "/tell"); - assertCommandRegistry(commands); return commands; } diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index 0257554831..00a021d056 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -989,6 +989,117 @@ describe("runReplyAgent block streaming", () => { }); }); +describe("runReplyAgent Active Memory inline debug", () => { + it("appends inline Active Memory debug payload when verbose is enabled", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-active-memory-inline-")); + const storePath = path.join(tmp, "sessions.json"); + const sessionKey = "main"; + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + }; + + await fs.writeFile( + storePath, + JSON.stringify( + { + [sessionKey]: sessionEntry, + }, + null, + 2, + ), + "utf-8", + ); + + runEmbeddedPiAgentMock.mockImplementationOnce(async () => { + const latest = loadSessionStore(storePath, { skipCache: true }); + latest[sessionKey] = { + ...latest[sessionKey], + pluginDebugEntries: [ + { + pluginId: "active-memory", + lines: [ + "๐Ÿงฉ Active Memory: ok 842ms recent 2 mem", + "๐Ÿ”Ž Active Memory Debug: lemon pepper wings; blue cheese", + ], + }, + ], + }; + await saveSessionStore(storePath, latest); + return { + payloads: [{ text: "Normal reply" }], + meta: {}, + }; + }); + + const typing = createMockTypingController(); + const sessionCtx = { + Provider: "telegram", + OriginatingTo: "chat:1", + AccountId: "primary", + MessageSid: "msg", + } as unknown as TemplateContext; + const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; + const followupRun = { + prompt: "hello", + summaryLine: "hello", + enqueuedAt: Date.now(), + run: { + agentId: "main", + sessionId: "session", + sessionKey, + messageProvider: "telegram", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + config: {}, + skillsSnapshot: {}, + provider: "anthropic", + model: "claude", + thinkLevel: "low", + verboseLevel: "on", + elevatedLevel: "off", + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + timeoutMs: 1_000, + blockReplyBreak: "message_end", + }, + } as unknown as FollowupRun; + + const result = await runReplyAgent({ + commandBody: "hello", + followupRun, + queueKey: sessionKey, + resolvedQueue, + shouldSteer: false, + shouldFollowup: false, + isActive: false, + isStreaming: false, + typing, + sessionCtx, + sessionEntry, + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, + storePath, + defaultModel: "anthropic/claude-opus-4-6", + resolvedVerboseLevel: "on", + isNewSession: false, + blockStreamingEnabled: false, + resolvedBlockStreamingBreak: "message_end", + shouldInjectGroupIntro: false, + typingMode: "instant", + }); + + expect(Array.isArray(result)).toBe(true); + expect((result as { text?: string }[]).map((payload) => payload.text)).toEqual([ + "Normal reply", + "๐Ÿงฉ Active Memory: ok 842ms recent 2 mem\n๐Ÿ”Ž Active Memory Debug: lemon pepper wings; blue cheese", + ]); + }); +}); + describe("runReplyAgent claude-cli routing", () => { function createRun() { const typing = createMockTypingController(); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index dfa9247491..958de47a35 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -6,10 +6,12 @@ import { isCliProvider } from "../../agents/model-selection.js"; import { queueEmbeddedPiMessage } from "../../agents/pi-embedded.js"; import { hasNonzeroUsage } from "../../agents/usage.js"; import { + loadSessionStore, resolveAgentIdFromSessionKey, resolveSessionFilePath, resolveSessionFilePathOptions, resolveSessionTranscriptPath, + resolveSessionPluginDebugLines, type SessionEntry, updateSessionStore, updateSessionStoreEntry, @@ -74,6 +76,39 @@ import type { TypingController } from "./typing.js"; const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000; +function buildInlinePluginStatusPayload(entry: SessionEntry | undefined): ReplyPayload | undefined { + const lines = resolveSessionPluginDebugLines(entry); + if (lines.length === 0) { + return undefined; + } + return { text: lines.join("\n") }; +} + +function refreshSessionEntryFromStore(params: { + storePath?: string; + sessionKey?: string; + fallbackEntry?: SessionEntry; + activeSessionStore?: Record; +}): SessionEntry | undefined { + const { storePath, sessionKey, fallbackEntry, activeSessionStore } = params; + if (!storePath || !sessionKey) { + return fallbackEntry; + } + try { + const latestStore = loadSessionStore(storePath, { skipCache: true }); + const latestEntry = latestStore?.[sessionKey]; + if (!latestEntry) { + return fallbackEntry; + } + if (activeSessionStore) { + activeSessionStore[sessionKey] = latestEntry; + } + return latestEntry; + } catch { + return fallbackEntry; + } +} + export async function runReplyAgent(params: { commandBody: string; followupRun: FollowupRun; @@ -713,6 +748,13 @@ export async function runReplyAgent(params: { } } + activeSessionEntry = refreshSessionEntryFromStore({ + storePath, + sessionKey, + fallbackEntry: activeSessionEntry, + activeSessionStore, + }); + // If verbose is enabled, prepend operational run notices. let finalPayloads = guardedReplyPayloads; const verboseNotices: ReplyPayload[] = []; @@ -822,6 +864,12 @@ export async function runReplyAgent(params: { if (verboseNotices.length > 0) { finalPayloads = [...verboseNotices, ...finalPayloads]; } + if (verboseEnabled) { + const pluginStatusPayload = buildInlinePluginStatusPayload(activeSessionEntry); + if (pluginStatusPayload) { + finalPayloads = [...finalPayloads, pluginStatusPayload]; + } + } if (responseUsageLine) { finalPayloads = appendUsageLine(finalPayloads, responseUsageLine); } diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 348a212c8d..f1bf2ae135 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -115,6 +115,46 @@ describe("buildStatusMessage", () => { expect(normalized).toContain("Reasoning: on"); }); + it("shows plugin status lines only when verbose is enabled", () => { + const visible = normalizeTestText( + buildStatusMessage({ + agent: { + model: "anthropic/pi:opus", + }, + sessionEntry: { + sessionId: "abc", + updatedAt: 0, + verboseLevel: "on", + pluginDebugEntries: [ + { pluginId: "active-memory", lines: ["๐Ÿงฉ Active Memory: timeout 15s recent"] }, + ], + }, + sessionKey: "agent:main:main", + queue: { mode: "collect", depth: 0 }, + }), + ); + const hidden = normalizeTestText( + buildStatusMessage({ + agent: { + model: "anthropic/pi:opus", + }, + sessionEntry: { + sessionId: "abc", + updatedAt: 0, + verboseLevel: "off", + pluginDebugEntries: [ + { pluginId: "active-memory", lines: ["๐Ÿงฉ Active Memory: timeout 15s recent"] }, + ], + }, + sessionKey: "agent:main:main", + queue: { mode: "collect", depth: 0 }, + }), + ); + + expect(visible).toContain("Active Memory: timeout 15s recent"); + expect(hidden).not.toContain("Active Memory: timeout 15s recent"); + }); + it("shows fast mode when enabled", () => { const text = buildStatusMessage({ agent: { diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 720f2ea89d..7a09e2ec9b 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -20,6 +20,7 @@ import { isCommandFlagEnabled } from "../config/commands.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveMainSessionKey, + resolveSessionPluginDebugLines, resolveSessionFilePath, resolveSessionFilePathOptions, type SessionEntry, @@ -674,6 +675,8 @@ export function buildStatusMessage(args: StatusArgs): string { const queueDetails = formatQueueDetails(args.queue); const verboseLabel = verboseLevel === "full" ? "verbose:full" : verboseLevel === "on" ? "verbose" : null; + const pluginDebugLines = verboseLevel !== "off" ? resolveSessionPluginDebugLines(entry) : []; + const pluginStatusLine = pluginDebugLines.length > 0 ? pluginDebugLines.join(" ยท ") : null; const elevatedLabel = elevatedLevel && elevatedLevel !== "off" ? elevatedLevel === "on" @@ -817,6 +820,7 @@ export function buildStatusMessage(args: StatusArgs): string { args.subagentsLine, args.taskLine, `โš™๏ธ ${optionsLine}`, + pluginStatusLine ? `๐Ÿงฉ ${pluginStatusLine}` : null, voiceLine, activationLine, ] diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 140f8f05aa..d1b80e1f68 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -103,6 +103,11 @@ export type SessionCompactionCheckpoint = { postCompaction: SessionCompactionTranscriptReference; }; +export type SessionPluginDebugEntry = { + pluginId: string; + lines: string[]; +}; + export type SessionEntry = { /** * Last delivered heartbeat payload (used to suppress duplicate heartbeat notifications). @@ -232,9 +237,41 @@ export type SessionEntry = { lastThreadId?: string | number; skillsSnapshot?: SessionSkillSnapshot; systemPromptReport?: SessionSystemPromptReport; + /** + * Generic plugin-owned runtime debug entries shown in verbose status surfaces. + * Each plugin owns and may overwrite only its own entry between turns. + */ + pluginDebugEntries?: SessionPluginDebugEntry[]; + /** + * Legacy flat plugin debug lines. + * Prefer `pluginDebugEntries` for new writes. + */ + pluginStatusLines?: string[]; acp?: SessionAcpMeta; }; +export function resolveSessionPluginDebugLines( + entry: Pick | undefined, +): string[] { + const structured = Array.isArray(entry?.pluginDebugEntries) + ? entry.pluginDebugEntries.flatMap((pluginEntry) => + Array.isArray(pluginEntry?.lines) + ? pluginEntry.lines.filter( + (line): line is string => typeof line === "string" && line.trim().length > 0, + ) + : [], + ) + : []; + if (structured.length > 0) { + return structured; + } + return Array.isArray(entry?.pluginStatusLines) + ? entry.pluginStatusLines.filter( + (line): line is string => typeof line === "string" && line.trim().length > 0, + ) + : []; +} + export function normalizeSessionRuntimeModelFields(entry: SessionEntry): SessionEntry { const normalizedModel = normalizeOptionalString(entry.model); const normalizedProvider = normalizeOptionalString(entry.modelProvider);