feat(openclaw): v2 recall/retention controls, scalability fixes, and Gemini safety settings (#480)
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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 {...}```
<actual message>
---
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) <noreply@anthropic.com>
* fix(openclaw): warn when recallContextTurns > 1 but event.messages is empty
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* fix(openclaw): strip metadata envelopes after channel envelope extraction too
The prompt format is: [ChannelName ...]\n<metadata envelope>\n<message>
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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* fix(openclaw): move current time inside memory tag, simplify recall query format
- Move "Current time" line inside <hindsight_memories> 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* docs(openclaw): revert Auto-Recall token count to 1024 as unchanged from main
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* fix(openclaw): revert recallMaxTokens default from 2048 to 1024 to match main
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cb6d1c469c
commit
d425e93cb4
12 changed files with 1409 additions and 172 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 `<hindsight_memories>` 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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {
|
||||
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',
|
||||
|
|
|
|||
113
hindsight-integrations/openclaw/src/derive-bank-id.test.ts
Normal file
113
hindsight-integrations/openclaw/src/derive-bank-id.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>): 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: '<hindsight_memories>\nUser prefers dark mode\n</hindsight_memories>\nHere is how to enable dark mode.' }
|
||||
];
|
||||
const result = prepareRetentionTranscript(messages, baseConfig);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.transcript).not.toContain('<hindsight_memories>');
|
||||
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 = `<hindsight_memories>\nRelevant memories:\n- User prefers dark mode [world]\n\nUser message: What is dark mode?\n</hindsight_memories>\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('<hindsight_memories>');
|
||||
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: '<hindsight_memories>\nonly tags\n</hindsight_memories>' },
|
||||
{ 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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> | 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<string>();
|
||||
// Use dedicated client instances per bank to avoid cross-session bankId mutation races.
|
||||
const clientsByBankId = new Map<string, HindsightClient>();
|
||||
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<string, Promise<RecallResponse>>();
|
||||
const turnCountBySession = new Map<string, number>();
|
||||
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<string, string>();
|
||||
|
||||
// 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<object>();
|
||||
|
||||
// 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<void> {
|
|||
}
|
||||
|
||||
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<Label> (untrusted metadata):\n```json\n{...}\n```\n<message>\n---
|
||||
content = content.replace(/^---\n[\w\s]+\(untrusted metadata\)[^\n]*\n```json[\s\S]*?```\n\n?/im, '').replace(/\n---$/, '');
|
||||
// Strip: <Label> (untrusted metadata):\n```json\n{...}\n``` (without --- wrapper)
|
||||
content = content.replace(/[\w\s]+\(untrusted metadata\)[^\n]*\n```json[\s\S]*?```\n?/gim, '');
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a recall query from a hook event's rawMessage or prompt.
|
||||
*
|
||||
|
|
@ -168,10 +248,26 @@ export function extractRecallQuery(
|
|||
rawMessage: string | undefined,
|
||||
prompt: string | undefined,
|
||||
): string | null {
|
||||
// Reject known metadata/system message patterns — these are not user queries
|
||||
const METADATA_PATTERNS = [
|
||||
/^\s*conversation info\s*\(untrusted metadata\)/i,
|
||||
/^\s*\(untrusted metadata\)/i,
|
||||
/^\s*system:/i,
|
||||
];
|
||||
const isMetadata = (s: string) => METADATA_PATTERNS.some(p => p.test(s));
|
||||
|
||||
let recallQuery = rawMessage;
|
||||
if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.trim().length < 5) {
|
||||
// Strip sender metadata envelope before any checks
|
||||
if (recallQuery) {
|
||||
recallQuery = stripMetadataEnvelopes(recallQuery);
|
||||
}
|
||||
if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.trim().length < 5 || isMetadata(recallQuery)) {
|
||||
recallQuery = prompt;
|
||||
if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.length < 5) {
|
||||
// Strip metadata envelopes from prompt too, then check if anything useful remains
|
||||
if (recallQuery) {
|
||||
recallQuery = stripMetadataEnvelopes(recallQuery);
|
||||
}
|
||||
if (!recallQuery || recallQuery.length < 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -198,53 +294,206 @@ export function extractRecallQuery(
|
|||
// Remove trailing [from: SenderName] metadata (group chats)
|
||||
cleaned = cleaned.replace(/\n\[from:[^\]]*\]\s*$/, '');
|
||||
|
||||
// Strip metadata envelopes again after channel envelope extraction, in case
|
||||
// the metadata block appeared after the [ChannelName] header
|
||||
cleaned = stripMetadataEnvelopes(cleaned);
|
||||
|
||||
recallQuery = cleaned.trim() || recallQuery;
|
||||
}
|
||||
|
||||
const trimmed = recallQuery.trim();
|
||||
if (trimmed.length < 5) return null;
|
||||
if (trimmed.length < 5 || isMetadata(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent context passed to plugin hooks.
|
||||
* These fields are populated by OpenClaw when invoking hooks.
|
||||
*/
|
||||
interface PluginHookAgentContext {
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
workspaceDir?: string;
|
||||
messageProvider?: string;
|
||||
channelId?: string;
|
||||
senderId?: string;
|
||||
export function composeRecallQuery(
|
||||
latestQuery: string,
|
||||
messages: any[] | undefined,
|
||||
recallContextTurns: number,
|
||||
recallRoles: Array<'user' | 'assistant' | 'system' | 'tool'> = ['user', 'assistant'],
|
||||
): string {
|
||||
const latest = latestQuery.trim();
|
||||
if (recallContextTurns <= 1 || !Array.isArray(messages) || messages.length === 0) {
|
||||
return latest;
|
||||
}
|
||||
|
||||
const allowedRoles = new Set(recallRoles);
|
||||
const contextualMessages = sliceLastTurnsByUserBoundary(messages, recallContextTurns);
|
||||
const contextLines = contextualMessages
|
||||
.map((msg: any) => {
|
||||
const role = msg?.role;
|
||||
if (!allowedRoles.has(role)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let content = '';
|
||||
if (typeof msg?.content === 'string') {
|
||||
content = msg.content;
|
||||
} else if (Array.isArray(msg?.content)) {
|
||||
content = msg.content
|
||||
.filter((block: any) => block?.type === 'text' && typeof block?.text === 'string')
|
||||
.map((block: any) => block.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
content = stripMemoryTags(content).trim();
|
||||
content = stripMetadataEnvelopes(content);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
if (role === 'user' && content === latest) {
|
||||
return null;
|
||||
}
|
||||
return `${role}: ${content}`;
|
||||
})
|
||||
.filter((line: string | null): line is string => Boolean(line));
|
||||
|
||||
if (contextLines.length === 0) {
|
||||
return latest;
|
||||
}
|
||||
|
||||
return [
|
||||
'Prior context:',
|
||||
contextLines.join('\n'),
|
||||
latest,
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
export function truncateRecallQuery(query: string, latestQuery: string, maxChars: number): string {
|
||||
if (maxChars <= 0) {
|
||||
return query;
|
||||
}
|
||||
|
||||
const latest = latestQuery.trim();
|
||||
if (query.length <= maxChars) {
|
||||
return query;
|
||||
}
|
||||
|
||||
const latestOnly = latest.length <= maxChars ? latest : latest.slice(0, maxChars);
|
||||
|
||||
if (!query.includes('Prior context:')) {
|
||||
return latestOnly;
|
||||
}
|
||||
|
||||
// New order: Prior context at top, latest user message at bottom.
|
||||
// Truncate by dropping oldest context lines first to preserve the suffix.
|
||||
const contextMarker = 'Prior context:\n\n';
|
||||
const markerIndex = query.indexOf(contextMarker);
|
||||
if (markerIndex === -1) {
|
||||
return latestOnly;
|
||||
}
|
||||
|
||||
const suffixMarker = '\n\n' + latest;
|
||||
const suffixIndex = query.lastIndexOf(suffixMarker);
|
||||
if (suffixIndex === -1) {
|
||||
return latestOnly;
|
||||
}
|
||||
|
||||
const suffix = query.slice(suffixIndex); // \n\n<latest>
|
||||
if (suffix.length >= maxChars) {
|
||||
return latestOnly;
|
||||
}
|
||||
|
||||
const contextBody = query.slice(markerIndex + contextMarker.length, suffixIndex);
|
||||
const contextLines = contextBody.split('\n').filter(Boolean);
|
||||
const keptContextLines: string[] = [];
|
||||
|
||||
// Add context lines from newest (bottom) to oldest (top), stopping when we exceed maxChars
|
||||
for (let i = contextLines.length - 1; i >= 0; i--) {
|
||||
keptContextLines.unshift(contextLines[i]);
|
||||
const candidate = `${contextMarker}${keptContextLines.join('\n')}${suffix}`;
|
||||
if (candidate.length > maxChars) {
|
||||
keptContextLines.shift();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (keptContextLines.length > 0) {
|
||||
return `${contextMarker}${keptContextLines.join('\n')}${suffix}`;
|
||||
}
|
||||
|
||||
return latestOnly;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a bank ID from the agent context.
|
||||
* Creates per-user banks: {messageProvider}-{senderId}
|
||||
* Uses configurable dynamicBankGranularity to determine bank segmentation.
|
||||
* Falls back to default bank when context is unavailable.
|
||||
*/
|
||||
function deriveBankId(
|
||||
ctx: PluginHookAgentContext | undefined,
|
||||
pluginConfig: PluginConfig
|
||||
): string {
|
||||
// If dynamic bank ID is disabled, use static bank
|
||||
/**
|
||||
* Parse the OpenClaw sessionKey to extract context fields.
|
||||
* Format: "agent:{agentId}:{provider}:{channelType}:{channelId}[:{extra}]"
|
||||
* Example: "agent:c0der:telegram:group:-1003825475854:topic:42"
|
||||
*/
|
||||
function parseSessionKey(sessionKey: string): { agentId?: string; provider?: string; channel?: string } {
|
||||
const parts = sessionKey.split(':');
|
||||
if (parts.length < 5 || parts[0] !== 'agent') return {};
|
||||
// parts[1] = agentId, parts[2] = provider, parts[3] = channelType, parts[4..] = channelId + extras
|
||||
return {
|
||||
agentId: parts[1],
|
||||
provider: parts[2],
|
||||
// Rejoin from channelType onward as the channel identifier (e.g. "group:-1003825475854:topic:42")
|
||||
channel: parts.slice(3).join(':'),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveBankId(ctx: PluginHookAgentContext | undefined, pluginConfig: PluginConfig): string {
|
||||
if (pluginConfig.dynamicBankId === false) {
|
||||
return pluginConfig.bankIdPrefix
|
||||
? `${pluginConfig.bankIdPrefix}-${DEFAULT_BANK_NAME}`
|
||||
: DEFAULT_BANK_NAME;
|
||||
return pluginConfig.bankIdPrefix ? `${pluginConfig.bankIdPrefix}-openclaw` : 'openclaw';
|
||||
}
|
||||
|
||||
const channelType = ctx?.messageProvider || 'unknown';
|
||||
const userId = ctx?.senderId || 'default';
|
||||
// When no context is available, fall back to the static default bank.
|
||||
if (!ctx) {
|
||||
return pluginConfig.bankIdPrefix ? `${pluginConfig.bankIdPrefix}-openclaw` : 'openclaw';
|
||||
}
|
||||
|
||||
const fields = pluginConfig.dynamicBankGranularity?.length ? pluginConfig.dynamicBankGranularity : ['agent', 'channel', 'user'];
|
||||
|
||||
// Validate field names at runtime — typos silently produce 'unknown' segments
|
||||
const validFields = new Set(['agent', 'channel', 'user', 'provider']);
|
||||
for (const f of fields) {
|
||||
if (!validFields.has(f)) {
|
||||
console.warn(`[Hindsight] Unknown dynamicBankGranularity field "${f}" — will resolve to "unknown" in bank ID. Valid fields: agent, channel, user, provider`);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse sessionKey as fallback when direct context fields are missing
|
||||
const sessionParsed = ctx?.sessionKey ? parseSessionKey(ctx.sessionKey) : {};
|
||||
|
||||
// Warn when 'user' is in active fields but senderId is missing — bank ID will contain "anonymous"
|
||||
if (fields.includes('user') && ctx && !ctx.senderId) {
|
||||
debug('[Hindsight] senderId not available in context — bank ID will use "anonymous". Ensure your OpenClaw provider passes senderId.');
|
||||
}
|
||||
|
||||
const fieldMap: Record<string, string> = {
|
||||
agent: ctx?.agentId || sessionParsed.agentId || 'default',
|
||||
channel: ctx?.channelId || sessionParsed.channel || 'unknown',
|
||||
user: ctx?.senderId || 'anonymous',
|
||||
provider: ctx?.messageProvider || sessionParsed.provider || 'unknown',
|
||||
};
|
||||
|
||||
const baseBankId = fields
|
||||
.map(f => encodeURIComponent(fieldMap[f] || 'unknown'))
|
||||
.join('::');
|
||||
|
||||
// Build bank ID: {prefix?}-{channelType}-{senderId}
|
||||
const baseBankId = `${channelType}-${userId}`;
|
||||
return pluginConfig.bankIdPrefix
|
||||
? `${pluginConfig.bankIdPrefix}-${baseBankId}`
|
||||
: baseBankId;
|
||||
}
|
||||
|
||||
|
||||
export function formatMemories(results: MemoryResult[]): string {
|
||||
if (!results || results.length === 0) return '';
|
||||
return results
|
||||
.map(r => {
|
||||
const type = r.type ? ` [${r.type}]` : '';
|
||||
const date = r.mentioned_at ? ` (${r.mentioned_at})` : '';
|
||||
return `- ${r.text}${type}${date}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
|
||||
// Provider detection from standard env vars
|
||||
const PROVIDER_DETECTION = [
|
||||
{ name: 'openai', keyEnv: 'OPENAI_API_KEY', defaultModel: 'gpt-4o-mini' },
|
||||
|
|
@ -257,8 +506,8 @@ const PROVIDER_DETECTION = [
|
|||
];
|
||||
|
||||
function detectLLMConfig(pluginConfig?: PluginConfig): {
|
||||
provider: string;
|
||||
apiKey: string;
|
||||
provider?: string;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
source: string;
|
||||
|
|
@ -344,6 +593,19 @@ function detectLLMConfig(pluginConfig?: PluginConfig): {
|
|||
}
|
||||
|
||||
// No configuration found - show helpful error
|
||||
|
||||
// Allow empty LLM config if using external Hindsight API (server handles LLM)
|
||||
const externalApiCheck = detectExternalApi(pluginConfig);
|
||||
if (externalApiCheck.apiUrl) {
|
||||
return {
|
||||
provider: undefined,
|
||||
apiKey: undefined,
|
||||
model: undefined,
|
||||
baseUrl: undefined,
|
||||
source: 'external-api-mode-no-llm',
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No LLM configuration found for Hindsight memory plugin.\n\n` +
|
||||
`Option 1: Set a standard provider API key (auto-detect):\n` +
|
||||
|
|
@ -382,13 +644,11 @@ function detectExternalApi(pluginConfig?: PluginConfig): {
|
|||
* Build HindsightClientOptions from LLM config, plugin config, and external API settings.
|
||||
*/
|
||||
function buildClientOptions(
|
||||
llmConfig: { provider: string; apiKey: string; model?: string },
|
||||
llmConfig: { provider?: string; apiKey?: string; model?: string },
|
||||
pluginCfg: PluginConfig,
|
||||
externalApi: { apiUrl: string | null; apiToken: string | null },
|
||||
): HindsightClientOptions {
|
||||
return {
|
||||
llmProvider: llmConfig.provider,
|
||||
llmApiKey: llmConfig.apiKey,
|
||||
llmModel: llmConfig.model,
|
||||
embedVersion: pluginCfg.embedVersion,
|
||||
embedPackagePath: pluginCfg.embedPackagePath,
|
||||
|
|
@ -452,7 +712,22 @@ function getPluginConfig(api: MoltbotPluginAPI): PluginConfig {
|
|||
bankIdPrefix: config.bankIdPrefix,
|
||||
excludeProviders: Array.isArray(config.excludeProviders) ? config.excludeProviders : [],
|
||||
autoRecall: config.autoRecall !== false, // Default: true (on) — backward compatible
|
||||
retainEveryNTurns: config.retainEveryNTurns,
|
||||
dynamicBankGranularity: Array.isArray(config.dynamicBankGranularity) ? config.dynamicBankGranularity : undefined,
|
||||
autoRetain: config.autoRetain !== false, // Default: true
|
||||
retainRoles: Array.isArray(config.retainRoles) ? config.retainRoles : undefined,
|
||||
recallBudget: config.recallBudget || 'mid',
|
||||
recallMaxTokens: config.recallMaxTokens || 1024,
|
||||
recallTypes: Array.isArray(config.recallTypes) ? config.recallTypes : ['world', 'experience'],
|
||||
recallRoles: Array.isArray(config.recallRoles) ? config.recallRoles : ['user', 'assistant'],
|
||||
retainEveryNTurns: typeof config.retainEveryNTurns === 'number' && config.retainEveryNTurns >= 1 ? config.retainEveryNTurns : 1,
|
||||
retainOverlapTurns: typeof config.retainOverlapTurns === 'number' && config.retainOverlapTurns >= 0 ? config.retainOverlapTurns : 0,
|
||||
recallTopK: typeof config.recallTopK === 'number' ? config.recallTopK : undefined,
|
||||
recallContextTurns: typeof config.recallContextTurns === 'number' && config.recallContextTurns >= 1 ? config.recallContextTurns : 1,
|
||||
recallMaxQueryChars: typeof config.recallMaxQueryChars === 'number' && config.recallMaxQueryChars >= 1 ? config.recallMaxQueryChars : 800,
|
||||
recallPromptPreamble:
|
||||
typeof config.recallPromptPreamble === 'string' && config.recallPromptPreamble.trim().length > 0
|
||||
? config.recallPromptPreamble
|
||||
: DEFAULT_RECALL_PROMPT_PREAMBLE,
|
||||
debug: config.debug ?? false,
|
||||
};
|
||||
}
|
||||
|
|
@ -525,7 +800,10 @@ export default function (api: MoltbotPluginAPI) {
|
|||
|
||||
// Initialize client with direct HTTP mode
|
||||
debug('[Hindsight] Creating HindsightClient (HTTP mode)...');
|
||||
client = new HindsightClient(buildClientOptions(llmConfig, pluginConfig, externalApi));
|
||||
clientOptions = buildClientOptions(llmConfig, pluginConfig, externalApi);
|
||||
clientsByBankId.clear();
|
||||
banksWithMissionSet.clear();
|
||||
client = new HindsightClient(clientOptions);
|
||||
|
||||
// Set default bank (will be overridden per-request when dynamic bank IDs are enabled)
|
||||
const defaultBankId = deriveBankId(undefined, pluginConfig);
|
||||
|
|
@ -546,8 +824,8 @@ export default function (api: MoltbotPluginAPI) {
|
|||
debug('[Hindsight] Creating HindsightEmbedManager...');
|
||||
embedManager = new HindsightEmbedManager(
|
||||
apiPort,
|
||||
llmConfig.provider,
|
||||
llmConfig.apiKey,
|
||||
llmConfig.provider || "",
|
||||
llmConfig.apiKey || "",
|
||||
llmConfig.model,
|
||||
llmConfig.baseUrl,
|
||||
pluginConfig.daemonIdleTimeout,
|
||||
|
|
@ -561,7 +839,10 @@ export default function (api: MoltbotPluginAPI) {
|
|||
|
||||
// Initialize client (local daemon mode — no apiUrl)
|
||||
debug('[Hindsight] Creating HindsightClient (subprocess mode)...');
|
||||
client = new HindsightClient(buildClientOptions(llmConfig, pluginConfig, { apiUrl: null, apiToken: null }));
|
||||
clientOptions = buildClientOptions(llmConfig, pluginConfig, { apiUrl: null, apiToken: null });
|
||||
clientsByBankId.clear();
|
||||
banksWithMissionSet.clear();
|
||||
client = new HindsightClient(clientOptions);
|
||||
|
||||
// Set default bank (will be overridden per-request when dynamic bank IDs are enabled)
|
||||
const defaultBankId = deriveBankId(undefined, pluginConfig);
|
||||
|
|
@ -616,6 +897,9 @@ export default function (api: MoltbotPluginAPI) {
|
|||
console.error('[Hindsight] External API health check failed:', error);
|
||||
// Reset state for reinitialization attempt
|
||||
client = null;
|
||||
clientOptions = null;
|
||||
clientsByBankId.clear();
|
||||
banksWithMissionSet.clear();
|
||||
isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -632,6 +916,9 @@ export default function (api: MoltbotPluginAPI) {
|
|||
// Reset state for reinitialization
|
||||
embedManager = null;
|
||||
client = null;
|
||||
clientOptions = null;
|
||||
clientsByBankId.clear();
|
||||
banksWithMissionSet.clear();
|
||||
isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -655,7 +942,10 @@ export default function (api: MoltbotPluginAPI) {
|
|||
|
||||
await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken);
|
||||
|
||||
client = new HindsightClient(buildClientOptions(llmConfig, reinitPluginConfig, externalApi));
|
||||
clientOptions = buildClientOptions(llmConfig, reinitPluginConfig, externalApi);
|
||||
clientsByBankId.clear();
|
||||
banksWithMissionSet.clear();
|
||||
client = new HindsightClient(clientOptions);
|
||||
const defaultBankId = deriveBankId(undefined, reinitPluginConfig);
|
||||
client.setBankId(defaultBankId);
|
||||
|
||||
|
|
@ -669,8 +959,8 @@ export default function (api: MoltbotPluginAPI) {
|
|||
// Local daemon mode
|
||||
embedManager = new HindsightEmbedManager(
|
||||
apiPort,
|
||||
llmConfig.provider,
|
||||
llmConfig.apiKey,
|
||||
llmConfig.provider || "",
|
||||
llmConfig.apiKey || "",
|
||||
llmConfig.model,
|
||||
llmConfig.baseUrl,
|
||||
reinitPluginConfig.daemonIdleTimeout,
|
||||
|
|
@ -680,7 +970,10 @@ export default function (api: MoltbotPluginAPI) {
|
|||
|
||||
await embedManager.start();
|
||||
|
||||
client = new HindsightClient(buildClientOptions(llmConfig, reinitPluginConfig, { apiUrl: null, apiToken: null }));
|
||||
clientOptions = buildClientOptions(llmConfig, reinitPluginConfig, { apiUrl: null, apiToken: null });
|
||||
clientsByBankId.clear();
|
||||
banksWithMissionSet.clear();
|
||||
client = new HindsightClient(clientOptions);
|
||||
const defaultBankId = deriveBankId(undefined, reinitPluginConfig);
|
||||
client.setBankId(defaultBankId);
|
||||
|
||||
|
|
@ -705,6 +998,9 @@ export default function (api: MoltbotPluginAPI) {
|
|||
}
|
||||
|
||||
client = null;
|
||||
clientOptions = null;
|
||||
clientsByBankId.clear();
|
||||
banksWithMissionSet.clear();
|
||||
isInitialized = false;
|
||||
|
||||
debug('[Hindsight] Service stopped');
|
||||
|
|
@ -718,22 +1014,17 @@ export default function (api: MoltbotPluginAPI) {
|
|||
debug('[Hindsight] Plugin loaded successfully');
|
||||
|
||||
// Register agent hooks for auto-recall and auto-retention
|
||||
if (registeredApis.has(api)) {
|
||||
debug('[Hindsight] Hooks already registered for this api instance, skipping duplicate registration');
|
||||
return;
|
||||
}
|
||||
registeredApis.add(api);
|
||||
debug('[Hindsight] Registering agent hooks...');
|
||||
|
||||
// Store session key and context for retention
|
||||
let currentSessionKey: string | undefined;
|
||||
let currentAgentContext: PluginHookAgentContext | undefined;
|
||||
|
||||
// Auto-recall: Inject relevant memories before agent processes the message
|
||||
// Hook signature: (event, ctx) where event has {prompt, messages?} and ctx has agent context
|
||||
api.on('before_agent_start', async (event: any, ctx?: PluginHookAgentContext) => {
|
||||
api.on('before_prompt_build', async (event: any, ctx?: PluginHookAgentContext) => {
|
||||
try {
|
||||
// Capture session key and context for use in agent_end
|
||||
if (ctx?.sessionKey) {
|
||||
currentSessionKey = ctx.sessionKey;
|
||||
}
|
||||
currentAgentContext = ctx;
|
||||
|
||||
// Check if this provider is excluded
|
||||
if (ctx?.messageProvider && pluginConfig.excludeProviders?.includes(ctx.messageProvider)) {
|
||||
debug(`[Hindsight] Skipping recall for excluded provider: ${ctx.messageProvider}`);
|
||||
|
|
@ -746,22 +1037,52 @@ export default function (api: MoltbotPluginAPI) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Derive bank ID from context
|
||||
const bankId = deriveBankId(ctx, pluginConfig);
|
||||
debug(`[Hindsight] before_agent_start - bank: ${bankId}, channel: ${ctx?.messageProvider}/${ctx?.channelId}`);
|
||||
// Derive bank ID from context — enrich ctx.senderId from the inbound metadata
|
||||
// block when it's missing (agent-phase hooks don't carry senderId in ctx directly).
|
||||
const senderIdFromPrompt = !ctx?.senderId ? extractSenderIdFromText(event.prompt ?? event.rawMessage ?? '') : undefined;
|
||||
const effectiveCtxForRecall = senderIdFromPrompt ? { ...ctx, senderId: senderIdFromPrompt } : ctx;
|
||||
|
||||
// Cache the resolved sender ID keyed by sessionKey so agent_end can use it.
|
||||
// event.messages in agent_end is clean history without the metadata blocks.
|
||||
const resolvedSenderId = effectiveCtxForRecall?.senderId;
|
||||
const sessionKeyForCache = ctx?.sessionKey;
|
||||
if (resolvedSenderId && sessionKeyForCache) {
|
||||
senderIdBySession.set(sessionKeyForCache, resolvedSenderId);
|
||||
if (senderIdBySession.size > MAX_TRACKED_SESSIONS) {
|
||||
const oldest = senderIdBySession.keys().next().value;
|
||||
if (oldest) senderIdBySession.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
const bankId = deriveBankId(effectiveCtxForRecall, pluginConfig);
|
||||
debug(`[Hindsight] before_prompt_build - bank: ${bankId}, channel: ${ctx?.messageProvider}/${ctx?.channelId}`);
|
||||
debug(`[Hindsight] event keys: ${Object.keys(event).join(', ')}`);
|
||||
debug(`[Hindsight] event.context keys: ${Object.keys(event.context ?? {}).join(', ')}`);
|
||||
|
||||
// Get the user's latest message for recall — only the raw user text, not the full prompt
|
||||
// rawMessage is clean user text; prompt includes envelope, system events, media notes, etc.
|
||||
debug(`[Hindsight] extractRecallQuery input lengths - raw: ${event.rawMessage?.length ?? 0}, prompt: ${event.prompt?.length ?? 0}`);
|
||||
const extracted = extractRecallQuery(event.rawMessage, event.prompt);
|
||||
if (!extracted) {
|
||||
debug('[Hindsight] extractRecallQuery returned null, skipping recall');
|
||||
return;
|
||||
}
|
||||
let prompt = extracted;
|
||||
debug(`[Hindsight] extractRecallQuery result length: ${extracted.length}`);
|
||||
const recallContextTurns = pluginConfig.recallContextTurns ?? 1;
|
||||
const recallMaxQueryChars = pluginConfig.recallMaxQueryChars ?? 800;
|
||||
const sessionMessages = event.context?.sessionEntry?.messages ?? event.messages ?? [];
|
||||
const messageCount = sessionMessages.length;
|
||||
debug(`[Hindsight] event.messages count: ${messageCount}, roles: ${sessionMessages.map((m: any) => m.role).join(',')}`);
|
||||
if (recallContextTurns > 1 && messageCount === 0) {
|
||||
debug('[Hindsight] recallContextTurns > 1 but event.messages is empty — prior context unavailable at before_agent_start for this provider');
|
||||
}
|
||||
const recallRoles = pluginConfig.recallRoles ?? ['user', 'assistant'];
|
||||
const composedPrompt = composeRecallQuery(extracted, sessionMessages, recallContextTurns, recallRoles);
|
||||
let prompt = truncateRecallQuery(composedPrompt, extracted, recallMaxQueryChars);
|
||||
|
||||
// Truncate — Hindsight API recall has a 500 token limit; 800 chars stays safely under even with non-ASCII
|
||||
const MAX_RECALL_QUERY_CHARS = 800;
|
||||
if (prompt.length > MAX_RECALL_QUERY_CHARS) {
|
||||
prompt = prompt.substring(0, MAX_RECALL_QUERY_CHARS);
|
||||
// Final defensive cap
|
||||
if (prompt.length > recallMaxQueryChars) {
|
||||
prompt = prompt.substring(0, recallMaxQueryChars);
|
||||
}
|
||||
|
||||
// Wait for client to be ready
|
||||
|
|
@ -774,23 +1095,25 @@ export default function (api: MoltbotPluginAPI) {
|
|||
await clientGlobal.waitForReady();
|
||||
|
||||
// Get client configured for this context's bank (async to handle mission setup)
|
||||
const client = await clientGlobal.getClientForContext(ctx);
|
||||
const client = await clientGlobal.getClientForContext(effectiveCtxForRecall);
|
||||
if (!client) {
|
||||
debug('[Hindsight] Client not initialized, skipping auto-recall');
|
||||
return;
|
||||
}
|
||||
|
||||
debug(`[Hindsight] Auto-recall for bank ${bankId}, prompt: ${prompt.substring(0, 50)}`);
|
||||
debug(`[Hindsight] Auto-recall for bank ${bankId}, full query:\n---\n${prompt}\n---`);
|
||||
|
||||
// Recall with deduplication: reuse in-flight request for same bank
|
||||
const recallKey = bankId;
|
||||
const normalizedPrompt = prompt.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
const queryHash = createHash('sha256').update(normalizedPrompt).digest('hex').slice(0, 16);
|
||||
const recallKey = `${bankId}::${queryHash}`;
|
||||
const existing = inflightRecalls.get(recallKey);
|
||||
let recallPromise: Promise<RecallResponse>;
|
||||
if (existing) {
|
||||
debug(`[Hindsight] Reusing in-flight recall for bank ${bankId}`);
|
||||
recallPromise = existing;
|
||||
} else {
|
||||
recallPromise = client.recall({ query: prompt, max_tokens: 2048 }, RECALL_TIMEOUT_MS);
|
||||
recallPromise = client.recall({ query: prompt, max_tokens: pluginConfig.recallMaxTokens || 1024, budget: pluginConfig.recallBudget, types: pluginConfig.recallTypes }, RECALL_TIMEOUT_MS);
|
||||
inflightRecalls.set(recallKey, recallPromise);
|
||||
void recallPromise.catch(() => {}).finally(() => inflightRecalls.delete(recallKey));
|
||||
}
|
||||
|
|
@ -802,17 +1125,23 @@ export default function (api: MoltbotPluginAPI) {
|
|||
return;
|
||||
}
|
||||
|
||||
debug(`[Hindsight] Raw recall response (${response.results.length} results before topK):\n${response.results.map((r: any, i: number) => ` [${i}] score=${r.score?.toFixed(3) ?? 'n/a'} type=${r.type ?? 'n/a'}: ${JSON.stringify(r.content ?? r.text ?? r).substring(0, 200)}`).join('\n')}`);
|
||||
|
||||
const results = pluginConfig.recallTopK ? response.results.slice(0, pluginConfig.recallTopK) : response.results;
|
||||
|
||||
debug(`[Hindsight] After topK (${pluginConfig.recallTopK ?? 'unlimited'}): ${results.length} results injected`);
|
||||
|
||||
// Format memories as JSON with all fields from recall
|
||||
const memoriesJson = JSON.stringify(response.results, null, 2);
|
||||
const memoriesFormatted = formatMemories(results);
|
||||
|
||||
const contextMessage = `<hindsight_memories>
|
||||
Relevant memories from past conversations (prioritize recent when conflicting):
|
||||
${memoriesJson}
|
||||
${pluginConfig.recallPromptPreamble || DEFAULT_RECALL_PROMPT_PREAMBLE}
|
||||
Current time - ${formatCurrentTimeForRecall()}
|
||||
|
||||
User message: ${prompt}
|
||||
${memoriesFormatted}
|
||||
</hindsight_memories>`;
|
||||
|
||||
debug(`[Hindsight] Auto-recall: Injecting ${response.results.length} memories from bank ${bankId}`);
|
||||
debug(`[Hindsight] Auto-recall: Injecting ${results.length} memories from bank ${bankId}`);
|
||||
|
||||
// Inject context before the user message
|
||||
return { prependContext: contextMessage };
|
||||
|
|
@ -831,8 +1160,9 @@ User message: ${prompt}
|
|||
// Hook signature: (event, ctx) where event has {messages, success, error?, durationMs?}
|
||||
api.on('agent_end', async (event: any, ctx?: PluginHookAgentContext) => {
|
||||
try {
|
||||
// Use context from this hook, or fall back to context captured in before_agent_start
|
||||
const effectiveCtx = ctx || currentAgentContext;
|
||||
// Avoid cross-session contamination: only use context carried by this event.
|
||||
const eventSessionKey = typeof event?.sessionKey === 'string' ? event.sessionKey : undefined;
|
||||
const effectiveCtx = ctx || (eventSessionKey ? ({ sessionKey: eventSessionKey } as PluginHookAgentContext) : undefined);
|
||||
|
||||
// Check if this provider is excluded
|
||||
if (effectiveCtx?.messageProvider && pluginConfig.excludeProviders?.includes(effectiveCtx.messageProvider)) {
|
||||
|
|
@ -840,16 +1170,72 @@ User message: ${prompt}
|
|||
return;
|
||||
}
|
||||
|
||||
// Derive bank ID from context
|
||||
const bankId = deriveBankId(effectiveCtx, pluginConfig);
|
||||
// Derive bank ID from context — enrich ctx.senderId from the session cache.
|
||||
// event.messages in agent_end is clean history without OpenClaw's metadata blocks;
|
||||
// the sender ID was captured during before_prompt_build where event.prompt has them.
|
||||
const sessionKeyForLookup = effectiveCtx?.sessionKey;
|
||||
const senderIdFromCache = !effectiveCtx?.senderId && sessionKeyForLookup
|
||||
? senderIdBySession.get(sessionKeyForLookup)
|
||||
: undefined;
|
||||
const effectiveCtxForRetain = senderIdFromCache ? { ...effectiveCtx, senderId: senderIdFromCache } : effectiveCtx;
|
||||
const bankId = deriveBankId(effectiveCtxForRetain, pluginConfig);
|
||||
debug(`[Hindsight Hook] agent_end triggered - bank: ${bankId}`);
|
||||
|
||||
// Check event success and messages
|
||||
if (!event.success || !Array.isArray(event.messages) || event.messages.length === 0) {
|
||||
debug('[Hindsight Hook] Skipping: success:', event.success, 'messages:', event.messages?.length);
|
||||
if (event.success === false) {
|
||||
debug('[Hindsight Hook] Agent run failed, skipping retention');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(event.context?.sessionEntry?.messages ?? event.messages) || (event.context?.sessionEntry?.messages ?? event.messages ?? []).length === 0) {
|
||||
debug('[Hindsight Hook] No messages in event, skipping retention');
|
||||
return;
|
||||
}
|
||||
|
||||
if (pluginConfig.autoRetain === false) {
|
||||
debug('[Hindsight Hook] autoRetain is disabled, skipping retention');
|
||||
return;
|
||||
}
|
||||
|
||||
// Chunked retention: skip non-Nth turns and use a sliding window when firing
|
||||
const retainEveryN = pluginConfig.retainEveryNTurns ?? 1;
|
||||
const allMessages = event.context?.sessionEntry?.messages ?? event.messages ?? [];
|
||||
let messagesToRetain = allMessages;
|
||||
let retainFullWindow = false;
|
||||
|
||||
if (retainEveryN > 1) {
|
||||
const sessionTrackingKey = `${bankId}:${effectiveCtx?.sessionKey || 'session'}`;
|
||||
const turnCount = (turnCountBySession.get(sessionTrackingKey) || 0) + 1;
|
||||
turnCountBySession.set(sessionTrackingKey, turnCount);
|
||||
if (turnCountBySession.size > MAX_TRACKED_SESSIONS) {
|
||||
const oldestKey = turnCountBySession.keys().next().value;
|
||||
if (oldestKey) {
|
||||
turnCountBySession.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (turnCount % retainEveryN !== 0) {
|
||||
const nextRetainAt = Math.ceil(turnCount / retainEveryN) * retainEveryN;
|
||||
debug(`[Hindsight Hook] Turn ${turnCount}/${retainEveryN}, skipping retain (next at turn ${nextRetainAt})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sliding window in turns: N turns + configured overlap turns.
|
||||
// We slice by actual turn boundaries (user-role messages), so this
|
||||
// remains stable even when system/tool messages are present.
|
||||
const overlapTurns = pluginConfig.retainOverlapTurns ?? 0;
|
||||
const windowTurns = retainEveryN + overlapTurns;
|
||||
messagesToRetain = sliceLastTurnsByUserBoundary(allMessages, windowTurns);
|
||||
retainFullWindow = true;
|
||||
debug(`[Hindsight Hook] Turn ${turnCount}: chunked retain firing (window: ${windowTurns} turns, ${messagesToRetain.length} messages)`);
|
||||
}
|
||||
|
||||
const retention = prepareRetentionTranscript(messagesToRetain, pluginConfig, retainFullWindow);
|
||||
if (!retention) {
|
||||
debug('[Hindsight Hook] No messages to retain (filtered/short/no-user)');
|
||||
return;
|
||||
}
|
||||
const { transcript, messageCount } = retention;
|
||||
|
||||
// Wait for client to be ready
|
||||
const clientGlobal = (global as any).__hindsightClient;
|
||||
if (!clientGlobal) {
|
||||
|
|
@ -860,79 +1246,32 @@ User message: ${prompt}
|
|||
await clientGlobal.waitForReady();
|
||||
|
||||
// Get client configured for this context's bank (async to handle mission setup)
|
||||
const client = await clientGlobal.getClientForContext(effectiveCtx);
|
||||
const client = await clientGlobal.getClientForContext(effectiveCtxForRetain);
|
||||
if (!client) {
|
||||
console.warn('[Hindsight] Client not initialized, skipping retain');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Chunked retention: only retain every Nth turn ---
|
||||
const retainEveryN = pluginConfig.retainEveryNTurns ?? 10;
|
||||
let messagesToRetain = event.messages;
|
||||
|
||||
if (retainEveryN > 1) {
|
||||
const sessionTrackingKey = `${bankId}:${effectiveCtx?.sessionKey || currentSessionKey || 'session'}`;
|
||||
const turnCount = (turnCountBySession.get(sessionTrackingKey) || 0) + 1;
|
||||
turnCountBySession.set(sessionTrackingKey, turnCount);
|
||||
|
||||
if (turnCount % retainEveryN !== 0) {
|
||||
const nextRetain = Math.ceil(turnCount / retainEveryN) * retainEveryN;
|
||||
debug(`[Hindsight Hook] Skipping retain (turn ${turnCount}, next at ${nextRetain})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sliding window: N turns of new content + 2-turn overlap for context continuity
|
||||
const windowSize = retainEveryN * 2 + 4;
|
||||
messagesToRetain = event.messages.slice(-windowSize);
|
||||
debug(`[Hindsight Hook] Chunked retain at turn ${turnCount} \u2014 last ${messagesToRetain.length} msgs`);
|
||||
}
|
||||
|
||||
// Format messages into a transcript
|
||||
const transcript = messagesToRetain
|
||||
.map((msg: any) => {
|
||||
const role = msg.role || 'unknown';
|
||||
let content = '';
|
||||
|
||||
// Handle different content formats
|
||||
if (typeof msg.content === 'string') {
|
||||
content = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
content = msg.content
|
||||
.filter((block: any) => block.type === 'text')
|
||||
.map((block: any) => block.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// Strip plugin-injected memory tags to prevent feedback loop
|
||||
content = stripMemoryTags(content);
|
||||
|
||||
return `[role: ${role}]\n${content}\n[${role}:end]`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
if (!transcript.trim() || transcript.length < 10) {
|
||||
debug('[Hindsight Hook] Transcript too short, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use unique document ID per conversation (sessionKey + timestamp)
|
||||
// Static sessionKey (e.g. "agent:main:main") causes CASCADE delete of old memories
|
||||
const documentId = `${effectiveCtx?.sessionKey || currentSessionKey || 'session'}-${Date.now()}`;
|
||||
const documentId = `${effectiveCtx?.sessionKey || 'session'}-${Date.now()}`;
|
||||
|
||||
// Retain to Hindsight
|
||||
debug(`[Hindsight] Retaining to bank ${bankId}, document: ${documentId}, chars: ${transcript.length}\n---\n${transcript.substring(0, 500)}${transcript.length > 500 ? '\n...(truncated)' : ''}\n---`);
|
||||
await client.retain({
|
||||
content: transcript,
|
||||
document_id: documentId,
|
||||
metadata: {
|
||||
retained_at: new Date().toISOString(),
|
||||
message_count: String(messagesToRetain.length),
|
||||
message_count: String(messageCount),
|
||||
channel_type: effectiveCtx?.messageProvider,
|
||||
channel_id: effectiveCtx?.channelId,
|
||||
sender_id: effectiveCtx?.senderId,
|
||||
},
|
||||
});
|
||||
|
||||
debug(`[Hindsight] Retained ${messagesToRetain.length} messages to bank ${bankId} for session ${documentId}`);
|
||||
debug(`[Hindsight] Retained ${messageCount} messages to bank ${bankId} for session ${documentId}`);
|
||||
} catch (error) {
|
||||
console.error('[Hindsight] Error retaining messages:', error);
|
||||
}
|
||||
|
|
@ -948,6 +1287,102 @@ User message: ${prompt}
|
|||
}
|
||||
|
||||
// Export client getter for tools
|
||||
|
||||
export function prepareRetentionTranscript(
|
||||
messages: any[],
|
||||
pluginConfig: PluginConfig,
|
||||
retainFullWindow = false
|
||||
): { transcript: string; messageCount: number } | null {
|
||||
if (!messages || messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let targetMessages: any[];
|
||||
if (retainFullWindow) {
|
||||
// Chunked retention: retain the full sliding window (already sliced by caller)
|
||||
targetMessages = messages;
|
||||
} else {
|
||||
// Default: retain only the last turn (user message + assistant responses)
|
||||
let lastUserIdx = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === 'user') {
|
||||
lastUserIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastUserIdx === -1) {
|
||||
return null; // No user message found in turn
|
||||
}
|
||||
targetMessages = messages.slice(lastUserIdx);
|
||||
}
|
||||
|
||||
// Role filtering
|
||||
const allowedRoles = new Set(pluginConfig.retainRoles || ['user', 'assistant']);
|
||||
const filteredMessages = targetMessages.filter((m: any) => allowedRoles.has(m.role));
|
||||
|
||||
if (filteredMessages.length === 0) {
|
||||
return null; // No messages to retain
|
||||
}
|
||||
|
||||
// Format messages into a transcript
|
||||
const transcriptParts = filteredMessages
|
||||
.map((msg: any) => {
|
||||
const role = msg.role || 'unknown';
|
||||
let content = '';
|
||||
|
||||
// Handle different content formats
|
||||
if (typeof msg.content === 'string') {
|
||||
content = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
content = msg.content
|
||||
.filter((block: any) => block.type === 'text')
|
||||
.map((block: any) => block.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// Strip plugin-injected memory tags and metadata envelopes to prevent feedback loop
|
||||
content = stripMemoryTags(content);
|
||||
content = stripMetadataEnvelopes(content);
|
||||
|
||||
return content.trim() ? `[role: ${role}]\n${content}\n[${role}:end]` : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const transcript = transcriptParts.join('\n\n');
|
||||
|
||||
if (!transcript.trim() || transcript.length < 10) {
|
||||
return null; // Transcript too short
|
||||
}
|
||||
|
||||
return { transcript, messageCount: transcriptParts.length };
|
||||
}
|
||||
|
||||
export function sliceLastTurnsByUserBoundary(messages: any[], turns: number): any[] {
|
||||
if (!Array.isArray(messages) || messages.length === 0 || turns <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let userTurnsSeen = 0;
|
||||
let startIndex = -1;
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i]?.role === 'user') {
|
||||
userTurnsSeen += 1;
|
||||
if (userTurnsSeen >= turns) {
|
||||
startIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (startIndex === -1) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
return messages.slice(startIndex);
|
||||
}
|
||||
|
||||
|
||||
export function getClient() {
|
||||
return client;
|
||||
}
|
||||
|
|
|
|||
22
hindsight-integrations/openclaw/src/remote-no-llm.test.ts
Normal file
22
hindsight-integrations/openclaw/src/remote-no-llm.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { HindsightClient } from './client.js';
|
||||
|
||||
describe('HindsightClient remote mode without LLM config', () => {
|
||||
it('should initialize successfully with only apiUrl and apiToken', () => {
|
||||
const client = new HindsightClient({
|
||||
apiUrl: 'https://api.example.com',
|
||||
apiToken: 'secret-token',
|
||||
});
|
||||
|
||||
expect(client).toBeDefined();
|
||||
expect(client).toBeInstanceOf(HindsightClient);
|
||||
});
|
||||
|
||||
it('should allow initialization with partial config', () => {
|
||||
const client = new HindsightClient({
|
||||
apiUrl: 'https://api.example.com',
|
||||
llmModel: 'gpt-4o-mini',
|
||||
});
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -28,6 +28,15 @@ export interface MoltbotConfig {
|
|||
};
|
||||
}
|
||||
|
||||
export interface PluginHookAgentContext {
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
workspaceDir?: string;
|
||||
messageProvider?: string;
|
||||
channelId?: string;
|
||||
senderId?: string;
|
||||
}
|
||||
|
||||
export interface PluginConfig {
|
||||
bankMission?: string;
|
||||
embedPort?: number;
|
||||
|
|
@ -44,7 +53,19 @@ export interface PluginConfig {
|
|||
bankIdPrefix?: string; // Prefix for bank IDs (e.g. 'prod' -> 'prod-slack-C123')
|
||||
excludeProviders?: string[]; // Message providers to exclude from recall/retain (e.g. ['telegram', 'discord'])
|
||||
autoRecall?: boolean; // Auto-recall memories on every prompt (default: true). Set to false when agent has its own recall tool.
|
||||
retainEveryNTurns?: number; // Retain every Nth turn instead of every turn (default: 10). Reduces O(n²) storage growth for long sessions.
|
||||
dynamicBankGranularity?: Array<'agent' | 'provider' | 'channel' | 'user'>; // Fields for bank ID derivation. Default: ['agent', 'channel', 'user']
|
||||
autoRetain?: boolean; // Default: true
|
||||
retainRoles?: Array<'user' | 'assistant' | 'system' | 'tool'>; // Roles to include in retained transcript. Default: ['user', 'assistant']
|
||||
recallBudget?: 'low' | 'mid' | 'high'; // Recall effort. Default: 'mid'
|
||||
recallMaxTokens?: number; // Max tokens for recall response. Default: 1024
|
||||
recallTypes?: Array<'world' | 'experience' | 'observation'>; // Memory types to recall. Default: ['world', 'experience']
|
||||
recallRoles?: Array<'user' | 'assistant' | 'system' | 'tool'>; // Roles to include when composing contextual recall query. Default: ['user', 'assistant']
|
||||
retainEveryNTurns?: number; // Retain every Nth turn (1 = every turn, default: 1). Values > 1 enable chunked retention.
|
||||
retainOverlapTurns?: number; // Extra prior turns included when chunked retention fires (default: 0). Window = retainEveryNTurns + retainOverlapTurns.
|
||||
recallTopK?: number; // Max number of memories to inject. Default: unlimited
|
||||
recallContextTurns?: number; // Number of user turns to include in recall query context. Default: 1 (latest only)
|
||||
recallMaxQueryChars?: number; // Max chars for composed recall query. Default: 800
|
||||
recallPromptPreamble?: string; // Prompt preamble placed above recalled memories. Default: built-in guidance text.
|
||||
debug?: boolean; // Enable debug logging (default: false)
|
||||
}
|
||||
|
||||
|
|
@ -71,6 +92,8 @@ export interface RetainResponse {
|
|||
export interface RecallRequest {
|
||||
query: string;
|
||||
max_tokens?: number;
|
||||
budget?: 'low' | 'mid' | 'high';
|
||||
types?: Array<'world' | 'experience' | 'observation'>;
|
||||
}
|
||||
|
||||
export interface RecallResponse {
|
||||
|
|
|
|||
|
|
@ -141,6 +141,9 @@ beforeAll(async () => {
|
|||
dynamicBankId: true,
|
||||
excludeProviders: ['slack'],
|
||||
retainEveryNTurns: 1, // retain every turn so individual tests aren't affected by chunking
|
||||
recallContextTurns: 3,
|
||||
recallMaxQueryChars: 180,
|
||||
recallRoles: ['user'],
|
||||
// No bankMission — keeps init lean
|
||||
});
|
||||
triggerHook = handle.trigger;
|
||||
|
|
@ -240,7 +243,7 @@ describe('before_agent_start hook', () => {
|
|||
expect(result.prependContext).toContain('</hindsight_memories>');
|
||||
});
|
||||
|
||||
it('injects all memory result fields in the prependContext JSON', async () => {
|
||||
it('injects all memory result fields in the prependContext', async () => {
|
||||
if (!apiReachable) return;
|
||||
const mem = makeMemoryResult('User prefers dark mode');
|
||||
mem.tags = ['preference'];
|
||||
|
|
@ -258,17 +261,10 @@ describe('before_agent_start hook', () => {
|
|||
{ messageProvider: 'telegram', senderId: 'U004' },
|
||||
)) as { prependContext: string };
|
||||
|
||||
// The prependContext should be valid JSON containing all MemoryResult fields
|
||||
const jsonStart = result.prependContext.indexOf('[');
|
||||
const jsonEnd = result.prependContext.lastIndexOf(']') + 1;
|
||||
const parsed = JSON.parse(result.prependContext.slice(jsonStart, jsonEnd)) as unknown[];
|
||||
expect(parsed).toHaveLength(1);
|
||||
const first = parsed[0] as Record<string, unknown>;
|
||||
expect(first.id).toBe(mem.id);
|
||||
expect(first.text).toBe('User prefers dark mode');
|
||||
expect(first.type).toBe('fact');
|
||||
expect(first.tags).toEqual(['preference']);
|
||||
expect(first.entities).toEqual(['dark_mode']);
|
||||
// formatMemories returns a bullet list, not JSON
|
||||
expect(result.prependContext).toContain('- User prefers dark mode');
|
||||
expect(result.prependContext).toContain('<hindsight_memories>');
|
||||
expect(result.prependContext).toContain('</hindsight_memories>');
|
||||
});
|
||||
|
||||
it('extracts the inner query from an envelope-formatted prompt when rawMessage is absent', async () => {
|
||||
|
|
@ -290,6 +286,32 @@ describe('before_agent_start hook', () => {
|
|||
expect(callArgs.query).toContain('What is my favorite food?');
|
||||
});
|
||||
|
||||
it('passes a latest-priority contextual recall query and respects max query chars', async () => {
|
||||
if (!apiReachable) return;
|
||||
recallSpy.mockResolvedValue(EMPTY_RECALL);
|
||||
|
||||
await triggerHook(
|
||||
'before_prompt_build',
|
||||
{
|
||||
rawMessage: 'Do I still prefer dark mode?',
|
||||
prompt: '',
|
||||
messages: [
|
||||
{ role: 'user', content: 'I prefer dark mode in IDEs.' },
|
||||
{ role: 'assistant', content: 'Noted: dark mode preference.' },
|
||||
{ role: 'user', content: 'Do I still prefer dark mode?' },
|
||||
],
|
||||
},
|
||||
{ messageProvider: 'telegram', senderId: 'U006A' },
|
||||
);
|
||||
|
||||
expect(recallSpy).toHaveBeenCalledOnce();
|
||||
const [callArgs] = recallSpy.mock.calls[0];
|
||||
expect(callArgs.query).toContain('Do I still prefer dark mode?');
|
||||
expect(callArgs.query).toContain('user: I prefer dark mode in IDEs.');
|
||||
expect(callArgs.query).not.toContain('assistant: Noted: dark mode preference.');
|
||||
expect(callArgs.query.length).toBeLessThanOrEqual(180);
|
||||
});
|
||||
|
||||
it('passes max_tokens to recall', async () => {
|
||||
if (!apiReachable) return;
|
||||
recallSpy.mockResolvedValue(EMPTY_RECALL);
|
||||
|
|
@ -534,10 +556,11 @@ describe('agent_end hook', () => {
|
|||
expect(retainSpy).toHaveBeenCalledOnce();
|
||||
const [req] = retainSpy.mock.calls[0];
|
||||
|
||||
// Each message should appear in the correct envelope format
|
||||
expect(req.content).toContain('[role: user]\nMy name is Carol.\n[user:end]');
|
||||
expect(req.content).toContain('[role: assistant]\nNice to meet you, Carol!\n[assistant:end]');
|
||||
// Only the last turn (from last user message onwards) is retained
|
||||
expect(req.content).toContain('[role: user]\nI work as a data scientist.\n[user:end]');
|
||||
expect(req.metadata?.message_count).toBe('4');
|
||||
expect(req.content).toContain("[role: assistant]\nThat's a fascinating career!\n[assistant:end]");
|
||||
// Earlier turns are excluded by turn boundary detection
|
||||
expect(req.content).not.toContain('My name is Carol.');
|
||||
expect(req.metadata?.message_count).toBe('2');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue