Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 26a27b79fc chore: trigger CI 2026-05-05 14:51:30 +02:00
Nicolò Boschi 50eb26fbe0 chore: regenerate docs skill 2026-05-05 14:51:30 +02:00
Nicolò Boschi 0bfc779946 feat(claude-code): add get_current_bank tool so Claude can tell user which bank is active 2026-05-05 14:51:30 +02:00
Nicolò Boschi 88f938a888 feat(self-driving-agents): copy bank-template.json and instruct Claude to create mental models from it 2026-05-05 14:51:30 +02:00
Nicolò Boschi 93f9d98b24 refactor(claude-code): remove bank_id from MCP tool params
bank_id is no longer exposed as a parameter on any MCP tool. The
server resolves it once at startup from plugin config (derive_bank_id).
This prevents Claude from trying to override it or getting confused
about which bank to use.

Removed inject_bank_id.py PreToolUse hook — no longer needed since
bank resolution is server-side only.
2026-05-05 14:51:30 +02:00
Nicolò Boschi 316e85feb1 feat(self-driving-agents): auto-approve MCP tools, skill, and bash for claude-code 2026-05-05 14:51:30 +02:00
Nicolò Boschi 98f25a58f8 feat(claude-code): add ingest_file tool + auto-approve MCP tools in skill
- Add agent_knowledge_ingest_file(file_path) — reads file server-side,
  no need to pass content inline. Avoids permission prompts for large
  content and keeps tool calls clean.
- Add mcp__hindsight__* to create-agent skill's allowed-tools
- Update skill instructions to prefer ingest_file for disk files
2026-05-05 14:51:30 +02:00
Nicolò Boschi d2d0115530 docs(claude-code): clarify ingest steps in create-agent skill 2026-05-05 14:51:30 +02:00
Nicolò Boschi 8eb1918560 feat(claude-code): auto-approve bash for .self-driving-agents dir in create-agent skill 2026-05-05 14:51:30 +02:00
Nicolò Boschi 97798ac35d refactor(self-driving-agents): simplify claude-code harness — just save content + print prompt
The CLI no longer writes subagent files, resolves banks, or patches
permissions for --harness claude-code. Instead it:
1. Fetches content from GitHub
2. Saves it to ~/.self-driving-agents/claude-code/<agent-id>/
3. Prints the exact prompt to give Claude Code

Claude handles everything via /hindsight-memory:create-agent skill:
- Creates the subagent
- Ingests the seed docs
- Creates initial knowledge pages based on the content

This eliminates all bank derivation issues (bank resolved at runtime
by the plugin) and keeps one code path for agent creation (the skill).
2026-05-05 14:51:29 +02:00
Nicolò Boschi 896ec26f6c refactor(claude-code): remove agent-knowledge skill — subagent body is self-contained 2026-05-05 14:51:29 +02:00
Nicolò Boschi 13d7cab7b3 feat(claude-code): add /create-agent skill for in-session agent creation 2026-05-05 14:51:29 +02:00
Nicolò Boschi 8bd17baab2 fix(self-driving-agents): fail if subagent already exists in claude-code 2026-05-05 14:51:29 +02:00
Nicolò Boschi 10058f4b68 fix(self-driving-agents): use plugin's agentName for bank derivation, not CLI agentId 2026-05-05 14:51:29 +02:00
Nicolò Boschi 7bfb460dc4 fix(self-driving-agents): resolve bank with project dimension from cwd
resolveFromClaudeCode now includes all dimensions (agent, project,
session, channel, user) matching the plugin's bank.py logic. The
project dimension uses basename(process.cwd()), so running the
installer from a repo directory ingests content into the correct
per-project bank that the plugin will use at runtime.
2026-05-05 14:51:29 +02:00
Nicolò Boschi d5cd04ddbf fix(self-driving-agents): use plugin bank derivation for content ingestion 2026-05-05 14:51:29 +02:00
Nicolò Boschi 2f9a93b318 feat(self-driving-agents): auto-approve hindsight MCP tools in user settings 2026-05-05 14:51:29 +02:00
Nicolò Boschi 6e40556115 fix(self-driving-agents): don't overwrite plugin config on subsequent installs
If ~/.hindsight/claude-code.json already has a Hindsight connection
configured, use it as-is. Only prompt for Cloud/Self-hosted setup on
first install. This prevents installing a second agent from clobbering
the shared config (agentName, bankId, etc.) that the plugin uses at
runtime.
2026-05-05 14:51:29 +02:00
Nicolò Boschi 8ff33713cf refactor(claude-code): simplify subagent — no hardcoded bank_id, no Stop hook
The subagent no longer hardcodes bank_id or has its own Stop hook.
Instead:
- inject_bank_id.py PreToolUse hook derives bank_id at runtime from
  the plugin config (supports dynamicBankId, per-repo via cwd, etc.)
- The main plugin's Stop hook retains the full conversation (including
  user input) to the derived bank

