feat: Add Claude Code integration plugin (#651)

* feat: Add Claude Code integration plugin

Complete port of hindsight-openclaw (v0.4.19) adapted to Claude Code's
hook-based plugin architecture. Pure Python stdlib, no external dependencies.

- Auto-recall via UserPromptSubmit hook (additionalContext injection)
- Auto-retain via async Stop hook (chunked retention with sliding window)
- Daemon management (auto-start/stop hindsight-embed via uvx)
- Dynamic bank IDs with per-agent/project/channel/user granularity
- All 34 configuration options with env var overrides
- File-based state persistence with fcntl locking
- Graceful degradation on all error paths

Works with Claude Code Channels (Telegram, Discord, Slack) and
interactive sessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Set correct chunked retention defaults (10/2, not 1/0)

retainEveryNTurns=10 and retainOverlapTurns=2 are the production-tested
values — every 10 turns, retain a 12-turn sliding window. The previous
defaults (1/0) would retain every single turn with no overlap, defeating
the chunked retention design that prevents API bombardment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Align recallBudget and daemonIdleTimeout with Openclaw defaults

recallBudget: "low" → "mid" (Openclaw default)
daemonIdleTimeout: 300 → 0 (Openclaw default, never auto-stop)

As an official Hindsight integration, defaults should match Openclaw.
Users can optimize locally via settings.json or env vars.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabio Scarsi 2026-03-23 12:06:54 +01:00 committed by GitHub
parent e0f0da5d2d
commit f4390bdc2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 2248 additions and 0 deletions

View file

@ -0,0 +1,8 @@
{
"name": "hindsight-memory",
"description": "Automatic long-term memory for Claude Code via Hindsight. Recalls relevant memories before each prompt and retains conversation transcripts after each response.",
"version": "0.1.0",
"author": "Fabio Scarsi",
"license": "MIT",
"keywords": ["memory", "hindsight", "recall", "retain"]
}

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Vectorize AI, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,269 @@
# Hindsight Memory Plugin for Claude Code
Biomimetic long-term memory for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) using [Hindsight](https://vectorize.io/hindsight). Automatically captures conversations and intelligently recalls relevant context — a complete port of [`hindsight-openclaw`](../openclaw/) adapted to Claude Code's hook-based plugin architecture.
## Quick Start
```bash
# 1. Configure your LLM provider for memory extraction
# Option A: OpenAI (auto-detected)
export OPENAI_API_KEY="sk-your-key"
# Option B: Anthropic (auto-detected)
export ANTHROPIC_API_KEY="your-key"
# Option C: Connect to an external Hindsight server (no local LLM needed)
# Edit settings.json: set "hindsightApiUrl": "https://your-hindsight-server.com"
# 2. Install the plugin
claude /plugin install /path/to/hindsight-integrations/claude-code
# 3. Start Claude Code — the plugin activates automatically
claude
```
That's it! The plugin will automatically start capturing and recalling memories.
## Features
- **Auto-recall** — on every user prompt, queries Hindsight for relevant memories and injects them as context (invisible to the chat transcript, visible to Claude)
- **Auto-retain** — after every response (or every N turns), extracts and retains conversation content to Hindsight for long-term storage
- **Daemon management** — can auto-start/stop `hindsight-embed` locally or connect to an external Hindsight server
- **Dynamic bank IDs** — supports per-agent, per-project, or per-session memory isolation
- **Channel-agnostic** — works with Claude Code Channels (Telegram, Discord, Slack) or interactive sessions
- **Zero dependencies** — pure Python stdlib, no pip install required
## Architecture
The plugin uses all four Claude Code hook events:
| Hook | Event | Purpose |
|------|-------|---------|
| `session_start.py` | `SessionStart` | Health check — verify Hindsight is reachable |
| `recall.py` | `UserPromptSubmit` | **Auto-recall** — query memories, inject as `additionalContext` |
| `retain.py` | `Stop` | **Auto-retain** — extract transcript, POST to Hindsight (async) |
| `session_end.py` | `SessionEnd` | Cleanup — stop auto-managed daemon if started |
### Library Modules
| Module | Purpose |
|--------|---------|
| `lib/client.py` | Hindsight REST API client (stdlib `urllib`) |
| `lib/config.py` | Configuration loader (settings.json + env overrides) |
| `lib/daemon.py` | `hindsight-embed` daemon lifecycle (start/stop/health) |
| `lib/bank.py` | Bank ID derivation + mission management |
| `lib/content.py` | Content processing (transcript parsing, memory formatting, tag stripping) |
| `lib/state.py` | File-based state persistence with `fcntl` locking |
| `lib/llm.py` | LLM provider auto-detection for daemon mode |
### How Recall Works
1. User sends a prompt → `UserPromptSubmit` hook fires
2. Plugin resolves Hindsight API URL (external, local, or auto-start daemon)
3. Derives bank ID (static or dynamic from project context)
4. Composes query from current prompt + optional prior turns
5. Calls Hindsight recall API
6. Formats memories into `<hindsight_memories>` block
7. Outputs via `hookSpecificOutput.additionalContext` — Claude sees it, user doesn't
### How Retain Works
1. Claude responds → `Stop` hook fires (async, non-blocking)
2. Reads conversation transcript from Claude Code's JSONL file
3. Applies chunked retention logic (every N turns with sliding window)
4. Strips `<hindsight_memories>` tags to prevent feedback loops
5. Extracts text from channel messages (Telegram reply tool calls, etc.)
6. POSTs formatted transcript to Hindsight retain API
## Connection Modes
The plugin supports three connection modes, matching the Openclaw plugin:
### 1. External API (recommended for production)
Connect to a running Hindsight server (cloud or self-hosted). No local LLM needed — the server handles fact extraction.
```json
{
"hindsightApiUrl": "https://your-hindsight-server.com",
"hindsightApiToken": "your-token"
}
```
### 2. Local Daemon (auto-managed)
The plugin automatically starts and stops `hindsight-embed` via `uvx`. Requires an LLM provider API key for local fact extraction.
```json
{
"hindsightApiUrl": "",
"apiPort": 9077
}
```
Set an LLM provider:
```bash
export OPENAI_API_KEY="sk-your-key" # Auto-detected, uses gpt-4o-mini
# or
export ANTHROPIC_API_KEY="your-key" # Auto-detected, uses claude-3-5-haiku
```
### 3. Existing Local Server
If you already have `hindsight-embed` running, leave `hindsightApiUrl` empty and set `apiPort` to match your server's port. The plugin will detect it automatically.
## Configuration
All settings are in `settings.json` at the plugin root. Every setting can also be overridden via environment variables.
### Connection & Daemon
| Setting | Default | Env Var | Description |
|---------|---------|---------|-------------|
| `hindsightApiUrl` | `""` | `HINDSIGHT_API_URL` | External Hindsight API URL. Empty = use local daemon. |
| `hindsightApiToken` | `null` | `HINDSIGHT_API_TOKEN` | Auth token for external API |
| `apiPort` | `9077` | `HINDSIGHT_API_PORT` | Port for local Hindsight daemon |
| `daemonIdleTimeout` | `0` | `HINDSIGHT_DAEMON_IDLE_TIMEOUT` | Seconds before idle daemon shuts down (0 = never) |
| `embedVersion` | `"latest"` | `HINDSIGHT_EMBED_VERSION` | `hindsight-embed` version for `uvx` |
| `embedPackagePath` | `null` | `HINDSIGHT_EMBED_PACKAGE_PATH` | Local path to `hindsight-embed` for development |
### LLM Provider (daemon mode only)
| Setting | Default | Env Var | Description |
|---------|---------|---------|-------------|
| `llmProvider` | auto-detect | `HINDSIGHT_LLM_PROVIDER` | LLM provider: `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `openai-codex`, `claude-code` |
| `llmModel` | provider default | `HINDSIGHT_LLM_MODEL` | Model override |
| `llmApiKeyEnv` | provider standard | — | Custom env var name for API key |
Auto-detection checks these env vars in order: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY`.
### Memory Bank
| Setting | Default | Env Var | Description |
|---------|---------|---------|-------------|
| `bankId` | `"claude_code"` | `HINDSIGHT_BANK_ID` | Static bank ID (when `dynamicBankId` is false) |
| `bankMission` | generic assistant | `HINDSIGHT_BANK_MISSION` | Agent identity/purpose for the memory bank |
| `retainMission` | extraction prompt | — | Custom retain mission (what to extract from conversations) |
| `dynamicBankId` | `false` | `HINDSIGHT_DYNAMIC_BANK_ID` | Enable per-context memory banks |
| `dynamicBankGranularity` | `["agent", "project"]` | — | Fields for dynamic bank ID: `agent`, `project`, `session`, `channel`, `user` |
| `bankIdPrefix` | `""` | — | Prefix for all bank IDs (e.g. `"prod"`) |
| `agentName` | `""` | `HINDSIGHT_AGENT_NAME` | Agent name for dynamic bank ID derivation |
### Auto-Recall
| Setting | Default | Env Var | Description |
|---------|---------|---------|-------------|
| `autoRecall` | `true` | `HINDSIGHT_AUTO_RECALL` | Enable automatic memory recall |
| `recallBudget` | `"mid"` | `HINDSIGHT_RECALL_BUDGET` | Recall effort: `low`, `mid`, `high` |
| `recallMaxTokens` | `1024` | `HINDSIGHT_RECALL_MAX_TOKENS` | Max tokens in recall response |
| `recallTypes` | `["world", "experience"]` | — | Memory types: `world`, `experience`, `observation` |
| `recallContextTurns` | `1` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | Prior turns for query composition (1 = latest only) |
| `recallMaxQueryChars` | `800` | `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | Max query length |
| `recallRoles` | `["user", "assistant"]` | — | Roles included in query context |
| `recallTopK` | `null` | — | Hard cap on memories per turn |
| `recallPromptPreamble` | built-in string | — | Text placed above recalled memories |
### Auto-Retain
| Setting | Default | Env Var | Description |
|---------|---------|---------|-------------|
| `autoRetain` | `true` | `HINDSIGHT_AUTO_RETAIN` | Enable automatic retention |
| `retainRoles` | `["user", "assistant"]` | — | Which roles to retain |
| `retainEveryNTurns` | `10` | — | Retain every Nth turn. Values >1 enable chunked retention with a sliding window. |
| `retainOverlapTurns` | `2` | — | Extra overlap turns included when chunked retention fires. Window = `retainEveryNTurns + retainOverlapTurns` (default: 12 turns). |
| `retainContext` | `"claude-code"` | — | Context label for retained memories |
### Miscellaneous
| Setting | Default | Env Var | Description |
|---------|---------|---------|-------------|
| `debug` | `false` | `HINDSIGHT_DEBUG` | Enable debug logging to stderr |
## Claude Code Channels
With [Claude Code Channels](https://docs.anthropic.com/en/docs/claude-code), Claude Code can operate as a persistent background agent connected to Telegram, Discord, Slack, and other messaging platforms. This plugin gives Channel-based agents the same long-term memory that `hindsight-openclaw` provides for Openclaw agents.
For Channel agents, set these environment variables in your Channel configuration:
```bash
# Per-channel/per-user memory isolation
export HINDSIGHT_CHANNEL_ID="telegram-group-12345"
export HINDSIGHT_USER_ID="user-67890"
```
And enable dynamic bank IDs:
```json
{
"dynamicBankId": true,
"dynamicBankGranularity": ["agent", "channel", "user"]
}
```
## Troubleshooting
### Plugin not activating
- Verify installation: check that `.claude-plugin/plugin.json` exists in the installed plugin directory
- Check Claude Code logs for `[Hindsight]` messages (enable `"debug": true` in settings.json)
### Recall returning no memories
- Verify the Hindsight server is reachable: `curl http://localhost:9077/health`
- Check that the bank has retained content: memories need at least one retain cycle
- Try increasing `recallBudget` to `"mid"` or `"high"`
### Daemon not starting
- Ensure `uvx` is installed: `pip install uv` or `brew install uv`
- Check that an LLM API key is set (required for local daemon)
- Review daemon logs: `tail -f ~/.hindsight/profiles/claude-code.log`
- Try starting manually: `uvx hindsight-embed@latest daemon --profile claude-code start`
### High latency on recall
- The recall hook has a 12-second timeout. If Hindsight is slow:
- Use `recallBudget: "low"` (fewer retrieval strategies)
- Reduce `recallMaxTokens`
- Consider using an external API with a faster server
### State file issues
- State is stored in `$CLAUDE_PLUGIN_DATA/state/`
- To reset: delete the `state/` directory
- Turn counts, bank missions, and daemon state are tracked here
## Development
To test local changes to `hindsight-embed`:
```json
{
"embedPackagePath": "/path/to/hindsight-embed"
}
```
The plugin will use `uv run --directory <path> hindsight-embed` instead of `uvx hindsight-embed@latest`.
To view daemon logs:
```bash
# Check daemon status
uvx hindsight-embed@latest daemon --profile claude-code status
# View logs
tail -f ~/.hindsight/profiles/claude-code.log
# List profiles
uvx hindsight-embed@latest profile list
```
## Links
- [Hindsight Documentation](https://vectorize.io/hindsight)
- [Claude Code Documentation](https://docs.anthropic.com/en/docs/claude-code)
- [GitHub Repository](https://github.com/vectorize-io/hindsight)
## License
MIT

View file

@ -0,0 +1,49 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/session_start.py\"",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/recall.py\"",
"timeout": 12
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/retain.py\"",
"timeout": 15,
"async": true
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/session_end.py\"",
"timeout": 10
}
]
}
]
}
}

