fleet-memory/hindsight-integrations/claude-code/scripts/lib/config.py
Nicolò Boschi 2d31b67d0c
feat(claude-code): full-session retain with document upsert and configurable tags (#695)
* feat(claude-code): full-session retain mode with document upsert and configurable tags

Switch default retain behavior from per-turn chunks to full-session upsert.
Each session is now retained as a single document (document_id = session_id)
that gets updated on every Stop event, instead of creating fragmented
documents with timestamp-suffixed IDs.

New config options:
- retainMode: "full-session" (default) or "chunked" (legacy)
- retainTags: list with template variable support ({session_id}, {bank_id}, {timestamp})
- retainMetadata: extra metadata dict merged with built-in fields, supports templates

* fix(claude-code): respect retainEveryNTurns in full-session mode

The turn-count gating was only applied in chunked mode, meaning
full-session mode would re-ingest the entire transcript on every
single Stop event. Now retainEveryNTurns gates both modes.

Also fix test isolation: resolve ~/.hindsight/claude-code.json at
call time (not module load) so HOME override in tests works correctly.

* fix(claude-code): fix config tests after USER_CONFIG_PATH removal

Update tests to use HOME env var override instead of monkeypatching
the removed USER_CONFIG_PATH constant. Add autouse fixture to
TestLoadConfig to isolate all config tests from real user config
and HINDSIGHT_* env vars.
2026-03-26 12:06:30 +01:00

146 lines
5 KiB
Python

"""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,
"retainMode": "full-session",
"retainRoles": ["user", "assistant"],
"retainEveryNTurns": 10,
"retainOverlapTurns": 2,
"retainContext": "claude-code",
"retainTags": [],
"retainMetadata": {},
# 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_RETAIN_MODE": ("retainMode", str),
"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_settings_file(path: str, config: dict) -> None:
"""Merge a settings.json file into config in-place. Silently skips if missing."""
if not os.path.exists(path):
return
try:
with open(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 {path}: {e}")
def load_config() -> dict:
"""Load plugin configuration from settings.json + env overrides.
Loading order (later entries win):
1. Built-in defaults
2. Plugin default settings.json (CLAUDE_PLUGIN_ROOT/settings.json)
3. User config (~/.hindsight/claude-code.json)
4. Environment variable overrides
~/.hindsight/claude-code.json is the recommended place to configure the
plugin — same convention as ~/.openclaw/openclaw.json. It is stable across
plugin updates and marketplace changes.
"""
config = dict(DEFAULTS)
# 1. Plugin default settings.json (ships with the plugin, version-specific path)
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__))))
_load_settings_file(os.path.join(plugin_root, "settings.json"), config)
# 2. User config — stable, version-independent, matches openclaw convention
user_config_path = os.path.join(os.path.expanduser("~"), ".hindsight", "claude-code.json")
_load_settings_file(user_config_path, config)
# 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)