* docs(claude-code): tidy configuration reference and sync README Add missing settings (retainMode, retainToolCalls, retainTags, retainMetadata, embedPackagePath, llmApiKeyEnv, agentName, and several recall options) that existed in code but not in docs. Restructure config tables with prose introductions, clearer descriptions, and consistent layout across both files. * refactor(claude-code): remove recallTopK setting Unused client-side cap — Hindsight server already controls result count via recallBudget and recallMaxTokens.
211 lines
6.9 KiB
Python
Executable file
211 lines
6.9 KiB
Python
Executable file
#!/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
|
|
|
|
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)
|