View file

@ -0,0 +1,59 @@
#!/bin/bash
set -e
echo "Installing Hindsight Memory Plugin for Claude Code..."
# Get the directory where this script is located
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
# Check Python version
if ! command -v python3 &> /dev/null; then
echo "Error: Python 3 not found. Please install Python 3.8+"
exit 1
fi
PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
echo "Found Python $PYTHON_VERSION"
# Check Claude Code is available
if ! command -v claude &> /dev/null; then
echo "Warning: 'claude' command not found. Make sure Claude Code is installed."
echo " See: https://docs.anthropic.com/en/docs/claude-code"
fi
# Install via Claude Code plugin system
echo ""
echo "To install the plugin, run the following in Claude Code:"
echo ""
echo " /plugin install $SCRIPT_DIR"
echo ""
echo "Or copy it manually:"
echo ""
PLUGIN_DIR="$HOME/.claude/plugins/hindsight-memory"
echo " mkdir -p $PLUGIN_DIR"
echo " cp -r $SCRIPT_DIR/.claude-plugin $PLUGIN_DIR/"
echo " cp -r $SCRIPT_DIR/hooks $PLUGIN_DIR/"
echo " cp -r $SCRIPT_DIR/scripts $PLUGIN_DIR/"
echo " cp $SCRIPT_DIR/settings.json $PLUGIN_DIR/"
echo ""
echo "Next steps:"
echo ""
echo "1. Configure your LLM provider for memory extraction:"
echo " # Option A: OpenAI (auto-detected)"
echo " export OPENAI_API_KEY=\"sk-your-key\""
echo ""
echo " # Option B: Anthropic (auto-detected)"
echo " export ANTHROPIC_API_KEY=\"your-key\""
echo ""
echo " # Option C: Explicit provider"
echo " export HINDSIGHT_API_LLM_PROVIDER=openai"
echo " export HINDSIGHT_API_LLM_API_KEY=\"sk-your-key\""
echo ""
echo "2. Or connect to an external Hindsight server:"
echo " Edit settings.json and set hindsightApiUrl"
echo ""
echo "3. Start Claude Code — the plugin will activate automatically."
echo ""
echo "On first use with daemon mode, uvx will download hindsight-embed (no manual install needed)."

View file

