From d425e93cb473ae394af0bfe320cd89eb9bd34f09 Mon Sep 17 00:00:00 2001 From: Tian Z <13511170+mysteriousHerb@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:55:16 +0100 Subject: [PATCH] feat(openclaw): v2 recall/retention controls, scalability fixes, and Gemini safety settings (#480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(openclaw): squash branch updates for fork PR * revert(api): drop memory_engine query normalization from this PR * fix(openclaw): harden hook isolation and sanitize recall logging * chore(openclaw): gate missing-senderId notice behind debug logger * fix(openclaw): address remaining PR review follow-ups * fix(openclaw): address upstream review comments on isolation and tests * feat(openclaw): prepend current timestamp to recalled memory context * chore(openclaw): sync package-lock version to 0.4.14 * chore(openclaw): format recall timestamp as yyyy-mm-dd HH:MM * feat(openclaw): add configurable recall context composition - Add recallRoles config to filter which message roles are included in recall query context - Add recallContextTurns to control how many user turns of prior context to include - Add recallMaxQueryChars to cap composed query length - Reduce default max_tokens from 2048 to 1024 for recall responses - Update documentation and plugin schema with new configuration options Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(openclaw): put latest user message at end of recall query, add debug to schema - Reorder composed recall query so latest user message is at the bottom, giving embedding models the most weight where it matters most - Update truncateRecallQuery to trim oldest context lines first, always preserving the suffix (priority instruction + latest message) - Add debug flag to openclaw.plugin.json schema - Update tests to reflect new query order Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(openclaw): add verbose debug logging for recall/retain - Log full recall query (not just first 50 chars) - Log all raw recall results with scores and content before topK trimming - Log retain transcript preview and document ID Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(openclaw): strip sender metadata envelope from prior context in recall query Prior context messages passed to composeRecallQuery contained raw OpenClaw envelope blocks (Sender/untrusted metadata JSON) which were diluting the semantic signal of the recall query. Strip them the same way extractRecallQuery already does for the latest message. Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(openclaw): add debug log for event.messages at recall time Helps diagnose why recallContextTurns > 1 may not show extra context by logging message count and roles available in event.messages. Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(openclaw): strip sender metadata envelope from rawMessage before recall query extraction The rawMessage from Telegram group chats arrives wrapped in a: --- Sender (untrusted metadata): ```json {...}``` --- envelope. This wasn't being stripped before extractRecallQuery used it, so the full envelope including JSON metadata was being sent as the recall query, severely diluting semantic relevance. Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(openclaw): warn when recallContextTurns > 1 but event.messages is empty Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(openclaw): read messages from event.context.sessionEntry.messages for recall and retain event.messages was always empty — the actual conversation history is at event.context.sessionEntry.messages. Fall back to event.messages for backwards compatibility. This fixes recallContextTurns and retain both being unable to see the conversation history. Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(openclaw): extract stripMetadataEnvelopes helper and apply to retain path - Add shared stripMetadataEnvelopes() to strip OpenClaw sender/conversation metadata blocks from message content in all paths (recall query extraction, prior context composition, and retain transcript) - This prevents metadata-polluted memories (name/sender ID facts) from being stored and ensures recall queries contain clean user text only Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(openclaw): strip metadata envelopes after channel envelope extraction too The prompt format is: [ChannelName ...]\n\n After extracting content after [ChannelName], the metadata envelope was still present. Now stripMetadataEnvelopes runs again after the channel envelope extraction step. Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(openclaw): switch recall hook from before_agent_start to before_prompt_build before_prompt_build runs after session load and has messages available, enabling recallContextTurns to work correctly. before_agent_start runs pre-session with no messages. Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(openclaw): move current time inside memory tag, simplify recall query format - Move "Current time" line inside so it's not exposed to the recall search as part of the query context - Remove RECALL_QUERY_PRIORITY_INSTRUCTION and "Latest user message:" label from composed recall query — the raw message is more effective for semantic search without the extra prompt noise Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(openclaw): address PR review comments on bank ID fallback and memory leaks - Add early return in deriveBankId when ctx is undefined, falling back to static default bank instead of generating a placeholder-filled ID - Remove unused RECALL_QUERY_PRIORITY_INSTRUCTION dead constant - Evict from banksWithMissionSet when evicting from clientsByBankId to prevent unbounded memory growth in long-running instances - Fix integration test hook name: before_agent_start → before_prompt_build - Fix integration test assertions to match actual composeRecallQuery output Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(openclaw): extract sender ID from inbound metadata blocks for bank ID derivation Agent-phase hooks (before_prompt_build, agent_end) don't carry senderId in ctx by design. Parse it from the "Conversation info / Sender (untrusted metadata)" JSON blocks that OpenClaw injects into the prompt/messages instead. - Add extractSenderIdFromText() helper that scans all metadata blocks and returns the first sender_id / id field found - before_prompt_build: extract from event.prompt/rawMessage, spread into ctx before calling deriveBankId and getClientForContext - agent_end: scan user messages for the metadata block, spread into effectiveCtx before calling deriveBankId and getClientForContext - Gracefully skipped when senderId is already present in ctx Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(openclaw): scan messages from end for sender ID to handle group chats When multiple users have spoken in a session, scanning from the front returns the first sender in history rather than the one who triggered the current agent run. Reverse the slice before finding so we always pick the most recent user message's sender ID. Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(openclaw): use event.messages for sender ID in agent_end, not sessionEntry sessionEntry.messages is the cleaned-up history without OpenClaw's injected metadata prefix blocks. event.messages is the raw payload that still contains the "Conversation info (untrusted metadata)" JSON — so parse sender_id from there instead. Also removes the unnecessary senderIdBySession cache added in the previous attempt, since event.messages has everything needed directly. Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(openclaw): cache sender ID from before_prompt_build for use in agent_end event.prompt in before_prompt_build contains OpenClaw's injected metadata blocks with sender_id. event.messages in agent_end is clean history without them — so parsing messages in agent_end never finds a sender ID. Fix: cache the resolved sender ID (keyed by sessionKey) when it's extracted in before_prompt_build, then look it up by sessionKey in agent_end. Co-Authored-By: Claude Sonnet 4.6 (1M context) * docs(openclaw): revert Auto-Recall token count to 1024 as unchanged from main Co-Authored-By: Claude Sonnet 4.6 (1M context) * fix(openclaw): revert recallMaxTokens default from 2048 to 1024 to match main Co-Authored-By: Claude Sonnet 4.6 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 (1M context) --- .../docs/sdks/integrations/openclaw.md | 65 +- .../version-0.4/sdks/integrations/openclaw.md | 65 +- hindsight-integrations/openclaw/README.md | 41 + .../openclaw/openclaw.plugin.json | 180 ++++- .../openclaw/src/client.test.ts | 14 +- hindsight-integrations/openclaw/src/client.ts | 14 +- .../openclaw/src/derive-bank-id.test.ts | 113 +++ .../openclaw/src/index.test.ts | 286 ++++++- hindsight-integrations/openclaw/src/index.ts | 701 ++++++++++++++---- .../openclaw/src/remote-no-llm.test.ts | 22 + hindsight-integrations/openclaw/src/types.ts | 25 +- .../openclaw/tests/hooks.integration.test.ts | 55 +- 12 files changed, 1409 insertions(+), 172 deletions(-) create mode 100644 hindsight-integrations/openclaw/src/derive-bank-id.test.ts create mode 100644 hindsight-integrations/openclaw/src/remote-no-llm.test.ts diff --git a/hindsight-docs/docs/sdks/integrations/openclaw.md b/hindsight-docs/docs/sdks/integrations/openclaw.md index 86a4f0b9..cd894c7d 100644 --- a/hindsight-docs/docs/sdks/integrations/openclaw.md +++ b/hindsight-docs/docs/sdks/integrations/openclaw.md @@ -90,7 +90,70 @@ Optional settings in `~/.openclaw/openclaw.json`: - `apiPort` - Port for the openclaw profile daemon (default: `9077`) - `daemonIdleTimeout` - Seconds before daemon shuts down from inactivity (default: `0` = never) - `embedVersion` - hindsight-embed version (default: `"latest"`) -- `bankMission` - Custom context for the memory bank (optional) +- `bankMission` - Agent identity/purpose stored on the memory bank. Helps the memory engine understand context for better fact extraction during retain. Set once per bank on first use — not a recall prompt. +- `dynamicBankId` - Enable per-context memory banks (default: `true`) +- `bankIdPrefix` - Optional prefix for bank IDs (e.g. `"prod"` → `"prod-slack-C123"`) +- `dynamicBankGranularity` - Fields used to derive bank ID: `agent`, `channel`, `user`, `provider` (default: `["agent", "channel", "user"]`) +- `excludeProviders` - Message providers to skip for recall/retain (e.g. `["slack"]`, `["telegram"]`, `["discord"]`) +- `autoRecall` - Auto-inject memories before each turn (default: `true`). Set to `false` when the agent has its own recall tool. +- `autoRetain` - Auto-retain conversations after each turn (default: `true`) +- `retainRoles` - Which message roles to retain (default: `["user", "assistant"]`). Options: `user`, `assistant`, `system`, `tool` +- `recallBudget` - Recall effort: `"low"`, `"mid"`, or `"high"` (default: `"mid"`). Higher budgets use more retrieval strategies for better results. +- `recallMaxTokens` - Max tokens for recall response (default: `1024`). Controls how much memory context is injected per turn. + +### Memory Isolation + +The plugin creates separate memory banks based on conversation context. By default, banks are derived from the `agent`, `channel`, and `user` fields — so each unique combination gets its own isolated memory store. + +You can customize which fields are used for bank segmentation with `dynamicBankGranularity`: + +```json +{ + "plugins": { + "entries": { + "hindsight-openclaw": { + "enabled": true, + "config": { + "dynamicBankGranularity": ["provider", "user"] + } + } + } + } +} +``` + +In this example, memories are isolated per provider + user, meaning the same user shares memories across all channels within a provider. + +Available isolation fields: +- `agent` - The agent/bot identity +- `channel` - The channel or conversation ID +- `user` - The user interacting with the agent +- `provider` - The message provider (e.g. Slack, Discord) + +Use `bankIdPrefix` to namespace bank IDs across environments (e.g. `"prod"`, `"staging"`). Set `dynamicBankId` to `false` to use a single shared bank for all conversations. + +### Retention Controls + +By default, the plugin retains `user` and `assistant` messages after each turn. You can customize this behavior: + +```json +{ + "plugins": { + "entries": { + "hindsight-openclaw": { + "enabled": true, + "config": { + "autoRetain": true, + "retainRoles": ["user", "assistant", "system"] + } + } + } + } +} +``` + +- `autoRetain` - Set to `false` to disable automatic retention entirely (useful if you handle retention yourself) +- `retainRoles` - Controls which message roles are included in the retained transcript. Only messages from the last user message onward are retained each turn, preventing duplicate storage. ### LLM Configuration diff --git a/hindsight-docs/versioned_docs/version-0.4/sdks/integrations/openclaw.md b/hindsight-docs/versioned_docs/version-0.4/sdks/integrations/openclaw.md index 86a4f0b9..cd894c7d 100644 --- a/hindsight-docs/versioned_docs/version-0.4/sdks/integrations/openclaw.md +++ b/hindsight-docs/versioned_docs/version-0.4/sdks/integrations/openclaw.md @@ -90,7 +90,70 @@ Optional settings in `~/.openclaw/openclaw.json`: - `apiPort` - Port for the openclaw profile daemon (default: `9077`) - `daemonIdleTimeout` - Seconds before daemon shuts down from inactivity (default: `0` = never) - `embedVersion` - hindsight-embed version (default: `"latest"`) -- `bankMission` - Custom context for the memory bank (optional) +- `bankMission` - Agent identity/purpose stored on the memory bank. Helps the memory engine understand context for better fact extraction during retain. Set once per bank on first use — not a recall prompt. +- `dynamicBankId` - Enable per-context memory banks (default: `true`) +- `bankIdPrefix` - Optional prefix for bank IDs (e.g. `"prod"` → `"prod-slack-C123"`) +- `dynamicBankGranularity` - Fields used to derive bank ID: `agent`, `channel`, `user`, `provider` (default: `["agent", "channel", "user"]`) +- `excludeProviders` - Message providers to skip for recall/retain (e.g. `["slack"]`, `["telegram"]`, `["discord"]`) +- `autoRecall` - Auto-inject memories before each turn (default: `true`). Set to `false` when the agent has its own recall tool. +- `autoRetain` - Auto-retain conversations after each turn (default: `true`) +- `retainRoles` - Which message roles to retain (default: `["user", "assistant"]`). Options: `user`, `assistant`, `system`, `tool` +- `recallBudget` - Recall effort: `"low"`, `"mid"`, or `"high"` (default: `"mid"`). Higher budgets use more retrieval strategies for better results. +- `recallMaxTokens` - Max tokens for recall response (default: `1024`). Controls how much memory context is injected per turn. + +### Memory Isolation + +The plugin creates separate memory banks based on conversation context. By default, banks are derived from the `agent`, `channel`, and `user` fields — so each unique combination gets its own isolated memory store. + +You can customize which fields are used for bank segmentation with `dynamicBankGranularity`: + +```json +{ + "plugins": { + "entries": { + "hindsight-openclaw": { + "enabled": true, + "config": { + "dynamicBankGranularity": ["provider", "user"] + } + } + } + } +} +``` + +In this example, memories are isolated per provider + user, meaning the same user shares memories across all channels within a provider. + +Available isolation fields: +- `agent` - The agent/bot identity +- `channel` - The channel or conversation ID +- `user` - The user interacting with the agent +- `provider` - The message provider (e.g. Slack, Discord) + +Use `bankIdPrefix` to namespace bank IDs across environments (e.g. `"prod"`, `"staging"`). Set `dynamicBankId` to `false` to use a single shared bank for all conversations. + +### Retention Controls + +By default, the plugin retains `user` and `assistant` messages after each turn. You can customize this behavior: + +```json +{ + "plugins": { + "entries": { + "hindsight-openclaw": { + "enabled": true, + "config": { + "autoRetain": true, + "retainRoles": ["user", "assistant", "system"] + } + } + } + } +} +``` + +- `autoRetain` - Set to `false` to disable automatic retention entirely (useful if you handle retention yourself) +- `retainRoles` - Controls which message roles are included in the retained transcript. Only messages from the last user message onward are retained each turn, preventing duplicate storage. ### LLM Configuration diff --git a/hindsight-integrations/openclaw/README.md b/hindsight-integrations/openclaw/README.md index a15d1a29..a304f00f 100644 --- a/hindsight-integrations/openclaw/README.md +++ b/hindsight-integrations/openclaw/README.md @@ -24,6 +24,47 @@ openclaw gateway That's it! The plugin will automatically start capturing and recalling memories. +## Features + +- **Auto-capture** and **auto-recall** of memories each turn +- **Memory isolation** — configurable per agent, channel, user, or provider via `dynamicBankGranularity` +- **Retention controls** — choose which message roles to retain and toggle auto-retain on/off + +## Configuration + +Optional settings in `~/.openclaw/openclaw.json` under `plugins.entries.hindsight-openclaw.config`: + +| Option | Default | Description | +|--------|---------|-------------| +| `apiPort` | `9077` | Port for the local Hindsight daemon | +| `daemonIdleTimeout` | `0` | Seconds before daemon shuts down from inactivity (0 = never) | +| `embedPort` | `0` | Port for `hindsight-embed` server (`0` = auto-assign) | +| `embedVersion` | `"latest"` | hindsight-embed version | +| `embedPackagePath` | — | Local path to `hindsight-embed` package for development | +| `bankMission` | — | Agent identity/purpose stored on the memory bank. Helps the engine understand context for better fact extraction. Set once per bank — not a recall prompt. | +| `llmProvider` | auto-detect | LLM provider override for memory extraction (`openai`, `anthropic`, `gemini`, `groq`, `ollama`, `openai-codex`, `claude-code`) | +| `llmModel` | provider default | LLM model override used with `llmProvider` | +| `llmApiKeyEnv` | provider standard env var | Custom env var name for the provider API key | +| `dynamicBankId` | `true` | Enable per-context memory banks | +| `bankIdPrefix` | — | Prefix for bank IDs (e.g. `"prod"`) | +| `dynamicBankGranularity` | `["agent", "channel", "user"]` | Fields used to derive bank ID. Options: `agent`, `channel`, `user`, `provider` | +| `excludeProviders` | `[]` | Message providers to skip for recall/retain (e.g. `slack`, `telegram`, `discord`) | +| `autoRecall` | `true` | Auto-inject memories before each turn. Set to `false` when the agent has its own recall tool. | +| `autoRetain` | `true` | Auto-retain conversations after each turn | +| `retainRoles` | `["user", "assistant"]` | Which message roles to retain. Options: `user`, `assistant`, `system`, `tool` | +| `retainEveryNTurns` | `1` | Retain every Nth turn. `1` = every turn (default). Values > 1 enable chunked retention with a sliding window. | +| `retainOverlapTurns` | `0` | Extra prior turns included when chunked retention fires. Window = `retainEveryNTurns + retainOverlapTurns`. Only applies when `retainEveryNTurns > 1`. | +| `recallBudget` | `"mid"` | Recall effort: `low`, `mid`, or `high`. Higher budgets use more retrieval strategies. | +| `recallMaxTokens` | `1024` | Max tokens for recall response. Controls how much memory context is injected per turn. | +| `recallTypes` | `["world", "experience"]` | Memory types to recall. Options: `world`, `experience`, `observation`. Excludes verbose `observation` entries by default. | +| `recallRoles` | `["user", "assistant"]` | Roles included when building prior context for recall query composition. Options: `user`, `assistant`, `system`, `tool`. | +| `recallTopK` | — | Max number of memories to inject per turn. Applied after API response as a hard cap. | +| `recallContextTurns` | `1` | Number of user turns to include when composing recall query context. `1` keeps latest-message-only behavior. | +| `recallMaxQueryChars` | `800` | Maximum character length for the composed recall query before calling recall. | +| `recallPromptPreamble` | built-in string | Prompt text placed above recalled memories in the injected `` block. | +| `hindsightApiUrl` | — | External Hindsight API URL (skips local daemon) | +| `hindsightApiToken` | — | Auth token for external API | + ## Documentation For full documentation, configuration options, troubleshooting, and development guide, see: diff --git a/hindsight-integrations/openclaw/openclaw.plugin.json b/hindsight-integrations/openclaw/openclaw.plugin.json index 7e2875b7..f5244574 100644 --- a/hindsight-integrations/openclaw/openclaw.plugin.json +++ b/hindsight-integrations/openclaw/openclaw.plugin.json @@ -17,7 +17,7 @@ }, "bankMission": { "type": "string", - "description": "Custom mission/context for the memory bank", + "description": "Agent identity/purpose stored on the memory bank. Helps the memory engine understand context for better fact extraction during retain. Set once per bank on first use — this is not a recall prompt.", "default": "You are an AI assistant helping users across multiple communication channels (Telegram, Slack, Discord, etc.). Remember user preferences, instructions, and important context from conversations to provide personalized assistance." }, "embedVersion": { @@ -28,7 +28,15 @@ "llmProvider": { "type": "string", "description": "LLM provider for Hindsight memory (e.g. 'openai', 'anthropic', 'gemini', 'groq', 'ollama', 'openai-codex', 'claude-code'). Takes priority over auto-detection but not over HINDSIGHT_API_LLM_PROVIDER env var.", - "enum": ["openai", "anthropic", "gemini", "groq", "ollama", "openai-codex", "claude-code"] + "enum": [ + "openai", + "anthropic", + "gemini", + "groq", + "ollama", + "openai-codex", + "claude-code" + ] }, "llmModel": { "type": "string", @@ -71,8 +79,119 @@ }, "excludeProviders": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "Message providers to exclude from recall and retain (e.g. ['telegram', 'discord'])" + }, + "dynamicBankGranularity": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "agent", + "channel", + "user", + "provider" + ] + }, + "description": "Fields used to derive bank ID. Controls memory isolation granularity. Default: ['agent', 'channel', 'user'].", + "default": [ + "agent", + "channel", + "user" + ] + }, + "autoRetain": { + "type": "boolean", + "description": "Automatically retain conversation as memories after each interaction. Set to false to disable.", + "default": true + }, + "retainRoles": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "user", + "assistant", + "system", + "tool" + ] + }, + "description": "Message roles to include in retained transcript. Default: ['user', 'assistant'].", + "default": [ + "user", + "assistant" + ] + }, + "retainEveryNTurns": { + "type": "integer", + "description": "Retain every Nth turn instead of every turn. 1 = every turn (default). Values > 1 enable chunked retention with a sliding window.", + "minimum": 1, + "default": 1 + }, + "retainOverlapTurns": { + "type": "integer", + "description": "Extra prior turns to include when chunked retention fires. Window = retainEveryNTurns + retainOverlapTurns. Only applies when retainEveryNTurns > 1.", + "minimum": 0, + "default": 0 + }, + "recallBudget": { + "type": "string", + "description": "Recall effort level. Higher budgets use more retrieval strategies for better results but take longer.", + "enum": ["low", "mid", "high"], + "default": "mid" + }, + "recallMaxTokens": { + "type": "integer", + "description": "Maximum tokens for recall response. Controls how much memory context is injected per turn.", + "minimum": 1, + "default": 1024 + }, + "recallTypes": { + "type": "array", + "items": { + "type": "string", + "enum": ["world", "experience", "observation"] + }, + "description": "Memory types to recall. Defaults to ['world', 'experience'] — excludes verbose observation entries.", + "default": ["world", "experience"] + }, + "recallRoles": { + "type": "array", + "items": { + "type": "string", + "enum": ["user", "assistant", "system", "tool"] + }, + "description": "Roles to include when composing contextual recall query. Default: ['user', 'assistant'].", + "default": ["user", "assistant"] + }, + "recallContextTurns": { + "type": "integer", + "minimum": 1, + "description": "Number of user turns to include in recall query context. 1 keeps latest-message-only behavior.", + "default": 1 + }, + "recallMaxQueryChars": { + "type": "integer", + "minimum": 1, + "description": "Maximum character length for composed recall query before calling recall.", + "default": 800 + }, + "recallTopK": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of memories to inject per turn. Applied after API response as a hard cap." + }, + "recallPromptPreamble": { + "type": "string", + "description": "Text shown above recalled memories in the injected context block.", + "default": "Relevant memories from past conversations (prioritize recent when conflicting). Only use memories that are directly useful to continue this conversation; ignore the rest:" + }, + "debug": { + "type": "boolean", + "description": "Enable debug logging for Hindsight plugin operations.", + "default": false } }, "additionalProperties": false @@ -137,6 +256,61 @@ "excludeProviders": { "label": "Excluded Providers", "placeholder": "e.g. telegram, discord" + }, + "dynamicBankGranularity": { + "label": "Bank Granularity", + "placeholder": "e.g. ['agent', 'channel', 'user']" + }, + "autoRetain": { + "label": "Auto-Retain", + "placeholder": "true (enable auto-retention)" + }, + "retainRoles": { + "label": "Retain Roles", + "placeholder": "e.g. ['user', 'assistant']" + }, + "retainEveryNTurns": { + "label": "Retain Every N Turns", + "placeholder": "1 (every turn, default)" + }, + "retainOverlapTurns": { + "label": "Retain Overlap Turns", + "placeholder": "0 (no overlap, default)" + }, + "recallBudget": { + "label": "Recall Budget", + "placeholder": "low, mid, or high" + }, + "recallMaxTokens": { + "label": "Recall Max Tokens", + "placeholder": "1024 (default)" + }, + "recallTypes": { + "label": "Recall Types", + "placeholder": "e.g. ['world', 'experience']" + }, + "recallRoles": { + "label": "Recall Roles", + "placeholder": "e.g. ['user', 'assistant']" + }, + "recallContextTurns": { + "label": "Recall Context Turns", + "placeholder": "1 (latest only, default)" + }, + "recallMaxQueryChars": { + "label": "Recall Max Query Chars", + "placeholder": "800 (default)" + }, + "recallTopK": { + "label": "Recall Top K", + "placeholder": "e.g. 5 (no limit by default)" + }, + "recallPromptPreamble": { + "label": "Recall Prompt Preamble", + "placeholder": "Instruction shown above recalled memories in injected context" + }, + "debug": { + "label": "Debug" } } } diff --git a/hindsight-integrations/openclaw/src/client.test.ts b/hindsight-integrations/openclaw/src/client.test.ts index 8d846321..7f48d475 100644 --- a/hindsight-integrations/openclaw/src/client.test.ts +++ b/hindsight-integrations/openclaw/src/client.test.ts @@ -2,27 +2,23 @@ import { describe, it, expect } from 'vitest'; import { HindsightClient } from './client.js'; describe('HindsightClient', () => { - it('should create instance with provider and API key', () => { - const client = new HindsightClient({ llmProvider: 'openai', llmApiKey: 'test-key', llmModel: 'gpt-4' }); + it('should create instance with model', () => { + const client = new HindsightClient({ llmModel: 'gpt-4' }); expect(client).toBeInstanceOf(HindsightClient); }); it('should set bank ID', () => { - const client = new HindsightClient({ llmProvider: 'openai', llmApiKey: 'test-key' }); - client.setBankId('test-bank'); - // No error thrown means success - expect(true).toBe(true); + const client = new HindsightClient({}); + expect(() => client.setBankId('test-bank')).not.toThrow(); }); it('should create instance with embed package path', () => { - const client = new HindsightClient({ llmProvider: 'openai', llmApiKey: 'test-key', llmModel: 'gpt-4', embedPackagePath: '/path/to/hindsight' }); + const client = new HindsightClient({ llmModel: 'gpt-4', embedPackagePath: '/path/to/hindsight' }); expect(client).toBeInstanceOf(HindsightClient); }); it('should create instance in HTTP mode', () => { const client = new HindsightClient({ - llmProvider: 'openai', - llmApiKey: 'test-key', apiUrl: 'https://api.example.com/', apiToken: 'bearer-token', }); diff --git a/hindsight-integrations/openclaw/src/client.ts b/hindsight-integrations/openclaw/src/client.ts index 803b5232..25de3f7e 100644 --- a/hindsight-integrations/openclaw/src/client.ts +++ b/hindsight-integrations/openclaw/src/client.ts @@ -29,8 +29,6 @@ function sanitizeFilename(name: string): string { } export interface HindsightClientOptions { - llmProvider: string; - llmApiKey: string; llmModel?: string; embedVersion?: string; embedPackagePath?: string; @@ -40,8 +38,6 @@ export interface HindsightClientOptions { export class HindsightClient { private bankId: string = 'default'; - private llmProvider: string; - private llmApiKey: string; private llmModel?: string; private embedVersion: string; private embedPackagePath?: string; @@ -49,8 +45,6 @@ export class HindsightClient { private apiToken?: string; constructor(opts: HindsightClientOptions) { - this.llmProvider = opts.llmProvider; - this.llmApiKey = opts.llmApiKey; this.llmModel = opts.llmModel; this.embedVersion = opts.embedVersion || 'latest'; this.embedPackagePath = opts.embedPackagePath; @@ -220,10 +214,16 @@ export class HindsightClient { ? (console.warn(`[Hindsight] Truncating recall query from ${request.query.length} to ${MAX_QUERY_CHARS} chars`), request.query.substring(0, MAX_QUERY_CHARS)) : request.query; - const body = { + const body: Record = { query, max_tokens: request.max_tokens || 1024, }; + if (request.budget) { + body.budget = request.budget; + } + if (request.types) { + body.types = request.types; + } const res = await fetch(url, { method: 'POST', diff --git a/hindsight-integrations/openclaw/src/derive-bank-id.test.ts b/hindsight-integrations/openclaw/src/derive-bank-id.test.ts new file mode 100644 index 00000000..9cbf3d98 --- /dev/null +++ b/hindsight-integrations/openclaw/src/derive-bank-id.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { deriveBankId } from './index.js'; +import type { PluginHookAgentContext, PluginConfig } from './types.js'; + +describe('deriveBankId', () => { + const ctx: PluginHookAgentContext = { + agentId: 'agent-123', + channelId: 'channel-456', + senderId: 'user-789', + messageProvider: 'slack', + }; + + const baseConfig: PluginConfig = { + dynamicBankId: true, + }; + + it('should use default isolation fields when not specified', () => { + const bankId = deriveBankId(ctx, baseConfig); + expect(bankId).toBe('agent-123::channel-456::user-789'); + }); + + it('should default to dynamic bank ID when dynamicBankId is not specified', () => { + const config: PluginConfig = {}; + const bankId = deriveBankId(ctx, config); + expect(bankId).toBe('agent-123::channel-456::user-789'); + }); + + it('should support ["agent", "user"] isolation', () => { + const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ['agent', 'user'] }; + const bankId = deriveBankId(ctx, config); + expect(bankId).toBe('agent-123::user-789'); + }); + + it('should support ["user"] isolation', () => { + const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ['user'] }; + const bankId = deriveBankId(ctx, config); + expect(bankId).toBe('user-789'); + }); + + it('should support ["agent"] isolation', () => { + const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ['agent'] }; + const bankId = deriveBankId(ctx, config); + expect(bankId).toBe('agent-123'); + }); + + it('should support ["channel"] isolation', () => { + const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ['channel'] }; + const bankId = deriveBankId(ctx, config); + expect(bankId).toBe('channel-456'); + }); + + it('should support ["provider"] isolation', () => { + const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ['provider'] }; + const bankId = deriveBankId(ctx, config); + expect(bankId).toBe('slack'); + }); + + it('should support mixed fields including provider', () => { + const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ['provider', 'user'] }; + const bankId = deriveBankId(ctx, config); + expect(bankId).toBe('slack::user-789'); + }); + + it('should prepend bankIdPrefix if set', () => { + const config: PluginConfig = { ...baseConfig, bankIdPrefix: 'prod' }; + const bankId = deriveBankId(ctx, config); + expect(bankId).toBe('prod-agent-123::channel-456::user-789'); + }); + + it('should use fallback values for missing context fields', () => { + const partialCtx: PluginHookAgentContext = { + agentId: 'agent-123', + }; + const bankId = deriveBankId(partialCtx, baseConfig); + expect(bankId).toBe('agent-123::unknown::anonymous'); + }); + + it('should parse sessionKey as fallback for missing channel and provider', () => { + const ctxWithSession: PluginHookAgentContext = { + agentId: 'my-agent', + sessionKey: 'agent:my-agent:telegram:group:-100123456:topic:7', + }; + const config: PluginConfig = { ...baseConfig, dynamicBankGranularity: ['agent', 'channel', 'provider'] }; + const bankId = deriveBankId(ctxWithSession, config); + expect(bankId).toBe('my-agent::group%3A-100123456%3Atopic%3A7::telegram'); + }); + + it('should return "openclaw" if dynamicBankId is false', () => { + const config: PluginConfig = { dynamicBankId: false }; + const bankId = deriveBankId(ctx, config); + expect(bankId).toBe('openclaw'); + }); + + it('should encode segments to prevent separator collisions', () => { + const ctxWithSeparator: PluginHookAgentContext = { + agentId: 'a::b', + channelId: 'c', + senderId: 'user-1', + }; + const ctxWithoutSeparator: PluginHookAgentContext = { + agentId: 'a', + channelId: 'b::c', + senderId: 'user-1', + }; + const bankId1 = deriveBankId(ctxWithSeparator, baseConfig); + const bankId2 = deriveBankId(ctxWithoutSeparator, baseConfig); + // These must NOT collide + expect(bankId1).not.toBe(bankId2); + // Segment delimiters are encoded, preserving unique values. + expect(bankId1).toBe('a%3A%3Ab::c::user-1'); + expect(bankId2).toBe('a::b%3A%3Ac::user-1'); + }); +}); diff --git a/hindsight-integrations/openclaw/src/index.test.ts b/hindsight-integrations/openclaw/src/index.test.ts index 4d19984b..ee03ecbd 100644 --- a/hindsight-integrations/openclaw/src/index.test.ts +++ b/hindsight-integrations/openclaw/src/index.test.ts @@ -1,5 +1,14 @@ import { describe, it, expect } from 'vitest'; -import { stripMemoryTags, extractRecallQuery } from './index.js'; +import { + stripMemoryTags, + extractRecallQuery, + formatMemories, + prepareRetentionTranscript, + sliceLastTurnsByUserBoundary, + composeRecallQuery, + truncateRecallQuery, +} from './index.js'; +import type { PluginConfig, MemoryResult } from './types.js'; // --------------------------------------------------------------------------- // stripMemoryTags @@ -82,6 +91,19 @@ describe('extractRecallQuery', () => { expect(result).toBe('What programming language do I prefer?'); }); + it('returns null when rawMessage is absent and prompt is bare metadata', () => { + const metadataPrompt = 'Conversation info (untrusted metadata):\n```json\n{"message_id": "abc123"}\n```'; + expect(extractRecallQuery(undefined, metadataPrompt)).toBeNull(); + }); + + it('falls back to prompt when rawMessage is metadata but prompt has real content', () => { + const result = extractRecallQuery( + 'Conversation info (untrusted metadata):', + 'System: You are c0der.\n\nhow many cats do i have?', + ); + expect(result).toBe('how many cats do i have?'); + }); + it('strips leading System: lines from prompt', () => { const prompt = 'System: You are an agent.\nSystem: Use tools wisely.\n\nWhat is my name?'; const result = extractRecallQuery(undefined, prompt); @@ -140,4 +162,266 @@ describe('extractRecallQuery', () => { const result = extractRecallQuery(' What is my job? ', undefined); expect(result).toBe('What is my job?'); }); + + it('rejects OpenClaw untrusted metadata messages as rawMessage', () => { + const result = extractRecallQuery('Conversation info (untrusted metadata):', undefined); + expect(result).toBeNull(); + }); + + it('rejects untrusted metadata even when prompt is also metadata', () => { + const result = extractRecallQuery( + 'Conversation info (untrusted metadata):', + 'Conversation info (untrusted metadata): some details', + ); + expect(result).toBeNull(); + }); + + it('falls back to prompt when rawMessage is metadata', () => { + const result = extractRecallQuery( + 'Conversation info (untrusted metadata):', + 'How many cats do I have?', + ); + expect(result).toBe('How many cats do I have?'); + }); +}); + + +// --------------------------------------------------------------------------- +// formatMemories +// --------------------------------------------------------------------------- + +describe('formatMemories', () => { + const makeMemoryResult = (overrides: Partial): MemoryResult => ({ + id: 'mem-1', + text: 'default text', + type: 'world', + entities: [], + context: '', + occurred_start: null, + occurred_end: null, + mentioned_at: null, + document_id: null, + metadata: null, + chunk_id: null, + tags: [], + ...overrides, + }); + + it('formats memories as a bulleted list', () => { + const memories: MemoryResult[] = [ + makeMemoryResult({ id: '1', text: 'User prefers dark mode', type: 'world', mentioned_at: '2023-01-01T12:00:00Z' }), + makeMemoryResult({ id: '2', text: 'User is learning Rust', type: 'experience', mentioned_at: null }), + ]; + const output = formatMemories(memories); + expect(output).toBe('- User prefers dark mode [world] (2023-01-01T12:00:00Z)\n\n- User is learning Rust [experience]'); + }); + + it('returns empty string for empty memories', () => { + expect(formatMemories([])).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// prepareRetentionTranscript +// --------------------------------------------------------------------------- + +describe('prepareRetentionTranscript', () => { + const baseConfig: PluginConfig = { + dynamicBankId: true, + retainRoles: ['user', 'assistant'], + }; + + it('returns null if no user message found (turn boundary)', () => { + const messages = [ + { role: 'assistant', content: 'Hello' }, + { role: 'system', content: 'Context' } + ]; + const result = prepareRetentionTranscript(messages, baseConfig); + expect(result).toBeNull(); + }); + + it('retains from last user message onwards', () => { + const messages = [ + { role: 'user', content: 'Old user' }, + { role: 'assistant', content: 'Old assistant' }, + { role: 'user', content: 'New user' }, + { role: 'assistant', content: 'New assistant' } + ]; + const result = prepareRetentionTranscript(messages, baseConfig); + expect(result).not.toBeNull(); + expect(result?.transcript).toContain('New user'); + expect(result?.transcript).toContain('New assistant'); + expect(result?.transcript).not.toContain('Old user'); + }); + + it('filters out excluded roles', () => { + const config: PluginConfig = { ...baseConfig, retainRoles: ['user'] }; + const messages = [ + { role: 'user', content: 'User msg' }, + { role: 'assistant', content: 'Assistant msg' } + ]; + const result = prepareRetentionTranscript(messages, config); + expect(result).not.toBeNull(); + expect(result?.transcript).toContain('User msg'); + expect(result?.transcript).not.toContain('Assistant msg'); + }); + + it('handles array content', () => { + const messages = [ + { role: 'user', content: [{ type: 'text', text: 'Hello array' }] } + ]; + const result = prepareRetentionTranscript(messages, baseConfig); + expect(result?.transcript).toContain('Hello array'); + }); + + it('strips memory tags from retained content (feedback loop prevention)', () => { + const messages = [ + { role: 'user', content: 'What is dark mode?' }, + { role: 'assistant', content: '\nUser prefers dark mode\n\nHere is how to enable dark mode.' } + ]; + const result = prepareRetentionTranscript(messages, baseConfig); + expect(result).not.toBeNull(); + expect(result?.transcript).not.toContain(''); + expect(result?.transcript).not.toContain('User prefers dark mode'); + expect(result?.transcript).toContain('Here is how to enable dark mode.'); + }); + + it('strips memory tags from user message when prependContext is prepended to it', () => { + // Simulates the host prepending prependContext to the user message content + const userContent = `\nRelevant memories:\n- User prefers dark mode [world]\n\nUser message: What is dark mode?\n\nWhat is dark mode?`; + const messages = [ + { role: 'user', content: userContent }, + { role: 'assistant', content: 'Dark mode is a display setting.' } + ]; + const result = prepareRetentionTranscript(messages, baseConfig); + expect(result).not.toBeNull(); + expect(result?.transcript).not.toContain(''); + expect(result?.transcript).not.toContain('User prefers dark mode'); + expect(result?.transcript).toContain('What is dark mode?'); + expect(result?.transcript).toContain('Dark mode is a display setting.'); + }); + + it('reports accurate messageCount excluding empty messages', () => { + const messages = [ + { role: 'user', content: 'Real message' }, + { role: 'assistant', content: '\nonly tags\n' }, + { role: 'assistant', content: 'Actual response' } + ]; + const result = prepareRetentionTranscript(messages, baseConfig); + expect(result).not.toBeNull(); + // The middle message becomes empty after tag stripping, so messageCount should be 2 + expect(result?.messageCount).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// sliceLastTurnsByUserBoundary +// --------------------------------------------------------------------------- + +describe('sliceLastTurnsByUserBoundary', () => { + it('returns the whole message list when requested turns exceed available user turns', () => { + const messages = [ + { role: 'system', content: 'System preface' }, + { role: 'user', content: 'Turn 1 user' }, + { role: 'assistant', content: 'Turn 1 assistant' }, + { role: 'user', content: 'Turn 2 user' }, + { role: 'assistant', content: 'Turn 2 assistant' }, + ]; + + const result = sliceLastTurnsByUserBoundary(messages, 3); + expect(result).toEqual(messages); + }); + + it('slices by real user-turn boundaries with system/tool messages present', () => { + const messages = [ + { role: 'system', content: 'System preface' }, + { role: 'user', content: 'Turn 1 user' }, + { role: 'assistant', content: 'Turn 1 assistant' }, + { role: 'tool', content: 'Tool output in turn 1' }, + { role: 'user', content: 'Turn 2 user' }, + { role: 'assistant', content: 'Turn 2 assistant' }, + { role: 'system', content: 'System note in turn 2' }, + { role: 'user', content: 'Turn 3 user' }, + { role: 'assistant', content: 'Turn 3 assistant' }, + ]; + + const result = sliceLastTurnsByUserBoundary(messages, 2); + expect(result).toEqual(messages.slice(4)); + }); + + it('returns empty list for invalid turn counts', () => { + const messages = [{ role: 'user', content: 'Hello' }]; + expect(sliceLastTurnsByUserBoundary(messages, 0)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// composeRecallQuery + truncateRecallQuery +// --------------------------------------------------------------------------- + +describe('composeRecallQuery', () => { + it('returns latest query unchanged when recallContextTurns is 1', () => { + const query = composeRecallQuery('What is my preference?', [{ role: 'user', content: 'Old message' }], 1); + expect(query).toBe('What is my preference?'); + }); + + it('includes prior user/assistant context when recallContextTurns > 1', () => { + const messages = [ + { role: 'user', content: 'I like dark mode.' }, + { role: 'assistant', content: 'Got it, dark mode noted.' }, + { role: 'user', content: 'What theme do I prefer?' }, + ]; + + const query = composeRecallQuery('What theme do I prefer?', messages, 2); + expect(query).toContain('What theme do I prefer?'); + expect(query).toContain('user: I like dark mode.'); + expect(query).toContain('assistant: Got it, dark mode noted.'); + // latest message should appear after prior context + expect(query.indexOf('Prior context:')).toBeLessThan(query.indexOf('What theme do I prefer?')); + }); + + it('respects recallRoles when building prior context', () => { + const messages = [ + { role: 'system', content: 'System context' }, + { role: 'assistant', content: 'Assistant context' }, + { role: 'user', content: 'What theme do I prefer?' }, + ]; + + const query = composeRecallQuery('What theme do I prefer?', messages, 2, ['user']); + expect(query).toBe('What theme do I prefer?'); + }); + + it('falls back to latest query when context has no usable text', () => { + const messages = [{ role: 'tool', content: 'binary blob' }]; + const query = composeRecallQuery('Summarize my preference', messages, 3); + expect(query).toBe('Summarize my preference'); + }); +}); + +describe('truncateRecallQuery', () => { + it('keeps query unchanged when under max', () => { + const query = 'short query'; + expect(truncateRecallQuery(query, query, 100)).toBe(query); + }); + + it('falls back to latest query when non-context query is over max', () => { + const latest = 'What foods do I like?'; + const long = `${latest} ${'x'.repeat(300)}`; + expect(truncateRecallQuery(long, latest, 20)).toBe(latest.slice(0, 20)); + }); + + it('trims prior context first and preserves latest section', () => { + const latest = 'What foods do I like?'; + const composed = [ + 'Prior context:', + 'user: I like sushi.', + 'assistant: You like sushi and ramen.', + 'user: Also pizza.', + latest, + ].join('\n\n'); + + const truncated = truncateRecallQuery(composed, latest, 180); + expect(truncated).toContain(latest); + expect(truncated.length).toBeLessThanOrEqual(180); + }); }); diff --git a/hindsight-integrations/openclaw/src/index.ts b/hindsight-integrations/openclaw/src/index.ts index 0bde0462..4db9db0b 100644 --- a/hindsight-integrations/openclaw/src/index.ts +++ b/hindsight-integrations/openclaw/src/index.ts @@ -1,6 +1,7 @@ -import type { MoltbotPluginAPI, PluginConfig } from './types.js'; +import type { MoltbotPluginAPI, PluginConfig, PluginHookAgentContext, MemoryResult } from './types.js'; import { HindsightEmbedManager } from './embed-manager.js'; import { HindsightClient, type HindsightClientOptions } from './client.js'; +import { createHash } from 'crypto'; import { dirname } from 'path'; import { fileURLToPath } from 'url'; @@ -13,6 +14,7 @@ const debug = (...args: unknown[]) => { // Module-level state let embedManager: HindsightEmbedManager | null = null; let client: HindsightClient | null = null; +let clientOptions: HindsightClientOptions | null = null; let initPromise: Promise | null = null; let isInitialized = false; let usingExternalApi = false; // Track if using external API (skip daemon management) @@ -22,18 +24,42 @@ let currentPluginConfig: PluginConfig | null = null; // Track which banks have had their mission set (to avoid re-setting on every request) const banksWithMissionSet = new Set(); +// Use dedicated client instances per bank to avoid cross-session bankId mutation races. +const clientsByBankId = new Map(); +const MAX_TRACKED_BANK_CLIENTS = 10_000; // In-flight recall deduplication: concurrent recalls for the same bank reuse one promise import type { RecallResponse } from './types.js'; const inflightRecalls = new Map>(); const turnCountBySession = new Map(); +const MAX_TRACKED_SESSIONS = 10_000; const RECALL_TIMEOUT_MS = 10_000; +// Cache sender IDs discovered in before_prompt_build (where event.prompt has the metadata +// blocks) so agent_end can look them up — event.messages in agent_end is clean history. +const senderIdBySession = new Map(); + +// Guard against double hook registration on the same api instance +// Uses a WeakSet so each api instance can only register hooks once +const registeredApis = new WeakSet(); + // Cooldown + guard to prevent concurrent reinit attempts let lastReinitAttempt = 0; let isReinitInProgress = false; const REINIT_COOLDOWN_MS = 30_000; +const DEFAULT_RECALL_PROMPT_PREAMBLE = + 'Relevant memories from past conversations (prioritize recent when conflicting). Only use memories that are directly useful to continue this conversation; ignore the rest:'; + +function formatCurrentTimeForRecall(date = new Date()): string { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + const hours = String(date.getUTCHours()).padStart(2, '0'); + const minutes = String(date.getUTCMinutes()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}`; +} + /** * Lazy re-initialization after startup failure. * Called by waitForReady when initPromise rejected but API may now be reachable. @@ -70,7 +96,10 @@ async function lazyReinit(): Promise { } const llmConfig = detectLLMConfig(config); - client = new HindsightClient(buildClientOptions(llmConfig, config, externalApi)); + clientOptions = buildClientOptions(llmConfig, config, externalApi); + clientsByBankId.clear(); + banksWithMissionSet.clear(); + client = new HindsightClient(clientOptions); const defaultBankId = deriveBankId(undefined, config); client.setBankId(defaultBankId); @@ -117,13 +146,31 @@ if (typeof global !== 'undefined') { getClientForContext: async (ctx: PluginHookAgentContext | undefined) => { if (!client) {return null;} const config = currentPluginConfig || {}; + if (config.dynamicBankId === false) { + return client; + } const bankId = deriveBankId(ctx, config); - client.setBankId(bankId); + let bankClient = clientsByBankId.get(bankId); + if (!bankClient) { + if (!clientOptions) { + return null; + } + bankClient = new HindsightClient(clientOptions); + bankClient.setBankId(bankId); + clientsByBankId.set(bankId, bankClient); + if (clientsByBankId.size > MAX_TRACKED_BANK_CLIENTS) { + const oldestKey = clientsByBankId.keys().next().value; + if (oldestKey) { + clientsByBankId.delete(oldestKey); + banksWithMissionSet.delete(oldestKey); + } + } + } // Set bank mission on first use of this bank (if configured) if (config.bankMission && config.dynamicBankId && !banksWithMissionSet.has(bankId)) { try { - await client.setBankMission(config.bankMission); + await bankClient.setBankMission(config.bankMission); banksWithMissionSet.add(bankId); debug(`[Hindsight] Set mission for new bank: ${bankId}`); } catch (error) { @@ -132,7 +179,7 @@ if (typeof global !== 'undefined') { } } - return client; + return bankClient; }, getPluginConfig: () => currentPluginConfig, }; @@ -156,6 +203,39 @@ export function stripMemoryTags(content: string): string { return content; } +/** + * Extract sender_id from OpenClaw's injected inbound metadata blocks. + * Checks both "Conversation info (untrusted metadata)" and "Sender (untrusted metadata)" blocks. + * Returns the first sender_id / id string found, or undefined if none. + */ +export function extractSenderIdFromText(text: string): string | undefined { + if (!text) return undefined; + const metaBlockRe = /[\w\s]+\(untrusted metadata\)[^\n]*\n```json\n([\s\S]*?)\n```/gi; + let match: RegExpExecArray | null; + while ((match = metaBlockRe.exec(text)) !== null) { + try { + const obj = JSON.parse(match[1]); + const id = obj?.sender_id ?? obj?.id; + if (id && typeof id === 'string') return id; + } catch { + // continue to next block + } + } + return undefined; +} + +/** + * Strip OpenClaw sender/conversation metadata envelopes from message content. + * These blocks are injected by OpenClaw but are noise for memory storage and recall. + */ +export function stripMetadataEnvelopes(content: string): string { + // Strip: ---\n