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