This means:
- Multiple subagents share the same bank (derived from plugin config)
- Per-repo isolation works via dynamicBankGranularity: ["agent", "project"]
- User input from the main thread is retained (not lost in subagent context)
- Subagent template is simpler — just tool instructions, no bank plumbing
2026-05-05 14:51:29 +02:00
24 changed files with 508 additions and 823 deletions
@@ -135,11 +135,15 @@ ENV_LLM_TIMEOUT = "HINDSIGHT_API_LLM_TIMEOUT"
ENV_LLM_GROQ_SERVICE_TIER = "HINDSIGHT_API_LLM_GROQ_SERVICE_TIER"
ENV_LLM_OPENAI_SERVICE_TIER = "HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER"
ENV_LLM_EXTRA_BODY = "HINDSIGHT_API_LLM_EXTRA_BODY"
ENV_LLM_DEFAULT_HEADERS = "HINDSIGHT_API_LLM_DEFAULT_HEADERS"
# Defaults for service tiers
DEFAULT_LLM_GROQ_SERVICE_TIER = "auto" # "on_demand", "flex", or "auto"
DEFAULT_LLM_OPENAI_SERVICE_TIER = None # None (default) or "flex" (50% cheaper)
DEFAULT_LLM_EXTRA_BODY = None # None = no extra body params; JSON dict merged into OpenAI extra_body
DEFAULT_LLM_DEFAULT_HEADERS = (
None # None = no extra headers; JSON dict passed as default_headers to provider SDK clients
)
# Per-operation LLM configuration (optional, falls back to global LLM config)
ENV_RETAIN_LLM_PROVIDER = "HINDSIGHT_API_RETAIN_LLM_PROVIDER"
@@ -847,6 +851,9 @@ class HindsightConfig:
llm_extra_body: (
dict | None
) # Extra body params merged into OpenAI-compatible API calls (e.g. {"chat_template_kwargs": {"enable_thinking": true}})
llm_default_headers: (
dict | None
) # Custom headers passed as default_headers to provider SDK clients (e.g. {"X-Component-Id": "hindsight"} for proxies / request tracing)
# Vertex AI configuration
llm_vertexai_project_id: str | None
@@ -1369,6 +1376,7 @@ class HindsightConfig:
llm_groq_service_tier=os.getenv(ENV_LLM_GROQ_SERVICE_TIER, DEFAULT_LLM_GROQ_SERVICE_TIER),
llm_openai_service_tier=os.getenv(ENV_LLM_OPENAI_SERVICE_TIER, DEFAULT_LLM_OPENAI_SERVICE_TIER),
llm_extra_body=json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null")),
llm_default_headers=json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null")),
# Vertex AI
llm_vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or DEFAULT_LLM_VERTEXAI_PROJECT_ID,
llm_vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION, DEFAULT_LLM_VERTEXAI_REGION),
@@ -148,6 +148,7 @@ def create_llm_provider(
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
extra_body: dict[str, Any] | None = None,
default_headers: dict[str, str] | None = None,
vertexai_project_id: str | None = None,
vertexai_region: str | None = None,
vertexai_credentials: Any = None,
@@ -165,6 +166,9 @@ def create_llm_provider(
groq_service_tier: Groq service tier (for Groq provider) - "on_demand", "flex", or "auto".
openai_service_tier: OpenAI service tier (for OpenAI provider) - None (default) or "flex" (50% cheaper).
extra_body: Extra body params merged into OpenAI-compatible API calls.
default_headers: Custom headers passed as ``default_headers`` to provider SDK clients
(used by operators routing through proxies / request-tracing middleware). Currently
wired into the Anthropic provider; other providers may opt in as needed.
vertexai_project_id: Vertex AI project ID (for VertexAI provider).
vertexai_region: Vertex AI region (for VertexAI provider).
vertexai_credentials: Vertex AI credentials object (for VertexAI provider).
@@ -243,6 +247,7 @@ def create_llm_provider(
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
default_headers=default_headers,
)
elif provider_lower == "litellm":
@@ -317,6 +322,7 @@ class LLMProvider:
openai_service_tier: str | None = None,
gemini_safety_settings: list | None = None,
extra_body: dict[str, Any] | None = None,
default_headers: dict[str, str] | None = None,
):
"""
Initialize LLM provider.
@@ -331,6 +337,10 @@ class LLMProvider:
openai_service_tier: OpenAI service tier (None or "flex") - from config.
gemini_safety_settings: Safety settings for Gemini/VertexAI providers.
extra_body: Extra body params merged into OpenAI-compatible API calls.
default_headers: Custom headers passed as ``default_headers`` to provider SDK clients.
Used by operators routing through proxies / request-tracing middleware. Falls
back to ``HindsightConfig.llm_default_headers`` (env: ``HINDSIGHT_API_LLM_DEFAULT_HEADERS``)
when ``None``.
"""
self.provider = provider.lower()
self.api_key = api_key
@@ -344,6 +354,17 @@ class LLMProvider:
self.gemini_safety_settings = gemini_safety_settings
# Extra body params for OpenAI-compatible providers (e.g. chat_template_kwargs)
self.extra_body = extra_body
# Default headers passed to provider SDK clients (e.g. proxy auth, request tracing).
# Same pattern as ``gemini_safety_settings``: explicit override wins; otherwise read
# the static server-level default from ``HindsightConfig`` via ``_get_raw_config()``.
self.default_headers = default_headers
if self.default_headers is None:
from ..config import _get_raw_config
try:
self.default_headers = _get_raw_config().llm_default_headers
except Exception:
pass # Config may not be initialized in test environments
# Validate provider
valid_providers = [
@@ -448,6 +469,7 @@ class LLMProvider:
groq_service_tier=self.groq_service_tier,
openai_service_tier=self.openai_service_tier,
extra_body=self.extra_body,
default_headers=self.default_headers,
vertexai_project_id=vertexai_project_id,
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
@@ -763,6 +785,7 @@ class LLMProvider:
DEFAULT_LLM_PROVIDER,
ENV_LLM_API_KEY,
ENV_LLM_BASE_URL,
ENV_LLM_DEFAULT_HEADERS,
ENV_LLM_EXTRA_BODY,
ENV_LLM_MODEL,
ENV_LLM_PROVIDER,
@@ -781,6 +804,7 @@ class LLMProvider:
base_url = os.getenv(ENV_LLM_BASE_URL, "")
model = os.getenv(ENV_LLM_MODEL, DEFAULT_LLM_MODEL)
extra_body = json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null"))
default_headers = json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null"))
return cls(
provider=provider,
@@ -789,6 +813,7 @@ class LLMProvider:
model=model,
reasoning_effort="low",
extra_body=extra_body,
default_headers=default_headers,
)
@@ -539,6 +539,7 @@ class MemoryEngine(MemoryEngineInterface):
base_url=memory_llm_base_url,
model=memory_llm_model,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
)
# Store client and model for convenience (deprecated: use _llm_config.call() instead)
@@ -566,6 +567,7 @@ class MemoryEngine(MemoryEngineInterface):
base_url=retain_base_url,
model=retain_model,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
)
# Reflect LLM config - for think/observe operations (can use lighter models)
@@ -588,6 +590,7 @@ class MemoryEngine(MemoryEngineInterface):
base_url=reflect_base_url,
model=reflect_model,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
)
# Consolidation LLM config - for mental model consolidation (can use efficient models)
@@ -610,6 +613,7 @@ class MemoryEngine(MemoryEngineInterface):
base_url=consolidation_base_url,
model=consolidation_model,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
)
# Initialize cross-encoder reranker (cached for performance)
@@ -37,6 +37,7 @@ class AnthropicLLM(LLMInterface):
model: str,
reasoning_effort: str = "low",
timeout: float = 300.0,
default_headers: dict[str, str] | None = None,
**kwargs: Any,
):
"""
@@ -49,6 +50,10 @@ class AnthropicLLM(LLMInterface):
model: Model name (e.g., "claude-sonnet-4-20250514").
reasoning_effort: Reasoning effort level (not used by Anthropic).
timeout: Request timeout in seconds.
default_headers: Optional custom headers passed as ``default_headers`` to
the Anthropic SDK client. Used by operators routing through proxies
or request-tracing middleware. Sourced from ``llm_default_headers`` in
``HindsightConfig`` (env: ``HINDSIGHT_API_LLM_DEFAULT_HEADERS``).
**kwargs: Additional provider-specific parameters.
"""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
@@ -60,11 +65,16 @@ class AnthropicLLM(LLMInterface):
try:
from anthropic import AsyncAnthropic
client_kwargs: dict[str, Any] = {"api_key": self.api_key}
# SDK retries disabled — wrapper-level retry loop in ``call`` handles
# backoff (mirrors ``OpenAICompatibleLLM`` so the two providers behave
# consistently).
client_kwargs: dict[str, Any] = {"api_key": self.api_key, "max_retries": 0}
if self.base_url:
client_kwargs["base_url"] = self.base_url
if timeout:
client_kwargs["timeout"] = timeout
if default_headers:
client_kwargs["default_headers"] = default_headers
self._client = AsyncAnthropic(**client_kwargs)
logger.info(f"Anthropic client initialized for model: {self.model}")
+30 -2
View File
@@ -12,6 +12,7 @@ from datetime import datetime, timezone
from typing import Any, Callable
from fastmcp import FastMCP
from pydantic import TypeAdapter
from hindsight_api import MemoryEngine
from hindsight_api.config import (
@@ -21,9 +22,12 @@ from hindsight_api.config import (
from hindsight_api.engine.audit import AuditEntry, AuditLogger
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
from hindsight_api.engine.search.tags import TagGroup
from hindsight_api.extensions import OperationValidationError
from hindsight_api.models import RequestContext
_TAG_GROUP_LIST_ADAPTER = TypeAdapter(list[TagGroup])
# All tools available in the system (explicit list — no wildcards).
# Defined here (shared module) to avoid circular imports with api/mcp.py.
_ALL_TOOLS: frozenset[str] = frozenset(
@@ -773,6 +777,7 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
types: list[str] | None = None,
tags: list[str] | None = None,
tags_match: str = "any",
tag_groups: list[dict] | None = None,
query_timestamp: str | None = None,
bank_id: str | None = None,
) -> str | dict:
@@ -782,8 +787,12 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
max_tokens: Maximum tokens to return in results (default: 4096)
budget: Search budget - 'low', 'mid', or 'high' (default: 'high'). Higher budgets search more thoroughly.
types: Fact types to include (e.g., ['world', 'experience']). Default: all types.
tags: Optional tags to filter results by (e.g., ['project:alpha'])
tags: Optional tags to filter results by (e.g., ['project:alpha']). Mutually exclusive with tag_groups.
tags_match: How to match tags - 'any' (match any tag) or 'all' (match all tags). Default: 'any'
tag_groups: Compound tag filter using boolean groups (AND-ed together). Each group is a leaf
{"tags": [...], "match": "any_strict"} or compound {"and": [...]}, {"or": [...]}, {"not": {...}}.
Example: [{"not": {"tags": ["closeout"], "match": "any_strict"}}] excludes memories tagged closeout.
Mutually exclusive with tags.
query_timestamp: Temporal context for the query (ISO format, e.g., '2024-01-15T10:30:00Z'). Helps retrieve time-relevant memories.
bank_id: Optional bank to search in (defaults to session bank). Use for cross-bank operations.
"""
@@ -792,6 +801,11 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
if target_bank is None:
return "Error: No bank_id configured"
if tags is not None and tag_groups is not None:
raise ValueError(
"'tags' and 'tag_groups' are mutually exclusive. Use 'tag_groups' for compound filtering."
)
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
budget_enum = budget_map.get(budget.lower(), Budget.HIGH)
fact_types = types if types is not None else list(VALID_RECALL_FACT_TYPES)
@@ -807,6 +821,8 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
if tags is not None:
recall_kwargs["tags"] = tags
recall_kwargs["tags_match"] = tags_match
if tag_groups is not None:
recall_kwargs["tag_groups"] = _TAG_GROUP_LIST_ADAPTER.validate_python(tag_groups)
if query_timestamp is not None:
recall_kwargs["question_date"] = parse_timestamp(query_timestamp)
@@ -832,6 +848,7 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
types: list[str] | None = None,
tags: list[str] | None = None,
tags_match: str = "any",
tag_groups: list[dict] | None = None,
query_timestamp: str | None = None,
) -> dict:
"""
@@ -840,8 +857,12 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
max_tokens: Maximum tokens to return in results (default: 4096)
budget: Search budget - 'low', 'mid', or 'high' (default: 'high'). Higher budgets search more thoroughly.
types: Fact types to include (e.g., ['world', 'experience']). Default: all types.
tags: Optional tags to filter results by (e.g., ['project:alpha'])
tags: Optional tags to filter results by (e.g., ['project:alpha']). Mutually exclusive with tag_groups.
tags_match: How to match tags - 'any' (match any tag) or 'all' (match all tags). Default: 'any'
tag_groups: Compound tag filter using boolean groups (AND-ed together). Each group is a leaf
{"tags": [...], "match": "any_strict"} or compound {"and": [...]}, {"or": [...]}, {"not": {...}}.
Example: [{"not": {"tags": ["closeout"], "match": "any_strict"}}] excludes memories tagged closeout.
Mutually exclusive with tags.
query_timestamp: Temporal context for the query (ISO format, e.g., '2024-01-15T10:30:00Z'). Helps retrieve time-relevant memories.
"""
try:
@@ -849,6 +870,11 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
if target_bank is None:
return {"error": "No bank_id configured", "results": []}
if tags is not None and tag_groups is not None:
raise ValueError(
"'tags' and 'tag_groups' are mutually exclusive. Use 'tag_groups' for compound filtering."
)
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
budget_enum = budget_map.get(budget.lower(), Budget.HIGH)
fact_types = types if types is not None else list(VALID_RECALL_FACT_TYPES)
@@ -864,6 +890,8 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
if tags is not None:
recall_kwargs["tags"] = tags
recall_kwargs["tags_match"] = tags_match
if tag_groups is not None:
recall_kwargs["tag_groups"] = _TAG_GROUP_LIST_ADAPTER.validate_python(tag_groups)
if query_timestamp is not None:
recall_kwargs["question_date"] = parse_timestamp(query_timestamp)
@@ -949,6 +949,51 @@ class TestRecallNewParams:
call_kwargs = mock_memory.recall_async.call_args.kwargs
assert call_kwargs["question_date"] == datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
async def test_recall_with_tag_groups_negative_filter(self, mock_memory):
"""tag_groups with NOT should pass through to engine after Pydantic validation."""
from hindsight_api.engine.search.tags import TagGroupNot
mcp = _make_mcp_server(mock_memory, {"recall"})
await _tools(mcp)["recall"].fn(
query="test",
tag_groups=[{"not": {"tags": ["closeout"], "match": "any_strict"}}],
)
call_kwargs = mock_memory.recall_async.call_args.kwargs
assert "tag_groups" in call_kwargs
assert len(call_kwargs["tag_groups"]) == 1
group = call_kwargs["tag_groups"][0]
assert isinstance(group, TagGroupNot)
assert group.filter.tags == ["closeout"]
async def test_recall_without_tag_groups_no_kwarg(self, mock_memory):
"""tag_groups omitted should not appear in engine kwargs."""
mcp = _make_mcp_server(mock_memory, {"recall"})
await _tools(mcp)["recall"].fn(query="test")
call_kwargs = mock_memory.recall_async.call_args.kwargs
assert "tag_groups" not in call_kwargs
async def test_recall_tags_and_tag_groups_mutually_exclusive(self, mock_memory):
"""Passing both tags and tag_groups returns an error and does not call engine."""
mcp = _make_mcp_server(mock_memory, {"recall"})
result = await _tools(mcp)["recall"].fn(
query="test",
tags=["project:x"],
tag_groups=[{"tags": ["closeout"], "match": "any_strict"}],
)
assert "mutually exclusive" in result
mock_memory.recall_async.assert_not_called()
async def test_recall_tag_groups_single_bank(self, mock_memory):
"""tag_groups should also work in single-bank mode."""
mcp = _make_mcp_server(mock_memory, {"recall"}, include_bank_id=False)
await _tools(mcp)["recall"].fn(
query="test",
tag_groups=[{"tags": ["scope:work"], "match": "all_strict"}],
)
call_kwargs = mock_memory.recall_async.call_args.kwargs
assert "tag_groups" in call_kwargs
assert len(call_kwargs["tag_groups"]) == 1
@pytest.mark.asyncio
class TestReflectNewParams:
@@ -1,6 +1,6 @@
/**
* Preload script for running Jest-style tests under Deno.
* Injects Jest-compatible globals (describe, test, beforeAll, expect)
* Injects Jest-compatible globals (describe, test, beforeAll, expect, jest)
* using Deno's standard library BDD and expect modules.
*
* Usage:
@@ -11,6 +11,91 @@
import { beforeAll, beforeEach, afterAll, afterEach, describe, it } from "jsr:@std/testing/bdd";
import { expect } from "jsr:@std/expect";
// @std/expect recognises mock functions via this well-known symbol
const MOCK_SYMBOL = Symbol.for("@MOCK");
type MockCall = {
args: unknown[];
returned?: unknown;
thrown?: unknown;
timestamp: number;
returns: boolean;
throws: boolean;
};
function createMock(impl?: (...args: unknown[]) => unknown) {
let currentImpl = impl;
const calls: MockCall[] = [];
const mockInfo = { calls };
const mockFn = function (this: unknown, ...args: unknown[]) {
const call: MockCall = {
args,
timestamp: Date.now(),
returns: false,
throws: false,
};
calls.push(call);
try {
const result = currentImpl ? currentImpl.apply(this, args) : undefined;
call.returned = result;
call.returns = true;
return result;
} catch (err) {
call.thrown = err;
call.throws = true;
throw err;
}
};
(mockFn as any)[MOCK_SYMBOL] = mockInfo;
(mockFn as any).mockResolvedValue = (val: unknown) => {
currentImpl = () => Promise.resolve(val);
return mockFn;
};
(mockFn as any).mockRejectedValue = (val: unknown) => {
currentImpl = () => Promise.reject(val);
return mockFn;
};
(mockFn as any).mockImplementation = (fn: (...args: unknown[]) => unknown) => {
currentImpl = fn;
return mockFn;
};
(mockFn as any).mockReturnValue = (val: unknown) => {
currentImpl = () => val;
return mockFn;
};
(mockFn as any).mockReset = () => {
calls.length = 0;
currentImpl = undefined;
return mockFn;
};
(mockFn as any).mockClear = () => {
calls.length = 0;
return mockFn;
};
(mockFn as any).mockRestore = () => {};
return mockFn;
}
const jest = {
fn: (impl?: (...args: unknown[]) => unknown) => createMock(impl),
spyOn: <T extends Record<string, unknown>>(obj: T, method: keyof T) => {
const original = obj[method];
const mock = createMock(
typeof original === "function" ? (original as (...args: unknown[]) => unknown) : undefined
);
const restore = () => {
obj[method] = original;
};
(mock as any).mockRestore = restore;
obj[method] = mock as unknown as T[keyof T];
return mock;
},
};
Object.assign(globalThis, {
describe,
test: it,
@@ -20,4 +105,5 @@ Object.assign(globalThis, {
afterAll,
afterEach,
expect,
jest,
});
@@ -470,7 +470,11 @@ describe("TestMission", () => {
});
});
describe("TestAbortSignal", () => {
// Skip under Deno: jest.spyOn cannot patch ES-module namespace objects whose
// properties are frozen. These unit tests are covered by the Jest suite.
const canSpyOnModules = typeof (globalThis as any).Deno === "undefined";
(canSpyOnModules ? describe : describe.skip)("TestAbortSignal", () => {
test("retain passes abort signal to SDK", async () => {
const bankId = randomBankId();
const controller = new AbortController();
@@ -174,6 +174,7 @@ To switch between backends:
| `HINDSIGHT_API_LLM_GROQ_SERVICE_TIER` | Groq service tier: `on_demand`, `flex`, `auto` | `auto` |
| `HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER` | OpenAI service tier: `flex` for 50% cost savings (OpenAI Flex Processing) | None (default) |
| `HINDSIGHT_API_LLM_EXTRA_BODY` | JSON dict merged into `extra_body` for all OpenAI-compatible API calls. Useful for custom model servers (e.g., vLLM `chat_template_kwargs`). | `null` |
| `HINDSIGHT_API_LLM_DEFAULT_HEADERS` | JSON dict passed as `default_headers` to provider SDK clients. Used by operators routing through proxies / request-tracing middleware (e.g. Cloudflare AI Gateway, Helicone, corporate proxies). Currently wired into the Anthropic provider; other providers can opt in. | `null` |
| `HINDSIGHT_API_LLM_GEMINI_SAFETY_SETTINGS` | JSON-encoded list of `{category, threshold}` dicts for Gemini/VertexAI content safety filtering | `null` |
**Provider Examples**
@@ -10,6 +10,7 @@ import os
import platform
import re
import subprocess
import sysconfig
import time
from pathlib import Path
from typing import IO, Optional
@@ -132,12 +133,23 @@ class DaemonEmbedManager(EmbedManager):
if dev_api_path.exists() and (dev_api_path / "pyproject.toml").exists():
return ["uv", "run", "--project", str(dev_api_path), "--extra", "all", "hindsight-api"]
# Prefer a hindsight-api entry point installed alongside hindsight-embed
# (e.g. `uv pip install hindsight-all` or `--target`). Falling through
# to uvx in that case downloads a standalone Python whose ABI won't
# match the sibling site-packages' C extensions (issue #1240).
package_root = Path(__file__).parent.parent
# Prefer a hindsight-api entry point installed alongside hindsight-embed.
# Try two strategies:
#
# 1. sysconfig: resolves <venv>/bin or <venv>/Scripts for standard
# pip/venv installs (issue #1401).
# 2. __file__-relative: resolves <target>/bin or <target>/Scripts for
# `pip install --target` layouts where sysconfig still points at the
# system/venv scripts dir (issue #1240).
binary_name = "hindsight-api.exe" if platform.system() == "Windows" else "hindsight-api"
scripts_dir = Path(sysconfig.get_path("scripts"))
candidate = scripts_dir / binary_name
if candidate.exists():
return [str(candidate)]
# --target installs place binaries alongside site-packages contents
package_root = Path(__file__).parent.parent
for bin_dir in ("bin", "Scripts"):
candidate = package_root / bin_dir / binary_name
if candidate.exists():
+52 -27
View File
@@ -109,38 +109,64 @@ def test_find_ui_command_uses_npx_yes_flag_for_published_control_plane(monkeypat
]
def test_find_api_command_prefers_sibling_binary_over_uvx(tmp_path, monkeypatch):
def test_find_api_command_prefers_installed_binary_over_uvx(tmp_path, monkeypatch):
"""
When hindsight-api is installed alongside hindsight-embed (e.g. via
`uv pip install hindsight-all`), _find_api_command should invoke that
binary directly rather than shelling out to uvx. uvx downloads a
standalone Python whose ABI won't match sibling C extensions compiled
for the host Python (regression for issue #1240, NixOS asyncpg failure).
`pip install hindsight-all`), _find_api_command should invoke that
binary directly rather than shelling out to uvx. Uses sysconfig to
locate the venv's scripts directory (issue #1401, #1240).
"""
package_root = tmp_path / "site-packages" / "hindsight_embed"
package_root.mkdir(parents=True)
fake_module = package_root / "daemon_embed_manager.py"
scripts_dir = tmp_path / "bin"
scripts_dir.mkdir()
api_binary = scripts_dir / "hindsight-api"
api_binary.touch()
manager = DaemonEmbedManager()
# Point __file__ away from monorepo so dev-mode check doesn't trigger
monkeypatch.setattr("hindsight_embed.daemon_embed_manager.__file__", str(tmp_path / "hindsight_embed" / "daemon_embed_manager.py"))
monkeypatch.setattr("hindsight_embed.daemon_embed_manager.sysconfig.get_path", lambda key: str(scripts_dir))
monkeypatch.setattr("hindsight_embed.daemon_embed_manager.platform.system", lambda: "Linux")
assert manager._find_api_command() == [str(api_binary)]
def test_find_api_command_target_install_uses_file_relative_fallback(tmp_path, monkeypatch):
"""
When installed with `pip install --target`, sysconfig still points at the
system/venv scripts dir (no binary there). The __file__-relative fallback
should find the sibling binary in <target>/bin/ (issue #1240).
"""
# sysconfig points to an empty venv scripts dir (no binary)
venv_scripts = tmp_path / "venv_bin"
venv_scripts.mkdir()
# --target layout: binary sits next to site-packages contents
target_dir = tmp_path / "target"
pkg_dir = target_dir / "hindsight_embed"
pkg_dir.mkdir(parents=True)
fake_module = pkg_dir / "daemon_embed_manager.py"
fake_module.write_text("")
sibling_bin = tmp_path / "site-packages" / "bin" / "hindsight-api"
sibling_bin.parent.mkdir(parents=True)
sibling_bin = target_dir / "bin" / "hindsight-api"
sibling_bin.parent.mkdir()
sibling_bin.touch()
manager = DaemonEmbedManager()
monkeypatch.setattr("hindsight_embed.daemon_embed_manager.__file__", str(fake_module))
monkeypatch.setattr("hindsight_embed.daemon_embed_manager.sysconfig.get_path", lambda key: str(venv_scripts))
monkeypatch.setattr("hindsight_embed.daemon_embed_manager.platform.system", lambda: "Linux")
assert manager._find_api_command() == [str(sibling_bin)]
def test_find_api_command_falls_back_to_uvx_when_no_sibling_binary(tmp_path, monkeypatch):
"""Without a sibling binary or dev checkout, fall back to uvx."""
package_root = tmp_path / "site-packages" / "hindsight_embed"
package_root.mkdir(parents=True)
fake_module = package_root / "daemon_embed_manager.py"
fake_module.write_text("")
def test_find_api_command_falls_back_to_uvx_when_no_binary(tmp_path, monkeypatch):
"""Without an installed binary or dev checkout, fall back to uvx."""
scripts_dir = tmp_path / "bin"
scripts_dir.mkdir()
# No hindsight-api binary in scripts_dir
manager = DaemonEmbedManager()
monkeypatch.setattr("hindsight_embed.daemon_embed_manager.__file__", str(fake_module))
monkeypatch.setattr("hindsight_embed.daemon_embed_manager.__file__", str(tmp_path / "hindsight_embed" / "daemon_embed_manager.py"))
monkeypatch.setattr("hindsight_embed.daemon_embed_manager.sysconfig.get_path", lambda key: str(scripts_dir))
monkeypatch.setattr("hindsight_embed.daemon_embed_manager.platform.system", lambda: "Linux")
monkeypatch.setenv("HINDSIGHT_EMBED_API_VERSION", "1.2.3")
@@ -148,17 +174,16 @@ def test_find_api_command_falls_back_to_uvx_when_no_sibling_binary(tmp_path, mon
def test_find_api_command_windows_uses_exe_suffix(tmp_path, monkeypatch):
"""On Windows, the sibling binary has a .exe suffix."""
package_root = tmp_path / "site-packages" / "hindsight_embed"
package_root.mkdir(parents=True)
fake_module = package_root / "daemon_embed_manager.py"
fake_module.write_text("")
sibling_bin = tmp_path / "site-packages" / "Scripts" / "hindsight-api.exe"
sibling_bin.parent.mkdir(parents=True)
sibling_bin.touch()
"""On Windows, the installed binary has a .exe suffix."""
scripts_dir = tmp_path / "Scripts"
scripts_dir.mkdir()
api_binary = scripts_dir / "hindsight-api.exe"
api_binary.touch()
manager = DaemonEmbedManager()
monkeypatch.setattr("hindsight_embed.daemon_embed_manager.__file__", str(fake_module))
# Point __file__ away from monorepo so dev-mode check doesn't trigger
monkeypatch.setattr("hindsight_embed.daemon_embed_manager.__file__", str(tmp_path / "hindsight_embed" / "daemon_embed_manager.py"))
monkeypatch.setattr("hindsight_embed.daemon_embed_manager.sysconfig.get_path", lambda key: str(scripts_dir))
monkeypatch.setattr("hindsight_embed.daemon_embed_manager.platform.system", lambda: "Windows")
assert manager._find_api_command() == [str(sibling_bin)]
assert manager._find_api_command() == [str(api_binary)]
@@ -0,0 +1,14 @@
{
"name": "hindsight-local",
"owner": {
"name": "Hindsight Team",
"url": "https://vectorize.io/hindsight"
},
"plugins": [
{
"name": "hindsight-memory",
"description": "Automatic long-term memory via Hindsight. Retains conversations, provides knowledge page tools.",
"source": "./claude-code"
}
]
}
@@ -22,18 +22,6 @@
]
}
],
"PreToolUse": [
{
"matcher": "mcp__hindsight__agent_knowledge_.*",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/inject_bank_id.py\"",
"timeout": 3
}
]
}
],
"Stop": [
{
"hooks": [
@@ -1,61 +0,0 @@
#!/usr/bin/env python3
"""PreToolUse hook: inject bank_id into agent_knowledge_* MCP tool calls.
Intercepts mcp__hindsight__agent_knowledge_* tool calls and injects
the resolved bank_id into tool_input, using the same derivation logic
as the recall/retain hooks (config chain + cwd context).
Exit codes:
0 — always (allow the tool call to proceed)
"""
import json
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib.bank import derive_bank_id
from lib.config import debug_log, load_config
def main():
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
return
tool_input = hook_input.get("tool_input", {})
# Skip if bank_id already provided (explicit override)
if tool_input.get("bank_id"):
return
config = load_config()
if not config.get("enableKnowledgeTools"):
return
bank_id = derive_bank_id(hook_input, config)
debug_log(config, f"Injecting bank_id={bank_id} into {hook_input.get('tool_name')}")
# Return updatedInput with bank_id injected
updated = dict(tool_input)
updated["bank_id"] = bank_id
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": updated,
}
}
json.dump(output, sys.stdout)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[Hindsight] inject_bank_id error: {e}", file=sys.stderr)
sys.exit(0)
@@ -46,12 +46,7 @@ _hook_input = {"cwd": os.getcwd(), "session_id": ""}
_default_bank_id = derive_bank_id(_hook_input, _config)
_client = HindsightClient(_api_url, _config.get("hindsightApiToken"))
_dbg(f"MCP server starting — API: {_api_url}, default bank: {_default_bank_id}")
def _bank(bank_id: str = "") -> str:
"""Resolve bank ID: use explicit value if provided, else fall back to default."""
return bank_id if bank_id else _default_bank_id
_dbg(f"MCP server starting — API: {_api_url}, bank: {_default_bank_id}")
def _encode_bank(bank_id: str) -> str:
@@ -68,33 +63,38 @@ PAGE_DEFAULTS = {
}
# ── Tools ───────────────────────────────────────────────
# bank_id is NEVER exposed as a parameter — it's always resolved by the
# inject_bank_id.py PreToolUse hook from plugin config at runtime.
@mcp.tool()
def agent_knowledge_list_pages(bank_id: str = "") -> str:
def agent_knowledge_get_current_bank() -> str:
"""Get the current memory bank ID. This is the bank where conversations are retained and pages are stored. Use this to tell the user which bank their agent will be bound to."""
return json.dumps({"bank_id": _default_bank_id})
@mcp.tool()
def agent_knowledge_list_pages() -> str:
"""List all your knowledge pages (IDs and names only). Use agent_knowledge_get_page to read the full content of a specific page."""
bid = _bank(bank_id)
resp = _client.request("GET", f"/v1/default/banks/{_encode_bank(bid)}/mental-models", timeout=10)
resp = _client.request("GET", f"/v1/default/banks/{_encode_bank(_default_bank_id)}/mental-models", timeout=10)
return json.dumps(resp, indent=2)
@mcp.tool()
def agent_knowledge_get_page(page_id: str, bank_id: str = "") -> str:
def agent_knowledge_get_page(page_id: str) -> str:
"""Read a specific knowledge page by its ID. Returns the full synthesized content."""
bid = _bank(bank_id)
resp = _client.request(
"GET", f"/v1/default/banks/{_encode_bank(bid)}/mental-models/{page_id}?detail=full", timeout=10
"GET", f"/v1/default/banks/{_encode_bank(_default_bank_id)}/mental-models/{page_id}?detail=full", timeout=10
)
return json.dumps(resp, indent=2)
@mcp.tool()
def agent_knowledge_create_page(page_id: str, name: str, source_query: str, bank_id: str = "") -> str:
def agent_knowledge_create_page(page_id: str, name: str, source_query: str) -> str:
"""Create a new knowledge page. The source_query is a question the system re-asks after each consolidation to rebuild the page from conversation observations. Pages auto-update as you have more conversations."""
bid = _bank(bank_id)
resp = _client.request(
"POST",
f"/v1/default/banks/{_encode_bank(bid)}/mental-models",
f"/v1/default/banks/{_encode_bank(_default_bank_id)}/mental-models",
body={
"id": page_id,
"name": name,
@@ -108,7 +108,7 @@ def agent_knowledge_create_page(page_id: str, name: str, source_query: str, bank
@mcp.tool()
def agent_knowledge_update_page(page_id: str, name: str = "", source_query: str = "", bank_id: str = "") -> str:
def agent_knowledge_update_page(page_id: str, name: str = "", source_query: str = "") -> str:
"""Update a page's name or source query. The content will re-synthesize on next consolidation."""
body = {}
if name:
@@ -117,35 +117,48 @@ def agent_knowledge_update_page(page_id: str, name: str = "", source_query: str
body["source_query"] = source_query
if not body:
return json.dumps({"error": "Provide name or source_query to update"})
bid = _bank(bank_id)
resp = _client.request(
"PATCH", f"/v1/default/banks/{_encode_bank(bid)}/mental-models/{page_id}", body=body, timeout=10
"PATCH", f"/v1/default/banks/{_encode_bank(_default_bank_id)}/mental-models/{page_id}", body=body, timeout=10
)
return json.dumps(resp, indent=2)
@mcp.tool()
def agent_knowledge_delete_page(page_id: str, bank_id: str = "") -> str:
def agent_knowledge_delete_page(page_id: str) -> str:
"""Permanently delete a knowledge page."""
bid = _bank(bank_id)
resp = _client.request("DELETE", f"/v1/default/banks/{_encode_bank(bid)}/mental-models/{page_id}", timeout=10)
resp = _client.request("DELETE", f"/v1/default/banks/{_encode_bank(_default_bank_id)}/mental-models/{page_id}", timeout=10)
return json.dumps(resp, indent=2)
@mcp.tool()
def agent_knowledge_recall(query: str, max_results: int = 10, bank_id: str = "") -> str:
def agent_knowledge_recall(query: str, max_results: int = 10) -> str:
"""Search across all retained conversations and documents for specific facts, numbers, or details not covered by your knowledge pages."""
bid = _bank(bank_id)
resp = _client.recall(bank_id=bid, query=query, max_tokens=max_results, budget="mid", timeout=10)
resp = _client.recall(bank_id=_default_bank_id, query=query, max_tokens=max_results, budget="mid", timeout=10)
return json.dumps(resp, indent=2)
@mcp.tool()
def agent_knowledge_ingest(title: str, content: str, bank_id: str = "") -> str:
"""Upload a document into your memory bank. Pass the full raw content — never summarize before ingesting. The title becomes the document ID (re-ingesting replaces it)."""
bid = _bank(bank_id)
def agent_knowledge_ingest(title: str, content: str) -> str:
"""Upload text content into your memory bank. Pass the full raw content — never summarize before ingesting. The title becomes the document ID (re-ingesting replaces it)."""
doc_id = title.lower().replace(" ", "-")
resp = _client.retain(bank_id=bid, content=content, document_id=doc_id, timeout=15)
resp = _client.retain(bank_id=_default_bank_id, content=content, document_id=doc_id, timeout=15)
return json.dumps(resp, indent=2)
@mcp.tool()
def agent_knowledge_ingest_file(file_path: str) -> str:
"""Ingest a file from disk into your memory bank. Reads the file and uploads its full content. The filename becomes the document ID."""
import os
if not os.path.isfile(file_path):
return json.dumps({"error": f"File not found: {file_path}"})
content = open(file_path, encoding="utf-8").read()
if not content.strip():
return json.dumps({"error": f"File is empty: {file_path}"})
doc_id = os.path.basename(file_path).rsplit(".", 1)[0].lower().replace(" ", "-")
resp = _client.retain(bank_id=_default_bank_id, content=content, document_id=doc_id, timeout=15)
return json.dumps(resp, indent=2)
@@ -1,124 +0,0 @@
#!/usr/bin/env python3
"""Stop hook for subagent: retain conversation to the agent's bank.
Called from a subagent's Stop hook with HINDSIGHT_BANK_ID set in the
environment. Reads the transcript, formats it, and posts to the
agent-specific bank.
This is separate from the main plugin's retain.py which retains to
the global bank derived from config. This script always retains to
the bank specified in HINDSIGHT_BANK_ID.
"""
import json
import os
import sys
import time
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.content import prepare_retention_transcript
from lib.daemon import get_api_url
def read_transcript(transcript_path: str) -> list:
if not transcript_path or not os.path.isfile(transcript_path):
return []
messages = []
try:
with open(transcript_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
if entry.get("type") in ("user", "assistant"):
msg = entry.get("message", {})
if isinstance(msg, dict) and msg.get("role"):
messages.append(msg)
elif "role" in entry and "content" in entry:
messages.append(entry)
except json.JSONDecodeError:
continue
except OSError:
pass
return messages
def main():
bank_id = os.environ.get("HINDSIGHT_BANK_ID")
if not bank_id:
return
config = load_config()
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
return
transcript_path = hook_input.get("transcript_path", "")
session_id = hook_input.get("session_id", "unknown")
messages = read_transcript(transcript_path)
if not messages:
debug_log(config, "Subagent retain: no messages, skipping")
return
transcript, message_count = prepare_retention_transcript(
messages,
retain_roles=["user", "assistant"],
retain_full_window=True,
include_tool_calls=config.get("retainToolCalls", True),
)
if not transcript:
return
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] Subagent retain: {e}", file=sys.stderr)
return
try:
client = HindsightClient(api_url, config.get("hindsightApiToken"))
except ValueError as e:
print(f"[Hindsight] Subagent retain: {e}", file=sys.stderr)
return
document_id = f"{session_id}-{int(time.time() * 1000)}"
debug_log(
config,
f"Subagent retaining to bank '{bank_id}', doc '{document_id}', {message_count} messages",
)
try:
client.retain(
bank_id=bank_id,
content=transcript,
document_id=document_id,
context="claude-code-subagent",
metadata={
"retained_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"message_count": str(message_count),
"session_id": session_id,
},
)
except Exception as e:
print(f"[Hindsight] Subagent retain failed: {e}", file=sys.stderr)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[Hindsight] Subagent retain error: {e}", file=sys.stderr)
sys.exit(0)
@@ -1,56 +0,0 @@
---
name: agent-knowledge
description: Your long-term knowledge pages. Read them at session start. Create new pages when you learn something worth remembering across sessions. Pages auto-update from your conversations via Hindsight.
---
# Agent Knowledge
You have knowledge pages that persist across sessions and auto-update from your conversations.
**How it works:** Your conversations are automatically retained into a Hindsight memory bank. The system extracts observations and uses them to keep your pages current. Each page has a "source query" — a question the system re-answers after every consolidation cycle to rebuild the page content. You create pages; the system maintains them.
## At session start
Call `agent_knowledge_list_pages` to see what pages exist, then `agent_knowledge_get_page` for each one you need.
## Reading
- `agent_knowledge_list_pages()` — list page IDs and names (no content)
- `agent_knowledge_get_page(page_id)` — read the full content of a page
## Creating pages
When you learn something durable — a user preference, a working procedure, performance data — create a page immediately.
`agent_knowledge_create_page(page_id, name, source_query)`
- `page_id`: lowercase with hyphens (`editorial-preferences`)
- `source_query`: a question that produces the page content from observations
Examples:
- `"What are the user's preferences for tone, length, and formatting?"`
- `"What content strategies have performed well or poorly? Include numbers."`
- `"What are the best practices for [topic], preferring our data over generic advice?"`
## Searching memories
`agent_knowledge_recall(query)` — search across all retained conversations and documents for specific facts.
Use when pages don't cover what you need.
## Ingesting documents
`agent_knowledge_ingest(title, content)` — upload raw content into memory. Never summarize before ingesting. Save large content to a file first, read it, then pass the full text.
## Updating and deleting
- `agent_knowledge_update_page(page_id, name?, source_query?)` — change what a page tracks
- `agent_knowledge_delete_page(page_id)` — remove a page
## Important
- Pages update automatically — don't edit content directly
- State preferences clearly in your responses so the system captures them
- Create pages silently — don't announce it to the user
- Prefer fewer broad pages over many narrow ones
@@ -0,0 +1,86 @@
---
name: create-agent
description: Create a new Hindsight-powered subagent with long-term memory. Use when the user wants a specialized agent that learns and remembers across sessions.
allowed-tools: Bash(ls ~/.self-driving-agents/*) Bash(cat ~/.self-driving-agents/*) Write mcp__hindsight__*
---
# Create Hindsight Agent
Create a new subagent with long-term memory powered by Hindsight.
## What to ask the user
1. **Agent name** — lowercase with hyphens (e.g. `code-reviewer`, `project-manager`)
2. **What the agent does** — one sentence for the description
3. **Any initial knowledge to seed** — files, docs, or context to ingest
## Create the subagent file
Write to `~/.claude/agents/<name>.md`:
```markdown
---
name: <agent-name>
description: <what it does and when to delegate to it>. It has access to knowledge pages and memory search via Hindsight.
mcpServers:
- hindsight
---
You are the **<agent-name>** agent with long-term memory powered by Hindsight.
## Startup — run these steps immediately
1. Call `agent_knowledge_list_pages` to see your knowledge pages.
2. Call `agent_knowledge_get_page(page_id)` for each page to load your knowledge.
3. Use this knowledge to inform everything you do in this conversation.
## Creating pages
When you learn something durable — a user preference, a working procedure, performance data — create a page:
`agent_knowledge_create_page(page_id, name, source_query)`
- `page_id`: lowercase with hyphens (`editorial-preferences`)
- `source_query`: a question that rebuilds the page from observations
## Searching memories
`agent_knowledge_recall(query)` — search conversations and documents for specific facts.
## Ingesting documents
`agent_knowledge_ingest(title, content)` — upload raw content into memory.
## Updating and deleting
- `agent_knowledge_update_page(page_id, name?, source_query?)`
- `agent_knowledge_delete_page(page_id)`
## Important
- Pages update automatically — don't edit content directly
- Create pages silently — don't announce it to the user
- Prefer fewer broad pages over many narrow ones
<ADD AGENT-SPECIFIC INSTRUCTIONS HERE — what it reviews, how it responds, what domain knowledge it applies>
```
## Rules
- Always include `mcpServers: [hindsight]` — this wires up the Hindsight memory tools
- Keep the startup steps and tool instructions verbatim — they're the Hindsight scaffolding
- Customize the description (used by Claude to decide when to delegate)
- Add agent-specific sections AFTER the Hindsight scaffolding (e.g. "## What I review for", "## My approach")
- Do NOT pass `bank_id` on any tool call — the plugin automatically resolves the correct bank at runtime. All agents in a project share the same memory bank. Never override this.
- Call `agent_knowledge_get_current_bank` to find out which bank is active, and tell the user: "This agent will be bound to bank `<bank_id>` — the same bank your conversations are retained to."
## After creation
1. Confirm the file was written
2. **Ingest seed content** — if the user points to a directory of files (e.g. `~/.self-driving-agents/claude-code/<agent>/`):
- List files with `ls`
- For EACH file, call `agent_knowledge_ingest_file(file_path)` with the full path — this reads and ingests the file server-side
- Use `agent_knowledge_ingest(title, content)` only for inline text the user provides directly
3. **Create 3 initial knowledge pages** — based on the ingested content, call `agent_knowledge_create_page(page_id, name, source_query)` 3 times with source queries that will produce useful synthesized pages for this agent
4. Tell the user they can invoke the agent with `@<agent-name>` or Claude will auto-delegate based on the description
5. Suggest restarting Claude Code or running `/agents` to load the new agent
@@ -1,355 +0,0 @@
"""Self-driving agents Hindsight plugin for Hermes.
Registers agent_knowledge_* tools as a regular plugin (not a memory provider),
so it coexists with the bundled hindsight memory provider or any other provider.
Config read from ~/.self-driving-agents/hermes/<agent>/config.json:
{ "api_url": "...", "api_token": "...", "bank_id": "..." }
"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Any
import httpx
logger = logging.getLogger(__name__)
PAGE_DEFAULTS = {
"mode": "delta",
"refresh_after_consolidation": True,
"exclude_mental_models": True,
"fact_types": ["observation"],
}
def _get_hermes_home() -> Path:
"""Get the active HERMES_HOME (respects profile isolation)."""
env = os.environ.get("HERMES_HOME")
if env:
return Path(env)
return Path.home() / ".hermes"
def _load_config() -> dict | None:
"""Load Hindsight config from the active Hermes profile.
Reads the same config.json the bundled hindsight provider uses,
so both share the same bank, API URL, and credentials.
"""
cfg_path = _get_hermes_home() / "hindsight" / "config.json"
if not cfg_path.exists():
return None
try:
cfg = json.loads(cfg_path.read_text())
# Normalize field names (bundled provider uses api_url/api_key,
# we expose as api_url/api_token for consistency with other harnesses)
return {
"api_url": cfg.get("api_url", ""),
"api_token": cfg.get("api_key", ""),
"bank_id": cfg.get("bank_id", "hermes"),
}
except Exception:
return None
def _api(
api_url: str,
path: str,
method: str = "GET",
body: dict | None = None,
token: str | None = None,
timeout: float = 30.0,
) -> Any:
headers: dict[str, str] = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
resp = httpx.request(
method,
f"{api_url}{path}",
json=body,
headers=headers,
timeout=timeout,
)
resp.raise_for_status()
return resp.json() if resp.content else {}
def _is_available() -> bool:
return _load_config() is not None
# ── Tool handlers ───────────────────────────────────────
def _handle_list_pages(args: dict, **kwargs: Any) -> str:
config = _load_config()
if not config:
return json.dumps({"error": "Plugin not configured"})
api_url = config["api_url"].rstrip("/")
token = config.get("api_token")
bank_id = config["bank_id"]
try:
result = _api(api_url, f"/v1/default/banks/{bank_id}/mental-models?detail=metadata", "GET", token=token)
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"error": str(e)})
def _handle_get_page(args: dict, **kwargs: Any) -> str:
config = _load_config()
if not config:
return json.dumps({"error": "Plugin not configured"})
api_url = config["api_url"].rstrip("/")
token = config.get("api_token")
bank_id = config["bank_id"]
try:
result = _api(api_url, f"/v1/default/banks/{bank_id}/mental-models/{args['page_id']}", "GET", token=token)
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"error": str(e)})
def _handle_create_page(args: dict, **kwargs: Any) -> str:
config = _load_config()
if not config:
return json.dumps({"error": "Plugin not configured"})
api_url = config["api_url"].rstrip("/")
token = config.get("api_token")
bank_id = config["bank_id"]
try:
result = _api(
api_url,
f"/v1/default/banks/{bank_id}/mental-models",
"POST",
body={
"id": args["page_id"],
"name": args["name"],
"source_query": args["source_query"],
"max_tokens": 4096,
"trigger": PAGE_DEFAULTS,
},
token=token,
)
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"error": str(e)})
def _handle_update_page(args: dict, **kwargs: Any) -> str:
config = _load_config()
if not config:
return json.dumps({"error": "Plugin not configured"})
api_url = config["api_url"].rstrip("/")
token = config.get("api_token")
bank_id = config["bank_id"]
try:
body: dict[str, str] = {}
if args.get("name"):
body["name"] = args["name"]
if args.get("source_query"):
body["source_query"] = args["source_query"]
result = _api(api_url, f"/v1/default/banks/{bank_id}/mental-models/{args['page_id']}", "PATCH", body=body, token=token)
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"error": str(e)})
def _handle_delete_page(args: dict, **kwargs: Any) -> str:
config = _load_config()
if not config:
return json.dumps({"error": "Plugin not configured"})
api_url = config["api_url"].rstrip("/")
token = config.get("api_token")
bank_id = config["bank_id"]
try:
_api(api_url, f"/v1/default/banks/{bank_id}/mental-models/{args['page_id']}", "DELETE", token=token)
return json.dumps({"success": True})
except Exception as e:
return json.dumps({"error": str(e)})
def _handle_recall(args: dict, **kwargs: Any) -> str:
config = _load_config()
if not config:
return json.dumps({"error": "Plugin not configured"})
api_url = config["api_url"].rstrip("/")
token = config.get("api_token")
bank_id = config["bank_id"]
try:
result = _api(
api_url,
f"/v1/default/banks/{bank_id}/memories/recall",
"POST",
body={"query": args["query"], "max_results": args.get("max_results", 10)},
token=token,
)
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"error": str(e)})
def _handle_ingest(args: dict, **kwargs: Any) -> str:
config = _load_config()
if not config:
return json.dumps({"error": "Plugin not configured"})
api_url = config["api_url"].rstrip("/")
token = config.get("api_token")
bank_id = config["bank_id"]
try:
doc_id = args["title"].lower().replace(" ", "-")
result = _api(
api_url,
f"/v1/default/banks/{bank_id}/memories",
"POST",
body={"items": [{"content": args["content"], "document_id": doc_id}], "async": True},
token=token,
)
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"error": str(e)})
# ── Tool schemas ────────────────────────────────────────
_TOOLS = [
(
"agent_knowledge_list_pages",
{
"name": "agent_knowledge_list_pages",
"description": "List all your knowledge pages (IDs and names only). Use agent_knowledge_get_page to read the full content of a specific page.",
"parameters": {"type": "object", "properties": {}},
},
_handle_list_pages,
"📚",
),
(
"agent_knowledge_get_page",
{
"name": "agent_knowledge_get_page",
"description": "Read a specific knowledge page by its ID. Returns the full synthesized content.",
"parameters": {
"type": "object",
"properties": {
"page_id": {"type": "string", "description": "Page ID (e.g. 'user-preferences')"},
},
"required": ["page_id"],
},
},
_handle_get_page,
"📖",
),
(
"agent_knowledge_create_page",
{
"name": "agent_knowledge_create_page",
"description": (
"Create a new knowledge page. The source_query is a question the system "
"re-asks after each consolidation to rebuild the page from conversation observations."
),
"parameters": {
"type": "object",
"properties": {
"page_id": {"type": "string", "description": "Unique page ID, lowercase with hyphens"},
"name": {"type": "string", "description": "Human-readable page name"},
"source_query": {"type": "string", "description": "The question that rebuilds this page"},
},
"required": ["page_id", "name", "source_query"],
},
},
_handle_create_page,
"📝",
),
(
"agent_knowledge_update_page",
{
"name": "agent_knowledge_update_page",
"description": "Update a page's name or source query. Content re-synthesizes on next consolidation.",
"parameters": {
"type": "object",
"properties": {
"page_id": {"type": "string", "description": "Page ID to update"},
"name": {"type": "string", "description": "New name (optional)"},
"source_query": {"type": "string", "description": "New source query (optional)"},
},
"required": ["page_id"],
},
},
_handle_update_page,
"✏️",
),
(
"agent_knowledge_delete_page",
{
"name": "agent_knowledge_delete_page",
"description": "Permanently delete a knowledge page.",
"parameters": {
"type": "object",
"properties": {
"page_id": {"type": "string", "description": "Page ID to delete"},
},
"required": ["page_id"],
},
},
_handle_delete_page,
"🗑️",
),
(
"agent_knowledge_recall",
{
"name": "agent_knowledge_recall",
"description": "Search across all retained conversations and documents for specific facts, numbers, or details not covered by your knowledge pages.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "What to search for"},
"max_results": {"type": "number", "description": "Max results (default 10)"},
},
"required": ["query"],
},
},
_handle_recall,
"🔍",
),
(
"agent_knowledge_ingest",
{
"name": "agent_knowledge_ingest",
"description": (
"Upload a document into your memory bank. Pass the full raw content — "
"never summarize before ingesting. The title becomes the document ID."
),
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Document title (becomes the document ID)"},
"content": {"type": "string", "description": "Full raw document content"},
},
"required": ["title", "content"],
},
},
_handle_ingest,
"📥",
),
]
# ── Registration ────────────────────────────────────────
def register(ctx: Any) -> None:
"""Register agent_knowledge_* tools. Called once by the Hermes plugin loader."""
for name, schema, handler, emoji in _TOOLS:
ctx.register_tool(
name=name,
toolset="hindsight-sda",
schema=schema,
handler=handler,
check_fn=_is_available,
emoji=emoji,
)
logger.info("[hindsight-sda] registered %d tools", len(_TOOLS))
@@ -1,7 +0,0 @@
name: hindsight-sda
version: 0.1.0
description: "Self-driving agents knowledge tools powered by Hindsight. Provides agent_knowledge_* tools for managing knowledge pages, recall, and ingestion."
kind: standalone
pip_dependencies:
- "httpx>=0.27"
requires_env: []
@@ -9,8 +9,7 @@
},
"files": [
"dist",
"skill",
"hermes-plugin"
"skill"
],
"scripts": {
"build": "tsc",
+63 -136
View File
@@ -742,73 +742,6 @@ async function promptClaudeConfig(
return { apiUrl, bankId, apiToken };
}
// ── Claude Code plugin management ─────────────────────
const CLAUDE_CODE_USER_CONFIG_DIR = join(homedir(), ".hindsight");
const CLAUDE_CODE_USER_CONFIG_PATH = join(CLAUDE_CODE_USER_CONFIG_DIR, "claude-code.json");
function readClaudeCodeConfig(): any {
if (!existsSync(CLAUDE_CODE_USER_CONFIG_PATH)) return null;
return JSON.parse(readFileSync(CLAUDE_CODE_USER_CONFIG_PATH, "utf-8"));
}
function writeClaudeCodeConfig(config: any): void {
mkdirSync(CLAUDE_CODE_USER_CONFIG_DIR, { recursive: true });
writeFileSync(CLAUDE_CODE_USER_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
}
function resolveFromClaudeCode(agentId: string): {
apiUrl: string;
bankId: string;
apiToken?: string;
} {
const config = readClaudeCodeConfig();
if (!config) throw new Error("Claude Code config not found at " + CLAUDE_CODE_USER_CONFIG_PATH);
const apiUrl = config.apiUrl || HINDSIGHT_CLOUD_API_URL;
const apiToken = config.apiToken || undefined;
let bankId: string;
if (config.dynamicBankId === false && config.bankId) {
bankId = config.bankId;
} else {
const granularity: string[] = config.dynamicBankGranularity || ["agent"];
const fieldMap: Record<string, string> = {
agent: agentId,
};
const base = granularity.map((f) => encodeURIComponent(fieldMap[f] || "unknown")).join("::");
bankId = config.bankIdPrefix ? `${config.bankIdPrefix}-${base}` : base;
}
return { apiUrl, bankId, apiToken };
}
async function ensureClaudeCodePlugin(agentId: string): Promise<{ apiUrl: string; bankId: string; apiToken?: string }> {
// Write or update config
const config = readClaudeCodeConfig() || {};
config.agentName = agentId;
config.enableKnowledgeTools = true;
// Always prompt for Hindsight connection — each agent install needs correct API + token
let resolvedApiUrl = config.hindsightApiUrl || `http://localhost:${config.apiPort || 9077}`;
let resolvedBankId = agentId;
let resolvedApiToken = config.hindsightApiToken || undefined;
if (process.stdin.isTTY) {
const claudeConfig = await promptClaudeConfig(agentId);
config.hindsightApiUrl = claudeConfig.apiUrl;
config.hindsightApiToken = claudeConfig.apiToken;
config.dynamicBankId = false;
resolvedApiUrl = claudeConfig.apiUrl;
resolvedBankId = claudeConfig.bankId;
resolvedApiToken = claudeConfig.apiToken;
}
writeClaudeCodeConfig(config);
return { apiUrl: resolvedApiUrl, bankId: resolvedBankId, apiToken: resolvedApiToken };
}
// ── Main ────────────────────────────────────────────────
async function main() {
@@ -975,7 +908,69 @@ async function main() {
bankId = claudeConfig.bankId;
apiToken = claudeConfig.apiToken;
} else if (harness === "claude-code") {
({ apiUrl, bankId, apiToken } = await ensureClaudeCodePlugin(agentId));
// Claude Code: just save content locally, Claude handles the rest via skill
const contentDir = join(homedir(), ".self-driving-agents", "claude-code", agentId);
mkdirSync(contentDir, { recursive: true });
// Copy content files to the local dir
const contentFiles = findContentFiles(dir);
for (const relPath of contentFiles) {
const destPath = join(contentDir, relPath);
mkdirSync(join(destPath, ".."), { recursive: true });
writeFileSync(destPath, readFileSync(join(dir, relPath), "utf-8"));
}
// Copy bank-template.json if present (has mental model definitions)
const templateSrc = join(dir, "bank-template.json");
if (existsSync(templateSrc)) {
writeFileSync(join(contentDir, "bank-template.json"), readFileSync(templateSrc, "utf-8"));
}
p.log.success(`Content saved to ${color.dim(contentDir)} (${contentFiles.length} files)`);
// Auto-approve hindsight MCP tools and skills in user settings
const userSettingsPath = join(homedir(), ".claude", "settings.json");
let userSettings: Record<string, any> = {};
if (existsSync(userSettingsPath)) {
try {
userSettings = JSON.parse(readFileSync(userSettingsPath, "utf-8"));
} catch {
/* ignore */
}
}
const allowedTools: string[] = userSettings.allowedTools || [];
const toolsToAllow = [
"mcp__hindsight__*",
"Skill(hindsight-memory:create-agent)",
`Bash(ls ~/.self-driving-agents/*)`,
`Bash(cat ~/.self-driving-agents/*)`,
];
let updated = false;
for (const tool of toolsToAllow) {
if (!allowedTools.includes(tool)) {
allowedTools.push(tool);
updated = true;
}
}
if (updated) {
userSettings.allowedTools = allowedTools;
writeFileSync(userSettingsPath, JSON.stringify(userSettings, null, 2) + "\n");
p.log.success("Auto-approved hindsight tools in Claude Code");
}
const hasBankTemplate = existsSync(join(contentDir, "bank-template.json"));
const prompt = hasBankTemplate
? `Use /hindsight-memory:create-agent to create a "${agentId}" agent. Then ingest all files from ${contentDir}/ (skip bank-template.json). Read ${contentDir}/bank-template.json and create the exact mental models (knowledge pages) defined in its "mental_models" array using agent_knowledge_create_page for each one.`
: `Use /hindsight-memory:create-agent to create a "${agentId}" agent. Then ingest all files from ${contentDir}/ and create 3 knowledge pages that make sense based on the content.`;
p.note(
[
`${color.dim("1.")} Start Claude Code`,
`${color.dim("2.")} Say: ${color.cyan(prompt)}`,
].join("\n"),
"Next steps"
);
p.outro(color.green(`'${agentId}' content ready`));
cleanup?.();
return;
} else {
p.cancel(`Unknown harness: ${harness}`);
process.exit(1);
@@ -1050,68 +1045,6 @@ async function main() {
// Step 6: Create agent + install skill (hermes handled in ensureHermesPlugin)
if (harness === "claude") {
claudeSkillZip = await generateClaudeSkill(agentId, apiUrl, bankId, apiToken);
} else if (harness === "claude-code") {
// Install per-agent subagent at ~/.claude/agents/<agentId>.md
const agentsDir = join(homedir(), ".claude", "agents");
mkdirSync(agentsDir, { recursive: true });
const agentFile = join(agentsDir, `${agentId}.md`);
writeFileSync(
agentFile,
`---
name: ${agentId}
description: ${agentId} agent with long-term memory. Delegate to this agent for tasks related to ${agentId.replace(/-/g, " ")}. It has access to knowledge pages and memory search via Hindsight.
skills:
- agent-knowledge
mcpServers:
- hindsight
hooks:
Stop:
- hooks:
- type: command
command: HINDSIGHT_BANK_ID="${bankId}" python3 "\${CLAUDE_PLUGIN_ROOT}/scripts/subagent_retain.py"
timeout: 15
async: true
---
You are the **${agentId}** agent with long-term memory powered by Hindsight.
## Startup — run these steps immediately
1. Call \`agent_knowledge_list_pages(bank_id="${bankId}")\` to see your knowledge pages.
2. Call \`agent_knowledge_get_page(page_id, bank_id="${bankId}")\` for each page to load your knowledge.
3. Use this knowledge to inform everything you do in this conversation.
## Creating pages
When you learn something durable — a user preference, a working procedure, performance data — create a page:
\`agent_knowledge_create_page(page_id, name, source_query, bank_id="${bankId}")\`
- \`page_id\`: lowercase with hyphens (\`editorial-preferences\`)
- \`source_query\`: a question that rebuilds the page from observations
## Searching memories
\`agent_knowledge_recall(query, bank_id="${bankId}")\` — search conversations and documents for specific facts.
## Ingesting documents
\`agent_knowledge_ingest(title, content, bank_id="${bankId}")\` — upload raw content into memory.
## Updating and deleting
- \`agent_knowledge_update_page(page_id, name?, source_query?, bank_id="${bankId}")\`
- \`agent_knowledge_delete_page(page_id, bank_id="${bankId}")\`
## Important
- Always pass \`bank_id="${bankId}"\` on every agent_knowledge_* tool call
- Pages update automatically — don't edit content directly
- Create pages silently — don't announce it to the user
- Prefer fewer broad pages over many narrow ones
`
);
p.log.success(`Subagent installed at ${color.dim(agentFile)}`);
} else if (harness === "hermes") {
// Skill + plugin already installed by ensureHermesPlugin
} else if (harness === "nemoclaw") {
@@ -1189,12 +1122,6 @@ When you learn something durable — a user preference, a working procedure, per
`${color.dim("3.")} Allowlist the API host: Settings → Capabilities → add ${color.cyan(apiHost)}`,
`${color.dim("4.")} Start a conversation and type ${color.cyan(`/${agentId}`)} to activate the agent`,
];
} else if (harness === "claude-code") {
nextSteps = [
`${color.dim("1.")} Start Claude Code: ${color.cyan("claude")}`,
`${color.dim("2.")} Claude will auto-delegate to ${color.cyan(agentId)} when relevant, or mention ${color.cyan(`@${agentId}`)}`,
`${color.dim("3.")} Conversations are automatically retained via hooks`,
];
} else if (harness === "hermes") {
nextSteps = [`${color.dim("1.")} hermes -p ${agentId} chat`];
} else if (harness === "nemoclaw") {
@@ -554,10 +554,15 @@ describe("claude-code config resolution", () => {
} else if (config.dynamicBankId) {
const granularity: string[] = config.dynamicBankGranularity || ["agent", "project"];
const fieldMap: Record<string, string> = {
agent: config.agentName || agentId, project: "unknown",
session: "unknown", channel: "default", user: "anonymous",
agent: config.agentName || agentId,
project: "unknown",
session: "unknown",
channel: "default",
user: "anonymous",
};
const base = granularity.map((f: string) => encodeURIComponent(fieldMap[f] || "unknown")).join("::");
const base = granularity
.map((f: string) => encodeURIComponent(fieldMap[f] || "unknown"))
.join("::");
bankId = config.bankIdPrefix ? `${config.bankIdPrefix}-${base}` : base;
} else {
bankId = config.bankIdPrefix ? `${config.bankIdPrefix}-${agentId}` : agentId;
@@ -566,7 +571,10 @@ describe("claude-code config resolution", () => {
}
it("uses external API URL when set", () => {
const r = resolveFromConfig("agent", { hindsightApiUrl: "https://api.example.com", hindsightApiToken: "tok" });
const r = resolveFromConfig("agent", {
hindsightApiUrl: "https://api.example.com",
hindsightApiToken: "tok",
});
expect(r.apiUrl).toBe("https://api.example.com");
expect(r.apiToken).toBe("tok");
});
@@ -587,7 +595,11 @@ describe("claude-code config resolution", () => {
});
it("computes dynamic bankId", () => {
const r = resolveFromConfig("seo", { dynamicBankId: true, agentName: "seo", dynamicBankGranularity: ["agent"] });
const r = resolveFromConfig("seo", {
dynamicBankId: true,
agentName: "seo",
dynamicBankGranularity: ["agent"],
});
expect(r.bankId).toBe("seo");
});
@@ -174,6 +174,7 @@ To switch between backends:
| `HINDSIGHT_API_LLM_GROQ_SERVICE_TIER` | Groq service tier: `on_demand`, `flex`, `auto` | `auto` |
| `HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER` | OpenAI service tier: `flex` for 50% cost savings (OpenAI Flex Processing) | None (default) |
| `HINDSIGHT_API_LLM_EXTRA_BODY` | JSON dict merged into `extra_body` for all OpenAI-compatible API calls. Useful for custom model servers (e.g., vLLM `chat_template_kwargs`). | `null` |
| `HINDSIGHT_API_LLM_DEFAULT_HEADERS` | JSON dict passed as `default_headers` to provider SDK clients. Used by operators routing through proxies / request-tracing middleware (e.g. Cloudflare AI Gateway, Helicone, corporate proxies). Currently wired into the Anthropic provider; other providers can opt in. | `null` |
| `HINDSIGHT_API_LLM_GEMINI_SAFETY_SETTINGS` | JSON-encoded list of `{category, threshold}` dicts for Gemini/VertexAI content safety filtering | `null` |
**Provider Examples**