From f4390bdc2e124cd8d8ccdb2c7b64a6f11d93305f Mon Sep 17 00:00:00 2001 From: Fabio Scarsi <103823600+fabioscarsi@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:06:54 +0100 Subject: [PATCH] feat: Add Claude Code integration plugin (#651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../claude-code/.claude-plugin/plugin.json | 8 + hindsight-integrations/claude-code/LICENSE | 21 + hindsight-integrations/claude-code/README.md | 269 ++++++++++++ .../claude-code/hooks/hooks.json | 49 +++ hindsight-integrations/claude-code/install.sh | 59 +++ .../claude-code/scripts/lib/__init__.py | 0 .../claude-code/scripts/lib/bank.py | 122 ++++++ .../claude-code/scripts/lib/client.py | 142 +++++++ .../claude-code/scripts/lib/config.py | 123 ++++++ .../claude-code/scripts/lib/content.py | 388 ++++++++++++++++++ .../claude-code/scripts/lib/daemon.py | 272 ++++++++++++ .../claude-code/scripts/lib/llm.py | 145 +++++++ .../claude-code/scripts/lib/state.py | 113 +++++ .../claude-code/scripts/recall.py | 216 ++++++++++ .../claude-code/scripts/retain.py | 187 +++++++++ .../claude-code/scripts/session_end.py | 43 ++ .../claude-code/scripts/session_start.py | 58 +++ .../claude-code/settings.json | 33 ++ 18 files changed, 2248 insertions(+) create mode 100644 hindsight-integrations/claude-code/.claude-plugin/plugin.json create mode 100644 hindsight-integrations/claude-code/LICENSE create mode 100644 hindsight-integrations/claude-code/README.md create mode 100644 hindsight-integrations/claude-code/hooks/hooks.json create mode 100755 hindsight-integrations/claude-code/install.sh create mode 100644 hindsight-integrations/claude-code/scripts/lib/__init__.py create mode 100644 hindsight-integrations/claude-code/scripts/lib/bank.py create mode 100644 hindsight-integrations/claude-code/scripts/lib/client.py create mode 100644 hindsight-integrations/claude-code/scripts/lib/config.py create mode 100644 hindsight-integrations/claude-code/scripts/lib/content.py create mode 100644 hindsight-integrations/claude-code/scripts/lib/daemon.py create mode 100644 hindsight-integrations/claude-code/scripts/lib/llm.py create mode 100644 hindsight-integrations/claude-code/scripts/lib/state.py create mode 100755 hindsight-integrations/claude-code/scripts/recall.py create mode 100755 hindsight-integrations/claude-code/scripts/retain.py create mode 100755 hindsight-integrations/claude-code/scripts/session_end.py create mode 100755 hindsight-integrations/claude-code/scripts/session_start.py create mode 100644 hindsight-integrations/claude-code/settings.json diff --git a/hindsight-integrations/claude-code/.claude-plugin/plugin.json b/hindsight-integrations/claude-code/.claude-plugin/plugin.json new file mode 100644 index 00000000..b6bcaa69 --- /dev/null +++ b/hindsight-integrations/claude-code/.claude-plugin/plugin.json @@ -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"] +} diff --git a/hindsight-integrations/claude-code/LICENSE b/hindsight-integrations/claude-code/LICENSE new file mode 100644 index 00000000..8dce7b4d --- /dev/null +++ b/hindsight-integrations/claude-code/LICENSE @@ -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. diff --git a/hindsight-integrations/claude-code/README.md b/hindsight-integrations/claude-code/README.md new file mode 100644 index 00000000..4d6b635e --- /dev/null +++ b/hindsight-integrations/claude-code/README.md @@ -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 `` 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 `` 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 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 diff --git a/hindsight-integrations/claude-code/hooks/hooks.json b/hindsight-integrations/claude-code/hooks/hooks.json new file mode 100644 index 00000000..a099fd26 --- /dev/null +++ b/hindsight-integrations/claude-code/hooks/hooks.json @@ -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 + } + ] + } + ] + } +} diff --git a/hindsight-integrations/claude-code/install.sh b/hindsight-integrations/claude-code/install.sh new file mode 100755 index 00000000..45f02a9e --- /dev/null +++ b/hindsight-integrations/claude-code/install.sh @@ -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)." diff --git a/hindsight-integrations/claude-code/scripts/lib/__init__.py b/hindsight-integrations/claude-code/scripts/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hindsight-integrations/claude-code/scripts/lib/bank.py b/hindsight-integrations/claude-code/scripts/lib/bank.py new file mode 100644 index 00000000..6300ba86 --- /dev/null +++ b/hindsight-integrations/claude-code/scripts/lib/bank.py @@ -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}") diff --git a/hindsight-integrations/claude-code/scripts/lib/client.py b/hindsight-integrations/claude-code/scripts/lib/client.py new file mode 100644 index 00000000..f7e5125f --- /dev/null +++ b/hindsight-integrations/claude-code/scripts/lib/client.py @@ -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) diff --git a/hindsight-integrations/claude-code/scripts/lib/config.py b/hindsight-integrations/claude-code/scripts/lib/config.py new file mode 100644 index 00000000..1ee7c059 --- /dev/null +++ b/hindsight-integrations/claude-code/scripts/lib/config.py @@ -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) diff --git a/hindsight-integrations/claude-code/scripts/lib/content.py b/hindsight-integrations/claude-code/scripts/lib/content.py new file mode 100644 index 00000000..9a723ab6 --- /dev/null +++ b/hindsight-integrations/claude-code/scripts/lib/content.py @@ -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: + + actual message text + + + 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 content — extract inner text + match = re.search(r"]*>([\s\S]*?)", content) + if match: + return match.group(1).strip() + return content + + +def strip_memory_tags(content: str) -> str: + """Remove and 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"[\s\S]*?", "", content) + content = re.sub(r"[\s\S]*?", "", 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 = 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 + 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: - [] () + """ + 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 "" diff --git a/hindsight-integrations/claude-code/scripts/lib/daemon.py b/hindsight-integrations/claude-code/scripts/lib/daemon.py new file mode 100644 index 00000000..26c63e02 --- /dev/null +++ b/hindsight-integrations/claude-code/scripts/lib/daemon.py @@ -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, {}) diff --git a/hindsight-integrations/claude-code/scripts/lib/llm.py b/hindsight-integrations/claude-code/scripts/lib/llm.py new file mode 100644 index 00000000..a9302af8 --- /dev/null +++ b/hindsight-integrations/claude-code/scripts/lib/llm.py @@ -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 diff --git a/hindsight-integrations/claude-code/scripts/lib/state.py b/hindsight-integrations/claude-code/scripts/lib/state.py new file mode 100644 index 00000000..d16acd3d --- /dev/null +++ b/hindsight-integrations/claude-code/scripts/lib/state.py @@ -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] diff --git a/hindsight-integrations/claude-code/scripts/recall.py b/hindsight-integrations/claude-code/scripts/recall.py new file mode 100755 index 00000000..415e172b --- /dev/null +++ b/hindsight-integrations/claude-code/scripts/recall.py @@ -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"\n" + f"{preamble}\n" + f"Current time - {current_time}\n\n" + f"{memories_formatted}\n" + f"" + ) + + # 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) diff --git a/hindsight-integrations/claude-code/scripts/retain.py b/hindsight-integrations/claude-code/scripts/retain.py new file mode 100755 index 00000000..05570f82 --- /dev/null +++ b/hindsight-integrations/claude-code/scripts/retain.py @@ -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) diff --git a/hindsight-integrations/claude-code/scripts/session_end.py b/hindsight-integrations/claude-code/scripts/session_end.py new file mode 100755 index 00000000..e877c14c --- /dev/null +++ b/hindsight-integrations/claude-code/scripts/session_end.py @@ -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) diff --git a/hindsight-integrations/claude-code/scripts/session_start.py b/hindsight-integrations/claude-code/scripts/session_start.py new file mode 100755 index 00000000..2d8e308a --- /dev/null +++ b/hindsight-integrations/claude-code/scripts/session_start.py @@ -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) diff --git a/hindsight-integrations/claude-code/settings.json b/hindsight-integrations/claude-code/settings.json new file mode 100644 index 00000000..4af325c6 --- /dev/null +++ b/hindsight-integrations/claude-code/settings.json @@ -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 +}