@ -0,0 +1,122 @@
"""Bank ID derivation and mission management.
Port of Openclaw's deriveBankId() and banksWithMissionSet logic, adapted
for Claude Code's context model.
Openclaw derives bank IDs from: agent, channel, user, provider.
Claude Code equivalent dimensions:
- agent configured name or "claude-code" (HINDSIGHT_AGENT_NAME)
- project derived from cwd (working directory basename)
- session session_id from hook input
- channel from env var HINDSIGHT_CHANNEL_ID (for Telegram/Discord agents)
- user from env var HINDSIGHT_USER_ID (for multi-user agents)
The channel/user dimensions enable the same per-user/per-channel isolation
that Openclaw provides via its messageProvider/channelId/senderId context.
Telegram/Discord agents set HINDSIGHT_CHANNEL_ID and HINDSIGHT_USER_ID in
their environment to achieve equivalent behavior.
"""
import os
import sys
import urllib.parse
from .state import read_state, write_state
DEFAULT_BANK_NAME = "claude-code"
# Valid granularity fields for Claude Code
VALID_FIELDS = {"agent", "project", "session", "channel", "user"}
def derive_bank_id(hook_input: dict, config: dict) -> str:
"""Derive a bank ID from hook context and config.
Port of: deriveBankId() in index.js
When dynamicBankId is false, returns the static bank.
When true, composes from granularity fields joined by '::'.
Args:
hook_input: The hook's stdin JSON (has session_id, cwd).
config: Plugin configuration dict.
"""
prefix = config.get("bankIdPrefix", "")
if not config.get("dynamicBankId", False):
# Static mode — single bank for everything
base = config.get("bankId") or DEFAULT_BANK_NAME
return f"{prefix}-{base}" if prefix else base
# Dynamic mode — compose from granularity fields
fields = config.get("dynamicBankGranularity")
if not fields or not isinstance(fields, list):
fields = ["agent", "project"]
# Warn on unknown fields (mirrors Openclaw's runtime check)
for f in fields:
if f not in VALID_FIELDS:
print(
f'[Hindsight] Unknown dynamicBankGranularity field "{f}"'
f"valid for Claude Code: {', '.join(sorted(VALID_FIELDS))}",
file=sys.stderr,
)
# Build field values from hook context + env vars
cwd = hook_input.get("cwd", "")
session_id = hook_input.get("session_id", "")
agent_name = config.get("agentName", "claude-code")
# Channel and user come from environment variables, set by the host agent
# (e.g. Telegram bot sets HINDSIGHT_CHANNEL_ID=telegram-group-12345)
channel_id = os.environ.get("HINDSIGHT_CHANNEL_ID", "")
user_id = os.environ.get("HINDSIGHT_USER_ID", "")
field_map = {
"agent": agent_name,
"project": os.path.basename(cwd) if cwd else "unknown",
"session": session_id or "unknown",
"channel": channel_id or "default",
"user": user_id or "anonymous",
}
segments = [urllib.parse.quote(field_map.get(f, "unknown"), safe="") for f in fields]
base_bank_id = "::".join(segments)
return f"{prefix}-{base_bank_id}" if prefix else base_bank_id
def ensure_bank_mission(client, bank_id: str, config: dict, debug_fn=None):
"""Set bank mission on first use, skip if already set.
Port of: banksWithMissionSet Set tracking in index.js
Uses a state file to persist which banks have had their mission set
across ephemeral hook invocations.
"""
mission = config.get("bankMission", "")
if not mission or not mission.strip():
return
# Check if we've already set mission for this bank
missions_set = read_state("bank_missions.json", {})
if bank_id in missions_set:
return
try:
retain_mission = config.get("retainMission")
client.set_bank_mission(bank_id, mission, retain_mission=retain_mission, timeout=10)
missions_set[bank_id] = True
# Cap tracked banks (mirrors Openclaw's MAX_TRACKED_BANK_CLIENTS)
if len(missions_set) > 10000:
keys = sorted(missions_set.keys())
for k in keys[: len(keys) // 2]:
del missions_set[k]
write_state("bank_missions.json", missions_set)
if debug_fn:
debug_fn(f"Set mission for bank: {bank_id}")
except Exception as e:
# Don't fail if mission set fails — bank might not exist yet,
# will be created on first retain (mirrors Openclaw behavior)
if debug_fn:
debug_fn(f"Could not set bank mission for {bank_id}: {e}")

View file

@ -0,0 +1,142 @@
"""Hindsight REST API client.
Communicates with a Hindsight server via HTTP. Mirrors the HTTP mode of the
Openclaw HindsightClient (client.js), adapted for Python stdlib.
"""
import json
import urllib.error
import urllib.parse
import urllib.request
from typing import Optional
DEFAULT_TIMEOUT = 15 # seconds
HEALTH_CHECK_RETRIES = 3
HEALTH_CHECK_DELAY = 2 # seconds
def _validate_api_url(url: str) -> str:
"""Validate and normalize the API URL. Reject non-HTTP schemes."""
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Hindsight API URL must use http or https, got: {parsed.scheme!r}")
if not parsed.hostname:
raise ValueError(f"Hindsight API URL has no hostname: {url!r}")
return url.rstrip("/")
class HindsightClient:
"""HTTP client for the Hindsight API."""
def __init__(self, api_url: str, api_token: Optional[str] = None):
self.api_url = _validate_api_url(api_url)
self.api_token = api_token
def _headers(self) -> dict:
headers = {"Content-Type": "application/json"}
if self.api_token:
headers["Authorization"] = f"Bearer {self.api_token}"
return headers
def _request(self, method: str, path: str, body: Optional[dict] = None, timeout: int = DEFAULT_TIMEOUT) -> dict:
url = f"{self.api_url}{path}"
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
body_text = ""
try:
body_text = e.read().decode()
except Exception:
pass
raise RuntimeError(f"HTTP {e.code} from {url}: {body_text}") from e
def health_check(self, timeout: int = 5) -> bool:
"""Check if the Hindsight server is reachable.
Mirrors Openclaw's checkExternalApiHealth: retries up to 3 times
with 2s delay between attempts.
"""
import time
for attempt in range(1, HEALTH_CHECK_RETRIES + 1):
try:
url = f"{self.api_url}/health"
req = urllib.request.Request(url, headers=self._headers(), method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
if resp.status == 200:
return True
except Exception:
pass
if attempt < HEALTH_CHECK_RETRIES:
time.sleep(HEALTH_CHECK_DELAY)
return False
def recall(
self,
bank_id: str,
query: str,
max_tokens: int = 1024,
budget: str = "mid",
types: Optional[list] = None,
timeout: int = 10,
) -> dict:
"""Recall memories from a bank.
Returns the raw API response dict with 'results' list.
"""
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories/recall"
body = {
"query": query,
"max_tokens": max_tokens,
}
if budget:
body["budget"] = budget
if types:
body["types"] = types
return self._request("POST", path, body, timeout=timeout)
def retain(
self,
bank_id: str,
content: str,
document_id: str = "conversation",
context: Optional[str] = None,
metadata: Optional[dict] = None,
timeout: int = 15,
) -> dict:
"""Retain content into a bank's memory.
Posts with async=true so the server processes in the background.
The context field helps Hindsight cluster memories by provenance
(e.g. "claude-code" vs manual retains).
"""
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories"
item = {
"content": content,
"document_id": document_id,
"metadata": metadata or {},
}
if context:
item["context"] = context
body = {
"items": [item],
"async": True,
}
return self._request("POST", path, body, timeout=timeout)
def set_bank_mission(
self, bank_id: str, mission: str, retain_mission: Optional[str] = None, timeout: int = 15
) -> dict:
"""Set the mission/persona for a bank.
Uses PATCH /banks/{id}/config with reflect_mission and retain_mission.
The old PUT /banks/{id} with 'mission' field is deprecated in v0.4.19.
"""
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/config"
updates = {"reflect_mission": mission}
if retain_mission:
updates["retain_mission"] = retain_mission
return self._request("PATCH", path, {"updates": updates}, timeout=timeout)

View file

@ -0,0 +1,123 @@
"""Configuration management for Hindsight plugin.
Loads settings from settings.json (plugin defaults) merged with environment
variable overrides. Full config schema matching Openclaw's 30+ options.
"""
import json
import os
import sys
DEFAULTS = {
# Recall
"autoRecall": True,
"recallBudget": "mid",
"recallMaxTokens": 1024,
"recallTypes": ["world", "experience"],
"recallContextTurns": 1,
"recallMaxQueryChars": 800,
"recallRoles": ["user", "assistant"],
"recallPromptPreamble": (
"Relevant memories from past conversations (prioritize recent when "
"conflicting). Only use memories that are directly useful to continue "
"this conversation; ignore the rest:"
),
"recallTopK": None,
# Retain
"autoRetain": True,
"retainRoles": ["user", "assistant"],
"retainEveryNTurns": 10,
"retainOverlapTurns": 2,
"retainContext": "claude-code",
# Connection
"hindsightApiUrl": None,
"hindsightApiToken": None,
"apiPort": 9077,
"daemonIdleTimeout": 0,
"embedVersion": "latest",
"embedPackagePath": None,
# Bank
"bankId": None,
"bankIdPrefix": "",
"dynamicBankId": False,
"dynamicBankGranularity": ["agent", "project"],
"bankMission": "",
"retainMission": None,
"agentName": "claude-code",
# LLM (for daemon mode)
"llmProvider": None,
"llmModel": None,
"llmApiKeyEnv": None,
# Misc
"debug": False,
}
# Map env var names to config keys and their types
ENV_OVERRIDES = {
"HINDSIGHT_API_URL": ("hindsightApiUrl", str),
"HINDSIGHT_API_TOKEN": ("hindsightApiToken", str),
"HINDSIGHT_BANK_ID": ("bankId", str),
"HINDSIGHT_AGENT_NAME": ("agentName", str),
"HINDSIGHT_AUTO_RECALL": ("autoRecall", bool),
"HINDSIGHT_AUTO_RETAIN": ("autoRetain", bool),
"HINDSIGHT_RECALL_BUDGET": ("recallBudget", str),
"HINDSIGHT_RECALL_MAX_TOKENS": ("recallMaxTokens", int),
"HINDSIGHT_RECALL_MAX_QUERY_CHARS": ("recallMaxQueryChars", int),
"HINDSIGHT_RECALL_CONTEXT_TURNS": ("recallContextTurns", int),
"HINDSIGHT_API_PORT": ("apiPort", int),
"HINDSIGHT_DAEMON_IDLE_TIMEOUT": ("daemonIdleTimeout", int),
"HINDSIGHT_EMBED_VERSION": ("embedVersion", str),
"HINDSIGHT_EMBED_PACKAGE_PATH": ("embedPackagePath", str),
"HINDSIGHT_DYNAMIC_BANK_ID": ("dynamicBankId", bool),
"HINDSIGHT_BANK_MISSION": ("bankMission", str),
"HINDSIGHT_LLM_PROVIDER": ("llmProvider", str),
"HINDSIGHT_LLM_MODEL": ("llmModel", str),
"HINDSIGHT_DEBUG": ("debug", bool),
}
def _cast_env(value: str, typ):
"""Cast environment variable string to target type. Returns None on failure."""
try:
if typ is bool:
return value.lower() in ("true", "1", "yes")
if typ is int:
return int(value)
return value
except (ValueError, AttributeError):
return None
def load_config() -> dict:
"""Load plugin configuration from settings.json + env overrides."""
config = dict(DEFAULTS)
# Find settings.json relative to plugin root
plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
if not plugin_root:
plugin_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
settings_path = os.path.join(plugin_root, "settings.json")
if os.path.exists(settings_path):
try:
with open(settings_path) as f:
file_config = json.load(f)
config.update({k: v for k, v in file_config.items() if v is not None})
except (json.JSONDecodeError, OSError) as e:
debug_log(config, f"Failed to load settings.json: {e}")
# Apply environment variable overrides
for env_name, (key, typ) in ENV_OVERRIDES.items():
val = os.environ.get(env_name)
if val is not None:
cast_val = _cast_env(val, typ)
if cast_val is not None:
config[key] = cast_val
return config
def debug_log(config: dict, *args):
"""Log to stderr if debug mode is enabled."""
if config.get("debug"):
print("[Hindsight]", *args, file=sys.stderr)

View file

@ -0,0 +1,388 @@
"""Content processing utilities.
Faithful port of Openclaw plugin's content processing: memory tag stripping,
query composition/truncation, transcript formatting, and memory formatting.
Source: reference/openclaw-source/index.js stripMemoryTags, composeRecallQuery,
truncateRecallQuery, sliceLastTurnsByUserBoundary, prepareRetentionTranscript,
formatMemories.
"""
import re
from datetime import datetime, timezone
# ---------------------------------------------------------------------------
# Memory tag stripping (anti-feedback-loop)
# ---------------------------------------------------------------------------
def strip_channel_envelope(content: str) -> str:
"""Strip Claude Code channel XML wrappers from user messages.
Claude Code wraps incoming channel messages in XML:
<channel source="plugin:telegram:telegram" chat_id="..." ...>
actual message text
</channel>
This is the Claude Code equivalent of Openclaw's stripMetadataEnvelopes().
Extracts the inner text, preserving the actual user message while removing
transport metadata that Hindsight doesn't need.
"""
# Match <channel ...>content</channel> — extract inner text
match = re.search(r"<channel\b[^>]*>([\s\S]*?)</channel>", content)
if match:
return match.group(1).strip()
return content
def strip_memory_tags(content: str) -> str:
"""Remove <hindsight_memories> and <relevant_memories> blocks.
Prevents retain feedback loop these were injected during recall and
should not be re-stored.
Port of: stripMemoryTags() in index.js
"""
content = re.sub(r"<hindsight_memories>[\s\S]*?</hindsight_memories>", "", content)
content = re.sub(r"<relevant_memories>[\s\S]*?</relevant_memories>", "", content)
return content
# ---------------------------------------------------------------------------
# Recall: query composition and truncation
# ---------------------------------------------------------------------------
def compose_recall_query(
latest_query: str,
messages: list,
recall_context_turns: int,
recall_roles: list = None,
) -> str:
"""Compose a multi-turn recall query from conversation history.
Port of: composeRecallQuery() in index.js
When recallContextTurns > 1, includes prior context from the transcript
above the latest user query. Format:
Prior context:
user: ...
assistant: ...
<latest query>
"""
latest = latest_query.strip()
if recall_context_turns <= 1 or not isinstance(messages, list) or not messages:
return latest
allowed_roles = set(recall_roles or ["user", "assistant"])
contextual_messages = slice_last_turns_by_user_boundary(messages, recall_context_turns)
context_lines = []
for msg in contextual_messages:
role = msg.get("role")
if role not in allowed_roles:
continue
content = _extract_text_content(msg.get("content", ""), role=role)
content = strip_channel_envelope(content)
content = strip_memory_tags(content).strip()
if not content:
continue
# Skip if this is the same as the latest query (avoid duplication)
if role == "user" and content == latest:
continue
context_lines.append(f"{role}: {content}")
if not context_lines:
return latest
return "\n\n".join(
[
"Prior context:",
"\n".join(context_lines),
latest,
]
)
def truncate_recall_query(query: str, latest_query: str, max_chars: int) -> str:
"""Truncate a composed recall query to max_chars.
Port of: truncateRecallQuery() in index.js
Preserves the latest user message. When the query contains "Prior context:",
drops oldest context lines first (from the top) to fit within the limit.
"""
if max_chars <= 0:
return query
latest = latest_query.strip()
if len(query) <= max_chars:
return query
# If even the latest alone is too long, hard-truncate it
latest_only = latest[:max_chars] if len(latest) > max_chars else latest
if "Prior context:" not in query:
return latest_only
context_marker = "Prior context:\n\n"
marker_index = query.find(context_marker)
if marker_index == -1:
return latest_only
suffix_marker = "\n\n" + latest
suffix_index = query.rfind(suffix_marker)
if suffix_index == -1:
return latest_only
suffix = query[suffix_index:] # \n\n<latest>
if len(suffix) >= max_chars:
return latest_only
context_body = query[marker_index + len(context_marker) : suffix_index]
context_lines = [line for line in context_body.split("\n") if line]
# Add context lines from newest (bottom) to oldest (top), stop when exceeding
kept = []
for i in range(len(context_lines) - 1, -1, -1):
kept.insert(0, context_lines[i])
candidate = f"{context_marker}{chr(10).join(kept)}{suffix}"
if len(candidate) > max_chars:
kept.pop(0)
break
if kept:
return f"{context_marker}{chr(10).join(kept)}{suffix}"
return latest_only
# ---------------------------------------------------------------------------
# Turn slicing
# ---------------------------------------------------------------------------
def slice_last_turns_by_user_boundary(messages: list, turns: int) -> list:
"""Slice messages to the last N turns, where a turn starts at a user message.
Port of: sliceLastTurnsByUserBoundary() in index.js
Walks backward counting user messages as turn boundaries. Returns
messages from the Nth user boundary to the end.
"""
if not isinstance(messages, list) or not messages or turns <= 0:
return []
user_turns_seen = 0
start_index = -1
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "user":
user_turns_seen += 1
if user_turns_seen >= turns:
start_index = i
break
if start_index == -1:
return list(messages)
return messages[start_index:]
# ---------------------------------------------------------------------------
# Memory formatting (recall results → context string)
# ---------------------------------------------------------------------------
def format_memories(results: list) -> str:
"""Format recall results into human-readable text.
Port of: formatMemories() in index.js
Format: - <text> [<type>] (<mentioned_at>)
"""
if not results:
return ""
lines = []
for r in results:
text = r.get("text", "")
mem_type = r.get("type", "")
mentioned_at = r.get("mentioned_at", "")
type_str = f" [{mem_type}]" if mem_type else ""
date_str = f" ({mentioned_at})" if mentioned_at else ""
lines.append(f"- {text}{type_str}{date_str}")
return "\n\n".join(lines)
def format_current_time() -> str:
"""Format current UTC time for recall context.
Port of: formatCurrentTimeForRecall() in index.js
"""
now = datetime.now(timezone.utc)
return now.strftime("%Y-%m-%d %H:%M")
# ---------------------------------------------------------------------------
# Retention transcript formatting
# ---------------------------------------------------------------------------
def prepare_retention_transcript(
messages: list,
retain_roles: list = None,
retain_full_window: bool = False,
) -> tuple:
"""Format messages into a retention transcript.
Port of: prepareRetentionTranscript() in index.js
Args:
messages: List of message dicts with 'role' and 'content'.
retain_roles: Roles to include (default: ['user', 'assistant']).
retain_full_window: If True, retain all messages (chunked mode).
If False, retain only the last turn (last user msg + responses).
Returns:
(transcript_text, message_count) or (None, 0) if nothing to retain.
"""
if not messages:
return None, 0
if retain_full_window:
target_messages = messages
else:
# Default: retain only the last turn
last_user_idx = -1
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "user":
last_user_idx = i
break
if last_user_idx == -1:
return None, 0
target_messages = messages[last_user_idx:]
allowed_roles = set(retain_roles or ["user", "assistant"])
parts = []
for msg in target_messages:
role = msg.get("role", "unknown")
if role not in allowed_roles:
continue
content = _extract_text_content(msg.get("content", ""), role=role)
content = strip_channel_envelope(content)
content = strip_memory_tags(content).strip()
if not content:
continue
parts.append(f"[role: {role}]\n{content}\n[{role}:end]")
if not parts:
return None, 0
transcript = "\n\n".join(parts)
if len(transcript.strip()) < 10:
return None, 0
return transcript, len(parts)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Fields in tool_use input that carry the outgoing message text.
# Ordered by likelihood — first match wins.
_MESSAGE_TEXT_FIELDS = ("text", "body", "message", "content")
# MCP tool name suffixes that are operational, not conversational.
# Checked against the last segment of the tool name (after the last __).
import re as _re
_OPERATIONAL_TOOL_PATTERN = _re.compile(
r"(?:recall|retain|reflect|search|extract|create_|delete_|update_|get_|list_)",
_re.IGNORECASE,
)
def _is_channel_message_tool(block: dict) -> bool:
"""Detect if a tool_use block is a channel message (reply/send).
Uses a structural approach rather than name-matching for robustness:
1. Must be an MCP tool (name starts with "mcp__")
2. Must NOT match known operational patterns (recall, search, CRUD)
3. Must have a text-like field in input (text, body, message, content)
This catches any channel plugin (Telegram, Slack, Discord, Matrix,
future channels) without hardcoding tool names. Built-in tools (Bash,
Read, Write) don't start with mcp__. MCP tools for non-messaging
purposes (hindsight recall, search) are excluded by pattern and by
lacking text/body fields.
"""
name = block.get("name", "")
if not name.startswith("mcp__"):
return False
# Exclude operational MCP tools (check only the tool suffix, not server name)
tool_suffix = name.split("__")[-1]
if _OPERATIONAL_TOOL_PATTERN.search(tool_suffix):
return False
tool_input = block.get("input", {})
if not isinstance(tool_input, dict):
return False
# Must have a text-carrying field with actual content
return any(isinstance(tool_input.get(f), str) and tool_input[f].strip() for f in _MESSAGE_TEXT_FIELDS)
def _extract_text_content(content, role: str = "") -> str:
"""Extract text from message content (string or content blocks array).
For user messages: extracts from plain strings (channel XML wrappers
are stripped separately by strip_channel_envelope).
For assistant messages: extracts from:
- {type: "text"} blocks terminal output/narration
- {type: "tool_use"} blocks detected as channel messages the agent's
actual responses to the user. Detection is structural (MCP tool with
text-like input field), not name-based, for channel-agnosticism.
Excludes:
- {type: "thinking"} internal reasoning
- {type: "tool_use"} for operational tools Bash, Read, Write, recall, etc.
- {type: "tool_result"} operational results, not conversation
"""
if isinstance(content, str):
return content
if isinstance(content, list):
texts = []
for block in content:
if not isinstance(block, dict):
continue
block_type = block.get("type", "")
# Text blocks: terminal output / narration
if block_type == "text":
text = block.get("text", "").strip()
if text:
texts.append(text)
# Tool use blocks: extract channel messages
elif block_type == "tool_use" and role == "assistant":
if _is_channel_message_tool(block):
tool_input = block.get("input", {})
for field in _MESSAGE_TEXT_FIELDS:
val = tool_input.get(field)
if isinstance(val, str) and val.strip():
texts.append(val.strip())
break
return "\n".join(texts)
return ""

View file

@ -0,0 +1,272 @@
"""Hindsight-embed daemon lifecycle management.
Port of: HindsightEmbedManager in embed-manager.js, adapted for Python
subprocess calls from ephemeral hook processes.
Manages three connection modes (same as Openclaw):
1. External API user provides hindsightApiUrl (skip daemon entirely)
2. Existing local server user already has hindsight running
3. Auto-managed daemon plugin starts/stops hindsight-embed
In Claude Code's ephemeral model, daemon state is tracked via files in
$CLAUDE_PLUGIN_DATA/state/. The daemon itself is a background OS process
managed by hindsight-embed's built-in daemon command.
"""
import os
import platform
import subprocess
import time
import urllib.error
import urllib.request
from .llm import detect_llm_config, get_llm_env_vars
from .state import read_state, write_state
DAEMON_STATE_FILE = "daemon.json"
PROFILE_NAME = "claude-code"
def _get_embed_command(config: dict) -> list:
"""Get the command to run hindsight-embed.
Port of: getEmbedCommand() in embed-manager.js
"""
embed_path = config.get("embedPackagePath")
if embed_path:
return ["uv", "run", "--directory", embed_path, "hindsight-embed"]
version = config.get("embedVersion", "latest")
package = f"hindsight-embed@{version}" if version else "hindsight-embed@latest"
return ["uvx", package]
def _run_embed(config: dict, args: list, env: dict = None, timeout: int = 10) -> subprocess.CompletedProcess:
"""Run a hindsight-embed command and return the result."""
cmd = _get_embed_command(config) + args
run_env = dict(os.environ)
if env:
run_env.update(env)
return subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
env=run_env,
)
def _is_embed_available(config: dict) -> bool:
"""Quick check if hindsight-embed is available on PATH.
Avoids the slow uvx download path when the tool isn't installed.
"""
import shutil
embed_path = config.get("embedPackagePath")
if embed_path:
return os.path.isdir(embed_path)
# Check for uvx or hindsight-embed on PATH
return shutil.which("uvx") is not None or shutil.which("hindsight-embed") is not None
def _check_health(base_url: str, timeout: int = 2) -> bool:
"""Quick health check against a Hindsight server."""
try:
url = f"{base_url.rstrip('/')}/health"
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status == 200
except Exception:
return False
def get_api_url(config: dict, debug_fn=None, allow_daemon_start: bool = False) -> str:
"""Determine the API URL, optionally starting daemon if needed.
Returns the API URL to use for recall/retain, handling all three modes.
Connection mode priority:
1. External API (hindsightApiUrl configured)
2. Existing local server (check port health)
3. Auto-managed daemon (only if allow_daemon_start=True)
The allow_daemon_start flag prevents the recall hook (10s timeout) from
blocking on a 30s daemon startup. Only the retain hook (async, 15s) should
attempt daemon start.
"""
# Mode 1: External API
external_url = config.get("hindsightApiUrl")
if external_url:
if debug_fn:
debug_fn(f"Using external API: {external_url}")
return external_url
# Mode 2 & 3: Local server
port = config.get("apiPort", 9077)
base_url = f"http://127.0.0.1:{port}"
# Check if something is already running on this port
if _check_health(base_url):
if debug_fn:
debug_fn(f"Existing server healthy on port {port}")
return base_url
# Mode 3: Auto-start daemon (only when allowed)
if not allow_daemon_start:
raise RuntimeError(
f"No Hindsight server on port {port}. Set hindsightApiUrl for external "
f"API, start hindsight-embed manually, or wait for the retain hook to "
f"auto-start the daemon."
)
if debug_fn:
debug_fn(f"No server on port {port}, attempting daemon start")
try:
_ensure_daemon_running(config, port, debug_fn)
except Exception as e:
if debug_fn:
debug_fn(f"Daemon start failed: {e}")
raise RuntimeError(
"No Hindsight server available. Set hindsightApiUrl for external API, "
"or ensure hindsight-embed is installed for local daemon mode."
) from e
return base_url
def _ensure_daemon_running(config: dict, port: int, debug_fn=None):
"""Start the hindsight-embed daemon if not already running.
Port of: HindsightEmbedManager.start() in embed-manager.js
"""
# Fast-fail if hindsight-embed toolchain is not available
if not _is_embed_available(config):
raise RuntimeError(
"hindsight-embed not found (uvx not on PATH). "
"Install with: pip install hindsight-embed, or set hindsightApiUrl."
)
base_url = f"http://127.0.0.1:{port}"
# Detect LLM config (needed for daemon's fact extraction)
try:
llm_config = detect_llm_config(config)
except RuntimeError as e:
raise RuntimeError(f"Cannot start daemon: {e}") from e
llm_env = get_llm_env_vars(llm_config)
# Build daemon environment
daemon_env = dict(llm_env)
idle_timeout = config.get("daemonIdleTimeout", 300)
daemon_env["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] = str(idle_timeout)
# On macOS, force CPU for embeddings/reranker (mirrors Openclaw)
if platform.system() == "Darwin":
daemon_env["HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"] = "1"
daemon_env["HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"] = "1"
# Step 1: Configure profile
if debug_fn:
debug_fn(f'Configuring "{PROFILE_NAME}" profile...')
profile_args = [
"profile",
"create",
PROFILE_NAME,
"--merge",
"--port",
str(port),
]
for env_name, env_val in daemon_env.items():
if env_val:
profile_args.extend(["--env", f"{env_name}={env_val}"])
try:
result = _run_embed(config, profile_args, daemon_env, timeout=10)
if result.returncode != 0:
if debug_fn:
debug_fn(f"Profile create stderr: {result.stderr.strip()}")
raise RuntimeError(f"Profile create failed (exit {result.returncode}): {result.stderr}")
if debug_fn:
debug_fn("Profile configured")
except subprocess.TimeoutExpired:
raise RuntimeError("Profile create timed out")
except FileNotFoundError:
raise RuntimeError(
"hindsight-embed not found. Install with: pip install hindsight-embed "
"or set hindsightApiUrl for external API mode."
)
# Step 2: Start daemon
if debug_fn:
debug_fn("Starting daemon...")
try:
result = _run_embed(
config,
["daemon", "--profile", PROFILE_NAME, "start"],
daemon_env,
timeout=10,
)
if debug_fn:
debug_fn(f"Daemon start exit={result.returncode} stdout={result.stdout.strip()}")
if result.returncode != 0 and "already running" not in result.stderr.lower():
raise RuntimeError(f"Daemon start failed (exit {result.returncode}): {result.stderr}")
except subprocess.TimeoutExpired:
raise RuntimeError("Daemon start timed out")
# Step 3: Wait for ready (poll health endpoint)
if debug_fn:
debug_fn("Waiting for daemon to be ready...")
for attempt in range(30):
if _check_health(base_url):
if debug_fn:
debug_fn(f"Daemon ready after {attempt + 1} attempts")
# Track that we started this daemon
write_state(
DAEMON_STATE_FILE,
{
"port": port,
"started_by_plugin": True,
"started_at": time.time(),
"pid": os.getpid(),
},
)
return
time.sleep(1)
raise RuntimeError("Daemon failed to become ready within 30 seconds")
def stop_daemon(config: dict, debug_fn=None):
"""Stop the daemon if it was started by this plugin.
Called during cleanup. Only stops if we started it (tracked in state).
"""
state = read_state(DAEMON_STATE_FILE)
if not state or not state.get("started_by_plugin"):
if debug_fn:
debug_fn("Daemon not started by plugin, skipping stop")
return
if debug_fn:
debug_fn("Stopping daemon...")
try:
result = _run_embed(
config,
["daemon", "--profile", PROFILE_NAME, "stop"],
timeout=10,
)
if debug_fn:
debug_fn(f"Daemon stop: {result.stdout.strip()}")
except Exception as e:
if debug_fn:
debug_fn(f"Daemon stop error: {e}")
# Clear state
write_state(DAEMON_STATE_FILE, {})

View file

@ -0,0 +1,145 @@
"""LLM provider detection for Hindsight's fact extraction.
Port of: detectLLMConfig() in index.js
When running hindsight-embed locally (daemon mode), it needs an LLM to
extract facts from retained conversations. This module detects the LLM
config using the same priority chain as Openclaw:
1. HINDSIGHT_API_LLM_* environment variables (highest priority)
2. Plugin config (llmProvider, llmModel, llmApiKeyEnv)
3. Auto-detect from standard provider env vars
4. External API mode (server-side LLM, no local config needed)
"""
import os
# Provider detection table — same order as Openclaw
PROVIDER_DETECTION = [
{"name": "openai", "key_env": "OPENAI_API_KEY", "default_model": "gpt-4o-mini"},
{"name": "anthropic", "key_env": "ANTHROPIC_API_KEY", "default_model": "claude-3-5-haiku-20241022"},
{"name": "gemini", "key_env": "GEMINI_API_KEY", "default_model": "gemini-2.5-flash"},
{"name": "groq", "key_env": "GROQ_API_KEY", "default_model": "openai/gpt-oss-20b"},
{"name": "ollama", "key_env": "", "default_model": "llama3.2"},
{"name": "openai-codex", "key_env": "", "default_model": "gpt-5.2-codex"},
{"name": "claude-code", "key_env": "", "default_model": "claude-sonnet-4-5-20250929"},
]
# Providers that don't require an API key
NO_KEY_REQUIRED = {"ollama", "openai-codex", "claude-code"}
def _find_provider(name):
"""Find a provider entry by name."""
for p in PROVIDER_DETECTION:
if p["name"] == name:
return p
return None
def detect_llm_config(config: dict) -> dict:
"""Detect LLM configuration.
Returns dict with: provider, api_key, model, base_url, source.
Returns None values for external API mode (server handles LLM).
Raises RuntimeError if no configuration found and not in external API mode.
"""
override_provider = os.environ.get("HINDSIGHT_API_LLM_PROVIDER")
override_model = os.environ.get("HINDSIGHT_API_LLM_MODEL")
override_key = os.environ.get("HINDSIGHT_API_LLM_API_KEY")
override_base_url = os.environ.get("HINDSIGHT_API_LLM_BASE_URL")
# Priority 1: HINDSIGHT_API_LLM_PROVIDER env var
if override_provider:
if not override_key and override_provider not in NO_KEY_REQUIRED:
raise RuntimeError(
f'HINDSIGHT_API_LLM_PROVIDER is set to "{override_provider}" but HINDSIGHT_API_LLM_API_KEY is not set.'
)
pinfo = _find_provider(override_provider)
return {
"provider": override_provider,
"api_key": override_key or "",
"model": override_model or (pinfo["default_model"] if pinfo else None),
"base_url": override_base_url,
"source": "HINDSIGHT_API_LLM_PROVIDER override",
}
# Priority 2: Plugin config llmProvider/llmModel
cfg_provider = config.get("llmProvider")
if cfg_provider:
pinfo = _find_provider(cfg_provider)
api_key = ""
key_env_name = config.get("llmApiKeyEnv")
if key_env_name:
api_key = os.environ.get(key_env_name, "")
elif pinfo and pinfo["key_env"]:
api_key = os.environ.get(pinfo["key_env"], "")
if not api_key and cfg_provider not in NO_KEY_REQUIRED:
key_source = key_env_name or (pinfo["key_env"] if pinfo else "unknown")
raise RuntimeError(
f'Plugin config llmProvider is "{cfg_provider}" but no API key found. Expected env var: {key_source}'
)
return {
"provider": cfg_provider,
"api_key": api_key,
"model": config.get("llmModel") or override_model or (pinfo["default_model"] if pinfo else None),
"base_url": override_base_url,
"source": "plugin config",
}
# Priority 3: Auto-detect from standard provider env vars
for pinfo in PROVIDER_DETECTION:
if pinfo["name"] in NO_KEY_REQUIRED:
continue # Must be explicitly requested
if not pinfo["key_env"]:
continue
api_key = os.environ.get(pinfo["key_env"], "")
if api_key:
return {
"provider": pinfo["name"],
"api_key": api_key,
"model": override_model or pinfo["default_model"],
"base_url": override_base_url,
"source": f"auto-detected from {pinfo['key_env']}",
}
# Priority 4: External API mode — server handles LLM
if config.get("hindsightApiUrl"):
return {
"provider": None,
"api_key": None,
"model": None,
"base_url": None,
"source": "external-api-mode-no-llm",
}
raise RuntimeError(
"No LLM configuration found for Hindsight.\n\n"
"Option 1: Set a standard provider API key (auto-detect):\n"
" export OPENAI_API_KEY=sk-your-key # Uses gpt-4o-mini\n"
" export ANTHROPIC_API_KEY=your-key # Uses claude-3-5-haiku\n\n"
"Option 2: Override with Hindsight-specific env vars:\n"
" export HINDSIGHT_API_LLM_PROVIDER=openai\n"
" export HINDSIGHT_API_LLM_API_KEY=sk-your-key\n\n"
"Option 3: Use an external Hindsight API (server-side LLM):\n"
" Set hindsightApiUrl in settings.json or HINDSIGHT_API_URL env var"
)
def get_llm_env_vars(llm_config: dict) -> dict:
"""Build environment variables for hindsight-embed daemon from LLM config.
These are passed to the daemon subprocess so it knows which LLM to use
for fact extraction.
"""
env = {}
if llm_config.get("provider"):
env["HINDSIGHT_API_LLM_PROVIDER"] = llm_config["provider"]
if llm_config.get("api_key"):
env["HINDSIGHT_API_LLM_API_KEY"] = llm_config["api_key"]
if llm_config.get("model"):
env["HINDSIGHT_API_LLM_MODEL"] = llm_config["model"]
if llm_config.get("base_url"):
env["HINDSIGHT_API_LLM_BASE_URL"] = llm_config["base_url"]
return env

View file

@ -0,0 +1,113 @@
"""File-based state persistence.
Claude Code hooks are ephemeral processes state must be persisted to files.
Uses $CLAUDE_PLUGIN_DATA/state/ as the storage directory.
"""
import fcntl
import json
import os
import re
def _state_dir() -> str:
"""Get the state directory, creating it if needed."""
plugin_data = os.environ.get("CLAUDE_PLUGIN_DATA", "")
if not plugin_data:
# Fallback to a temp location for testing
plugin_data = os.path.join(os.path.expanduser("~"), ".claude", "plugins", "data", "hindsight-memory")
state_dir = os.path.join(plugin_data, "state")
os.makedirs(state_dir, exist_ok=True)
return state_dir
def _safe_filename(name: str) -> str:
"""Sanitize a filename to prevent path traversal.
Strips path separators, .., and control characters. Mirrors Openclaw's
sanitizeFilename().
"""
# Replace path separators and dangerous patterns
name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", name)
# Collapse .. to prevent traversal
name = name.replace("..", "_")
# Limit length
name = name[:200]
return name or "state"
def _state_file(name: str) -> str:
"""Get path for a state file. Name is sanitized to prevent traversal."""
safe = _safe_filename(name)
path = os.path.join(_state_dir(), safe)
# Final guard: resolved path must be inside state_dir
resolved = os.path.realpath(path)
expected_dir = os.path.realpath(_state_dir())
if not resolved.startswith(expected_dir + os.sep) and resolved != expected_dir:
raise ValueError(f"State file path escapes state directory: {name!r}")
return path
def read_state(name: str, default=None):
"""Read a JSON state file. Returns default if not found."""
path = _state_file(name)
if not os.path.exists(path):
return default
try:
with open(path) as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return default
def write_state(name: str, data):
"""Write data to a JSON state file atomically."""
path = _state_file(name)
tmp_path = path + ".tmp"
try:
with open(tmp_path, "w") as f:
json.dump(data, f)
os.replace(tmp_path, path)
except OSError:
# Best-effort cleanup
try:
os.unlink(tmp_path)
except OSError:
pass
def get_turn_count(session_id: str) -> int:
"""Get the current turn count for a session."""
turns = read_state("turns.json", {})
return turns.get(session_id, 0)
def increment_turn_count(session_id: str) -> int:
"""Increment and return the turn count for a session.
Uses flock to prevent race conditions between concurrent hook processes
(e.g. async Stop + new UserPromptSubmit).
"""
lock_path = _state_file("turns.lock")
try:
lock_fd = open(lock_path, "w")
fcntl.flock(lock_fd, fcntl.LOCK_EX)
try:
turns = read_state("turns.json", {})
turns[session_id] = turns.get(session_id, 0) + 1
# Cap tracked sessions to prevent unbounded growth
if len(turns) > 10000:
sorted_keys = sorted(turns.keys())
for k in sorted_keys[: len(sorted_keys) // 2]:
del turns[k]
write_state("turns.json", turns)
return turns[session_id]
finally:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
lock_fd.close()
except OSError:
# Fallback: proceed without lock (better than failing)
turns = read_state("turns.json", {})
turns[session_id] = turns.get(session_id, 0) + 1
write_state("turns.json", turns)
return turns[session_id]

View file

@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""Auto-recall hook for UserPromptSubmit.
Port of: before_prompt_build handler in Openclaw index.js
Adapted for Claude Code hooks (ephemeral process, JSON stdin/stdout).
Flow:
1. Read hook input from stdin (prompt, session_id, transcript_path, cwd)
2. Resolve API URL (external, existing local, or auto-start daemon)
3. Derive bank ID (static or dynamic from project context)
4. Ensure bank mission is set (first use only)
5. Compose multi-turn query if recallContextTurns > 1
6. Truncate to recallMaxQueryChars
7. Call Hindsight recall API
8. Format memories and output hookSpecificOutput.additionalContext
9. Save last recall to state (for PostCompact re-injection)
Exit codes:
0 always (graceful degradation on any error)
"""
import json
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib.bank import derive_bank_id, ensure_bank_mission
from lib.client import HindsightClient
from lib.config import debug_log, load_config
from lib.content import (
compose_recall_query,
format_current_time,
format_memories,
truncate_recall_query,
)
from lib.daemon import get_api_url
from lib.state import write_state
LAST_RECALL_STATE = "last_recall.json"
def read_transcript_messages(transcript_path: str) -> list:
"""Read messages from a JSONL transcript file for multi-turn context.
Claude Code transcript format nests messages:
{type: "user", message: {role: "user", content: "..."}, uuid: "...", ...}
Also supports flat format for testing:
{role: "user", content: "..."}
"""
if not transcript_path or not os.path.isfile(transcript_path):
return []
messages = []
try:
with open(transcript_path) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
# Claude Code nested format: {type: "user", message: {role, content}}
if entry.get("type") in ("user", "assistant"):
msg = entry.get("message", {})
if isinstance(msg, dict) and msg.get("role"):
messages.append(msg)
# Flat format (testing / future compatibility)
elif "role" in entry and "content" in entry:
messages.append(entry)
except json.JSONDecodeError:
continue
except OSError:
pass
return messages
def main():
config = load_config()
if not config.get("autoRecall"):
debug_log(config, "Auto-recall disabled, exiting")
return
# Read hook input from stdin
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
print("[Hindsight] Failed to read hook input", file=sys.stderr)
return
debug_log(config, f"Hook input keys: {list(hook_input.keys())}")
# Extract user query — hooks-reference.md documents "prompt", but some
# Claude Code sources reference "user_prompt". Accept both defensively.
prompt = (hook_input.get("prompt") or hook_input.get("user_prompt") or "").strip()
if not prompt or len(prompt) < 5:
debug_log(config, "Prompt too short for recall, skipping")
return
# Resolve API URL (handles all three connection modes)
def _dbg(*a):
debug_log(config, *a)
try:
api_url = get_api_url(config, debug_fn=_dbg, allow_daemon_start=False)
except RuntimeError as e:
print(f"[Hindsight] {e}", file=sys.stderr)
return
api_token = config.get("hindsightApiToken")
try:
client = HindsightClient(api_url, api_token)
except ValueError as e:
print(f"[Hindsight] Invalid API URL: {e}", file=sys.stderr)
return
# Derive bank ID (static or dynamic from project context)
bank_id = derive_bank_id(hook_input, config)
# Set bank mission on first use
ensure_bank_mission(client, bank_id, config, debug_fn=_dbg)
# Multi-turn query composition
recall_context_turns = config.get("recallContextTurns", 1)
recall_max_query_chars = config.get("recallMaxQueryChars", 800)
recall_roles = config.get("recallRoles", ["user", "assistant"])
if recall_context_turns > 1:
transcript_path = hook_input.get("transcript_path", "")
messages = read_transcript_messages(transcript_path)
debug_log(config, f"Multi-turn context: {recall_context_turns} turns, {len(messages)} messages from transcript")
query = compose_recall_query(prompt, messages, recall_context_turns, recall_roles)
else:
query = prompt
query = truncate_recall_query(query, prompt, recall_max_query_chars)
# Final defensive cap (mirrors Openclaw)
if len(query) > recall_max_query_chars:
query = query[:recall_max_query_chars]
debug_log(config, f"Recalling from bank '{bank_id}', query length: {len(query)}")
# Call Hindsight recall API
try:
response = client.recall(
bank_id=bank_id,
query=query,
max_tokens=config.get("recallMaxTokens", 1024),
budget=config.get("recallBudget", "mid"),
types=config.get("recallTypes"),
timeout=10,
)
except Exception as e:
print(f"[Hindsight] Recall failed: {e}", file=sys.stderr)
return
results = response.get("results", [])
if not results:
debug_log(config, "No memories found")
return
# Apply topK limit
top_k = config.get("recallTopK")
if top_k and isinstance(top_k, int):
results = results[:top_k]
debug_log(config, f"Injecting {len(results)} memories")
# Format context message — exact match of Openclaw's format
memories_formatted = format_memories(results)
preamble = config.get("recallPromptPreamble", "")
current_time = format_current_time()
context_message = (
f"<hindsight_memories>\n"
f"{preamble}\n"
f"Current time - {current_time}\n\n"
f"{memories_formatted}\n"
f"</hindsight_memories>"
)
# Save last recall to state for diagnostics
write_state(
LAST_RECALL_STATE,
{
"context": context_message,
"saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"bank_id": bank_id,
"result_count": len(results),
},
)
# Output JSON for Claude Code hook system
output = {
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": context_message,
}
}
json.dump(output, sys.stdout)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[Hindsight] Unexpected error in recall: {e}", file=sys.stderr)
# Exit 2 in debug mode surfaces errors to Claude; 0 degrades silently
try:
from lib.config import load_config
sys.exit(2 if load_config().get("debug") else 0)
except Exception:
sys.exit(0)

View file

@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Auto-retain hook for Stop event.
Port of: agent_end handler in Openclaw index.js
Adapted for Claude Code hooks (ephemeral process, JSON stdin/stdout).
Flow:
1. Read hook input from stdin (session_id, transcript_path, cwd)
2. Read conversation transcript from transcript_path
3. Apply chunked retention logic (retainEveryNTurns + overlap window)
4. Resolve API URL (external, existing local, or auto-start daemon)
5. Derive bank ID and ensure mission
6. Format transcript (strip memory tags, filter roles)
7. POST to Hindsight retain API (async)
Exit codes:
0 always (graceful degradation on any error)
"""
import json
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib.bank import derive_bank_id, ensure_bank_mission
from lib.client import HindsightClient
from lib.config import debug_log, load_config
from lib.content import (
prepare_retention_transcript,
slice_last_turns_by_user_boundary,
)
from lib.daemon import get_api_url
from lib.state import increment_turn_count
def read_transcript(transcript_path: str) -> list:
"""Read a JSONL transcript file and return list of message dicts.
Claude Code transcript format nests messages:
{type: "user", message: {role: "user", content: "..."}, uuid: "...", ...}
Also supports flat format for testing:
{role: "user", content: "..."}
"""
if not transcript_path or not os.path.isfile(transcript_path):
return []
messages = []
try:
with open(transcript_path) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
# Claude Code nested format: {type: "user", message: {role, content}}
if entry.get("type") in ("user", "assistant"):
msg = entry.get("message", {})
if isinstance(msg, dict) and msg.get("role"):
messages.append(msg)
# Flat format (testing / future compatibility)
elif "role" in entry and "content" in entry:
messages.append(entry)
except json.JSONDecodeError:
continue
except OSError:
pass
return messages
def main():
config = load_config()
if not config.get("autoRetain"):
debug_log(config, "Auto-retain disabled, exiting")
return
# Read hook input from stdin
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
print("[Hindsight] Failed to read hook input", file=sys.stderr)
return
debug_log(config, f"Stop hook input keys: {list(hook_input.keys())}")
session_id = hook_input.get("session_id", "unknown")
transcript_path = hook_input.get("transcript_path", "")
# Read full transcript
all_messages = read_transcript(transcript_path)
if not all_messages:
debug_log(config, "No messages in transcript, skipping retain")
return
debug_log(config, f"Read {len(all_messages)} messages from transcript")
# Chunked retention logic — port of Openclaw's retainEveryNTurns + sliding window
retain_every_n = max(1, config.get("retainEveryNTurns", 1))
retain_full_window = False
messages_to_retain = all_messages
if retain_every_n > 1:
turn_count = increment_turn_count(session_id)
if turn_count % retain_every_n != 0:
next_at = ((turn_count // retain_every_n) + 1) * retain_every_n
debug_log(config, f"Turn {turn_count}/{retain_every_n}, skipping retain (next at turn {next_at})")
return
# Sliding window: N turns + configured overlap
overlap_turns = config.get("retainOverlapTurns", 0)
window_turns = retain_every_n + overlap_turns
messages_to_retain = slice_last_turns_by_user_boundary(all_messages, window_turns)
retain_full_window = True
debug_log(
config,
f"Turn {turn_count}: chunked retain firing "
f"(window: {window_turns} turns, {len(messages_to_retain)} messages)",
)
# Format transcript
retain_roles = config.get("retainRoles", ["user", "assistant"])
transcript, message_count = prepare_retention_transcript(messages_to_retain, retain_roles, retain_full_window)
if not transcript:
debug_log(config, "Empty transcript after formatting, skipping retain")
return
# Resolve API URL
def _dbg(*a):
debug_log(config, *a)
try:
api_url = get_api_url(config, debug_fn=_dbg, allow_daemon_start=True)
except RuntimeError as e:
print(f"[Hindsight] {e}", file=sys.stderr)
return
api_token = config.get("hindsightApiToken")
try:
client = HindsightClient(api_url, api_token)
except ValueError as e:
print(f"[Hindsight] Invalid API URL: {e}", file=sys.stderr)
return
# Derive bank ID and ensure mission
bank_id = derive_bank_id(hook_input, config)
ensure_bank_mission(client, bank_id, config, debug_fn=_dbg)
# Unique document ID — mirrors Openclaw: {sessionKey}-{timestamp}
document_id = f"{session_id}-{int(time.time() * 1000)}"
debug_log(
config, f"Retaining to bank '{bank_id}', doc '{document_id}', {message_count} messages, {len(transcript)} chars"
)
# POST to Hindsight retain API
try:
response = client.retain(
bank_id=bank_id,
content=transcript,
document_id=document_id,
context=config.get("retainContext", "claude-code"),
metadata={
"retained_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"message_count": str(message_count),
"session_id": session_id,
},
timeout=15,
)
debug_log(config, f"Retain response: {json.dumps(response)[:200]}")
except Exception as e:
print(f"[Hindsight] Retain failed: {e}", file=sys.stderr)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[Hindsight] Unexpected error in retain: {e}", file=sys.stderr)
try:
from lib.config import load_config
sys.exit(2 if load_config().get("debug") else 0)
except Exception:
sys.exit(0)

View file

@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""SessionEnd hook: daemon cleanup.
Fires once when a Claude Code session terminates. If the plugin
auto-started a hindsight-embed daemon, this is where we stop it.
Port of: Openclaw's service.stop() in index.js
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib.config import debug_log, load_config
from lib.daemon import stop_daemon
def main():
config = load_config()
# Consume stdin
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
hook_input = {}
debug_log(config, f"SessionEnd hook, reason: {hook_input.get('reason', 'unknown')}")
# Stop daemon if we started it
def _dbg(*a):
debug_log(config, *a)
stop_daemon(config, debug_fn=_dbg)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[Hindsight] SessionEnd error: {e}", file=sys.stderr)
sys.exit(0)

View file

@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""SessionStart hook: health check + session logging.
Fires once when a Claude Code session begins. Uses additionalContext
(supported on SessionStart) to inject an initial system note if
Hindsight is available.
This is the Claude Code equivalent of Openclaw's service.start() —
verify the server is reachable early, before the first prompt.
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib.client import HindsightClient
from lib.config import debug_log, load_config
from lib.daemon import get_api_url
def main():
config = load_config()
if not config.get("autoRecall") and not config.get("autoRetain"):
debug_log(config, "Both autoRecall and autoRetain disabled, skipping session start")
return
# Consume stdin
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
hook_input = {}
debug_log(config, f"SessionStart hook, source: {hook_input.get('source', 'unknown')}")
# Try to resolve API URL (health check). Don't start daemon here —
# that's too slow for session start. Just check if server is reachable.
def _dbg(*a):
debug_log(config, *a)
try:
api_url = get_api_url(config, debug_fn=_dbg, allow_daemon_start=False)
client = HindsightClient(api_url, config.get("hindsightApiToken"))
debug_log(config, f"Hindsight server reachable at {api_url}")
except (RuntimeError, ValueError) as e:
# Server not available — log but don't block session
debug_log(config, f"Hindsight not available at session start: {e}")
return
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[Hindsight] SessionStart error: {e}", file=sys.stderr)
sys.exit(0)

View file

@ -0,0 +1,33 @@
{
"hindsightApiUrl": "",
"bankId": "claude_code",
"bankMission": "You are a Claude Code AI assistant. Focus on technical discussions, decisions, and context relevant to the user's projects.",
"retainMission": "Extract technical decisions, architectural choices, user preferences, project context, and people/tool relationships. Ignore routine greetings and transient operational details.",
"autoRecall": true,
"autoRetain": true,
"recallBudget": "mid",
"recallMaxTokens": 1024,
"recallTypes": ["world", "experience"],
"recallContextTurns": 1,
"recallMaxQueryChars": 800,
"recallRoles": ["user", "assistant"],
"recallPromptPreamble": "Relevant memories from past conversations (prioritize recent when conflicting). Only use memories that are directly useful to continue this conversation; ignore the rest:",
"recallTopK": null,
"retainRoles": ["user", "assistant"],
"retainEveryNTurns": 10,
"retainOverlapTurns": 2,
"retainContext": "claude-code",
"hindsightApiToken": null,
"apiPort": 9077,
"daemonIdleTimeout": 0,
"embedVersion": "latest",
"embedPackagePath": null,
"bankIdPrefix": "",
"dynamicBankId": false,
"dynamicBankGranularity": ["agent", "project"],
"agentName": "",
"llmProvider": null,
"llmModel": null,
"llmApiKeyEnv": null,
"debug": false
}