fleet-memory/hindsight-integrations/claude-code/scripts/lib/bank.py
Fabio Scarsi f4390bdc2e
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>
2026-03-23 12:06:54 +01:00

122 lines
4.5 KiB
Python

"""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}")