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:
parent
e0f0da5d2d
commit
f4390bdc2e
18 changed files with 2248 additions and 0 deletions
|
|
@ -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"]
|
||||
}
|
||||
21
hindsight-integrations/claude-code/LICENSE
Normal file
21
hindsight-integrations/claude-code/LICENSE
Normal 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.
|
||||
269
hindsight-integrations/claude-code/README.md
Normal file
269
hindsight-integrations/claude-code/README.md
Normal 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
|
||||
49
hindsight-integrations/claude-code/hooks/hooks.json
Normal file
49
hindsight-integrations/claude-code/hooks/hooks.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
59
hindsight-integrations/claude-code/install.sh
Executable file
59
hindsight-integrations/claude-code/install.sh
Executable 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)."
|
||||
122
hindsight-integrations/claude-code/scripts/lib/bank.py
Normal file
122
hindsight-integrations/claude-code/scripts/lib/bank.py
Normal 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}")
|
||||
142
hindsight-integrations/claude-code/scripts/lib/client.py
Normal file
142
hindsight-integrations/claude-code/scripts/lib/client.py
Normal 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)
|
||||
123
hindsight-integrations/claude-code/scripts/lib/config.py
Normal file
123
hindsight-integrations/claude-code/scripts/lib/config.py
Normal 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)
|
||||
388
hindsight-integrations/claude-code/scripts/lib/content.py
Normal file
388
hindsight-integrations/claude-code/scripts/lib/content.py
Normal 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 ""
|
||||
272
hindsight-integrations/claude-code/scripts/lib/daemon.py
Normal file
272
hindsight-integrations/claude-code/scripts/lib/daemon.py
Normal 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, {})
|
||||
145
hindsight-integrations/claude-code/scripts/lib/llm.py
Normal file
145
hindsight-integrations/claude-code/scripts/lib/llm.py
Normal 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
|
||||
113
hindsight-integrations/claude-code/scripts/lib/state.py
Normal file
113
hindsight-integrations/claude-code/scripts/lib/state.py
Normal 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]
|
||||
216
hindsight-integrations/claude-code/scripts/recall.py
Executable file
216
hindsight-integrations/claude-code/scripts/recall.py
Executable 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)
|
||||
187
hindsight-integrations/claude-code/scripts/retain.py
Executable file
187
hindsight-integrations/claude-code/scripts/retain.py
Executable 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)
|
||||
43
hindsight-integrations/claude-code/scripts/session_end.py
Executable file
43
hindsight-integrations/claude-code/scripts/session_end.py
Executable 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)
|
||||
58
hindsight-integrations/claude-code/scripts/session_start.py
Executable file
58
hindsight-integrations/claude-code/scripts/session_start.py
Executable 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)
|
||||
33
hindsight-integrations/claude-code/settings.json
Normal file
33
hindsight-integrations/claude-code/settings.json
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue